CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-core

Core behavioral rules and skills for NanoClaw personal assistant agents. Always-on rules for communication, verification, memory, and formatting.

77

Quality

97%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

High

Do not use without reviewing

Overview
Quality
Evals
Security
Files

query-message-history.pyskills/query-history/scripts/

#!/usr/bin/env python3
"""
Query messages.db for the current chat by keyword and/or sender.

Invoked from `rules/context-recovery.md` when the agent is about to
claim lost context. Replaces the inlined Python snippet that lived in
the rule body — extraction tracked in jbaruch/nanoclaw-admin#181 (umbrella
RULES.md diet jbaruch/nanoclaw-admin#180).

Per `.github/copilot-instructions.md`, the on-tier invocation path is
`/home/node/.claude/skills/tessl__query-history/scripts/query-message-history.py`.

Usage:
    python3 .../query-message-history.py --keyword "release plan"
    python3 .../query-message-history.py --sender ligolnik
    python3 .../query-message-history.py --keyword "JCON" --sender ligolnik --limit 50
    python3 .../query-message-history.py --keyword "JCON" --limit 50 --offset 50

Env vars:
    NANOCLAW_CHAT_JID — required. Scopes the query to the current chat.
    NANOCLAW_DB       — optional, default `/workspace/store/messages.db`.

Output (stdout, single-line JSON — emitted on exit 0 and exit 1;
usage errors at exit 2 print a stderr diagnostic only, no JSON):
    {
      "rows": [
        {"id", "timestamp", "sender_name", "content", "is_from_me",
         "content_truncated"},
        ...
      ],
      "chat_jid": str,
      "query": {"keyword": str|null, "sender": str|null, "limit": int,
                "offset": int},
      "truncated": bool,
      "rows_dropped": int,
      "error": str|null
    }

    The serialized payload is capped at MAX_OUTPUT_BYTES on every
    JSON-emitting path (success and error) so the result honors the
    25 KB single-tool-result budget from `rules/query-size-limits.md`.
    When the cap engages: each row's `content` is first clipped to
    PER_ROW_CONTENT_CHARS (that row's `content_truncated` flips true),
    then whole rows are dropped oldest-first (`rows_dropped` counts
    them, `truncated` flips true). Envelope strings (query echoes,
    error) clip to ENVELOPE_FIELD_CHARS so the zero-row envelope is
    bounded too. Re-query with narrower filters or `--offset` batches
    to see what was cut.

Exit codes:
    0 — clean success (rows may be empty).
    1 — runtime failure (NANOCLAW_CHAT_JID missing, DB access failure,
        sqlite error). Diagnostic on stderr, empty-rows JSON on stdout.
    2 — usage error: no --keyword AND no --sender, or --limit non-
        positive, or --limit above the 50-row messages.db cap from
        `rules/query-size-limits.md`, or --offset negative or beyond
        SQLite's signed 64-bit integer range.
"""

import argparse
import json
import os
import sqlite3
import sys
from typing import Optional

DEFAULT_DB = "/workspace/store/messages.db"
DEFAULT_LIMIT = 20
# Hard cap mirrors `rules/query-size-limits.md` (max 50 rows for messages.db).
# Anything above this defeats the context-budget guard the rule set enforces.
MAX_LIMIT = 50
# Serialized-output budget mirrors the 25 KB single-tool-result limit in
# `rules/query-size-limits.md`. 25 KB = 25,000 bytes (the rule says KB,
# not KiB). The row cap alone doesn't enforce it — a handful of long
# messages can blow the budget at 50 rows.
MAX_OUTPUT_BYTES = 25_000
# Per-row content clip applied before whole rows are dropped: favors
# breadth (more rows, each capped) over depth (few complete rows). Chat
# messages are rarely this long; pasted logs and forwarded walls of text
# are the case this guards.
PER_ROW_CONTENT_CHARS = 4000
# SQLite binds integers as signed 64-bit; a Python int past this raises
# OverflowError at bind time (not sqlite3.Error), crashing with no JSON.
# Reject over-range --offset as a usage error instead.
SQLITE_MAX_INT = 2**63 - 1
# Clip for envelope strings echoed into every payload (query.keyword,
# query.sender, error). Bounds the zero-row envelope so cap_payload's
# row-dropping loop can always land under MAX_OUTPUT_BYTES — a
# pathological multi-KB --keyword would otherwise bust the budget with
# no rows left to drop.
ENVELOPE_FIELD_CHARS = 500
LIKE_ESCAPE_CHAR = "\\"


def escape_like(value: str) -> str:
    """Make user input safe to interpolate as a LIKE pattern fragment.

    SQLite's `LIKE` treats `%` and `_` inside bound values as wildcards even
    with parameter binding — `?` only escapes SQL-syntax characters, not
    pattern metacharacters. So `--keyword 'john_doe'` would still match
    `johnXdoe`, defeating "literal context recovery". Escape both
    metacharacters (and the escape char itself) and pair the LIKE with an
    explicit `ESCAPE '\\\\'` clause in the query.
    """
    return (
        value.replace(LIKE_ESCAPE_CHAR, LIKE_ESCAPE_CHAR * 2)
        .replace("%", LIKE_ESCAPE_CHAR + "%")
        .replace("_", LIKE_ESCAPE_CHAR + "_")
    )


def build_query(has_keyword: bool, has_sender: bool) -> str:
    """Compose the SELECT.

    Both filters are optional, but at least one must be set (caller-enforced).
    Keyword binds to `content`, sender binds to `sender_name` — the two are
    AND-combined when both are present, narrowing the result to messages
    matching both. Wildcards inside the bound value are escaped via
    `escape_like` + `ESCAPE '\\\\'` so user input matches literally.
    """
    where = ["chat_jid = ?"]
    if has_keyword:
        where.append("content LIKE '%' || ? || '%' ESCAPE '\\'")
    if has_sender:
        where.append("sender_name LIKE '%' || ? || '%' ESCAPE '\\'")
    return f"""
        SELECT id, timestamp, sender_name, content, is_from_me
        FROM messages
        WHERE {' AND '.join(where)}
        ORDER BY timestamp DESC, id DESC
        LIMIT ? OFFSET ?
    """


def parse_args(argv: list) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Search messages.db for the current chat.",
    )
    parser.add_argument("--keyword", help="Substring to match against `content`.")
    parser.add_argument(
        "--sender",
        help="Substring to match against `sender_name` (matches both display "
        "name and `@username` since the column stores `Display (@username)`).",
    )
    parser.add_argument(
        "--limit",
        type=int,
        default=DEFAULT_LIMIT,
        help=f"Max rows to return (default {DEFAULT_LIMIT}).",
    )
    parser.add_argument(
        "--offset",
        type=int,
        default=0,
        help="Rows to skip before returning results (default 0). Batch "
        "through result sets larger than the row cap with successive "
        "--offset values per rules/query-size-limits.md.",
    )
    return parser.parse_args(argv)


def make_payload(
    rows: list,
    chat_jid: str,
    keyword: Optional[str],
    sender: Optional[str],
    limit: int,
    offset: int,
    error: Optional[str],
) -> dict:
    """Single canonical output shape — every key present on success and
    error paths so the agent (or a downstream consumer) doesn't have to
    special-case error JSON. Envelope strings clip to
    ENVELOPE_FIELD_CHARS so the zero-row envelope stays bounded on
    every path (see cap_payload); a clipped envelope field flips
    `truncated` just like row-level cuts do."""
    clipped = False

    def clip(value: Optional[str]) -> Optional[str]:
        nonlocal clipped
        if value is None:
            return None
        if len(value) > ENVELOPE_FIELD_CHARS:
            clipped = True
            return value[:ENVELOPE_FIELD_CHARS]
        return value

    return {
        "rows": rows,
        "chat_jid": clip(chat_jid),
        "query": {
            "keyword": clip(keyword),
            "sender": clip(sender),
            "limit": limit,
            "offset": offset,
        },
        "truncated": clipped,
        "rows_dropped": 0,
        "error": clip(error),
    }


def cap_payload(payload: dict, max_bytes: int = MAX_OUTPUT_BYTES) -> dict:
    """Enforce the serialized-output budget in place.

    Phase 1 clips each row's `content` to PER_ROW_CONTENT_CHARS (marking
    that row's `content_truncated`). Phase 2 drops whole rows oldest-first
    (rows are ordered newest-first, so pops come off the tail) until the
    single-line JSON fits `max_bytes`, counting them in `rows_dropped`.
    Either phase engaging flips the top-level `truncated` flag. A payload
    already under budget passes through with only Phase-1 marking applied
    when a row exceeds the per-row clip."""

    def size(p: dict) -> int:
        # +1 for the trailing newline print() appends — the budget bounds
        # the full stdout tool result, not just the JSON body.
        return len(json.dumps(p).encode("utf-8")) + 1

    for row in payload["rows"]:
        content = row.get("content") or ""
        if len(content) > PER_ROW_CONTENT_CHARS:
            row["content"] = content[:PER_ROW_CONTENT_CHARS]
            row["content_truncated"] = True
            payload["truncated"] = True

    while payload["rows"] and size(payload) > max_bytes:
        payload["rows"].pop()
        payload["rows_dropped"] += 1
        payload["truncated"] = True

    return payload


def main(argv: Optional[list] = None) -> int:
    args = parse_args(argv if argv is not None else sys.argv[1:])

    if not args.keyword and not args.sender:
        sys.stderr.write("Usage error: provide at least one of --keyword or --sender.\n")
        return 2

    if args.limit <= 0:
        sys.stderr.write(
            f"Usage error: --limit={args.limit} is non-positive; use a positive integer.\n"
        )
        return 2
    if args.limit > MAX_LIMIT:
        sys.stderr.write(
            f"Usage error: --limit={args.limit} exceeds the messages.db cap of "
            f"{MAX_LIMIT} rows (rules/query-size-limits.md). "
            f"Re-run with a smaller limit; batch through larger result sets "
            f"with successive --offset values.\n"
        )
        return 2
    if args.offset < 0:
        sys.stderr.write(
            f"Usage error: --offset={args.offset} is negative; use zero or a "
            f"positive integer.\n"
        )
        return 2
    if args.offset > SQLITE_MAX_INT:
        # Don't echo the value — it can be arbitrarily many digits.
        sys.stderr.write(
            "Usage error: --offset exceeds SQLite's signed 64-bit integer "
            "range; use a smaller offset.\n"
        )
        return 2

    chat_jid = os.environ.get("NANOCLAW_CHAT_JID", "")
    if not chat_jid:
        err = "NANOCLAW_CHAT_JID not set — required env var for chat scope."
        sys.stderr.write(err + "\n")
        print(
            json.dumps(
                cap_payload(
                    make_payload([], "", args.keyword, args.sender, args.limit, args.offset, err)
                )
            )
        )
        return 1

    db_path = os.environ.get("NANOCLAW_DB", DEFAULT_DB)
    sql = build_query(bool(args.keyword), bool(args.sender))
    params: list = [chat_jid]
    if args.keyword:
        params.append(escape_like(args.keyword))
    if args.sender:
        params.append(escape_like(args.sender))
    params.append(args.limit)
    params.append(args.offset)

    # Open the DB read-only via URI — `sqlite3.connect(path)` would otherwise
    # create an empty file at a missing/mistyped NANOCLAW_DB and silently
    # fail with `no such table` later, masking the misconfiguration and
    # leaving a bogus DB file on disk.
    db_uri = "file:" + db_path + "?mode=ro"
    conn = None
    try:
        conn = sqlite3.connect(db_uri, timeout=5, uri=True)
        cursor = conn.execute(sql, params)
        rows = [
            {
                "id": r[0],
                "timestamp": r[1],
                "sender_name": r[2],
                "content": r[3],
                "is_from_me": bool(r[4]),
                "content_truncated": False,
            }
            for r in cursor.fetchall()
        ]
    except sqlite3.Error as e:
        err = f"DB access failed: {e}"
        sys.stderr.write(err + "\n")
        print(
            json.dumps(
                cap_payload(
                    make_payload(
                        [], chat_jid, args.keyword, args.sender, args.limit, args.offset, err
                    )
                )
            )
        )
        return 1
    finally:
        if conn is not None:
            conn.close()

    payload = cap_payload(
        make_payload(rows, chat_jid, args.keyword, args.sender, args.limit, args.offset, None)
    )
    print(json.dumps(payload))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

skills

CHANGELOG.md

pyrightconfig.json

README.md

requirements-dev.txt

tile.json