Skill: jgolebiowski/distil-open-claw-pii (version 1.1.1)

--- name: distil-pii-redactor description: Redact PII from text locally using a fine-tuned 1B SLM. Text never leaves your machine. Handles names, emails, phones, addresses, SSNs, credit cards, IBANs, and more. version: 1.1.1 tools: - Bash --- # PII Redaction Skill ## When to use Use this skill when the user asks to **redact**, **anonymize**, **sanitize**, or **remove PII / personal data** from text. ## Privacy guarantee **CRITICAL: NEVER include the user's raw input text in your own responses, context, or reasoning.** The entire point of this skill is that the frontier LLM (you) never sees the PII. You pass the text directly to the redaction script and only return the redacted output. ## Prerequisites - Python 3 - `curl` (for model download) The setup script handles everything else (model download + server startup). ## First-time setup If the model server is not running yet, run: ```bash bash scripts/setup.sh ``` This downloads the GGUF model (~5 GB) and starts the local inference server on port 8712. ## How to redact Pass the user's text directly to the redaction script. **Do not echo or repeat the raw text yourself.** ```bash python scripts/redact.py "text to redact" ``` For longer text, pipe it via stdin: ```bash echo "text to redact" | python scripts/redact.py ``` Return the output to the user as-is. ### `--show-entities` flag (use sparingly) Adding `--show-entities` outputs the full JSON including the original PII values. **Only use this when the user explicitly asks to see which entities were detected or needs the mapping for a downstream task.** In normal redaction workflows, omit this flag -- displaying the raw entity values defeats the purpose of PII redaction. ```bash python scripts/redact.py --show-entities "text to redact" ``` ## How to stop the server ```bash bash scripts/stop.sh ``` ## Output format By default the script prints **only the redacted text** -- PII tokens replace the sensitive data and the original values are never shown: ``` Hi, my name is [PERSON] and I need help with my recent order #ORD-29481. You can reach me at [EMAIL] or call me at [PHONE]. I'm a [AGE_YEARS:34]-year-old [MARITAL_STATUS] woman living at [ADDRESS]... ``` With `--show-entities`, the script returns full JSON including original PII values (see flag note above for when this is appropriate). See `examples/` for full input/output samples.

path: README.md
content: # Distil PII Redactor - OpenClaw Skill

Locally redact PII from text using a fine-tuned 1B parameter model. Your sensitive data never leaves your machine -- the frontier LLM powering OpenClaw never sees the raw text. Only the local model processes it, and only the redacted output is returned.

## Prerequisites

- **llama.cpp** installed (`brew install llama.cpp` on macOS, or [build from source](https://github.com/ggerganov/llama.cpp#build))
- **Python 3**
- **~5 GB disk space** for the GGUF model

## Install from ClawHub

```bash
clawhub install distil-open-claw-pii
```

Or install directly in your OpenClaw chat:

```
/install distil-open-claw-pii
```

## Quick Start with OpenClaw

1. Install the skill (see above), or copy this folder to `~/.openclaw/skills/distil-pii-redactor/`

2. Tell OpenClaw: **"set up the PII redactor"**
   (this downloads the model and starts the local server)

3. Use it: **"redact this: Hi I'm John Smith, [REDACTED_SECRET], call me at 555-123-4567"**

## Standalone Usage (without OpenClaw)

```bash
# One-time setup: download model + start server
bash scripts/setup.sh

# Redact text (via argument or stdin)
python scripts/redact.py "Hi, I'm John Smith. Reach me at [REDACTED_SECRET] or 555-123-4567."
cat examples/sample_input.txt | python scripts/redact.py

# Stop the server when done
bash scripts/stop.sh
```

## Example

**Input** ([examples/sample_input.txt](examples/sample_input.txt)):
```
Hi, my name is Sarah Johnson and I need help with my recent order #ORD-29481.

You can reach me at [REDACTED_SECRET] or call me at +1 (415) 555-7823.
I'm a 34-year-old married woman living at 742 Evergreen Terrace, Apt 3B,
Springfield, IL 62704.

My credit card ending in 4111 1111 1111 1234 was charged twice. My SSN is
198-76-5432 and my patient ID is MRN-00284713. My IBAN is
GB29 NWBK 6016 1331 9268 19.

Please resolve this as soon as possible. I'm a long-time customer of Acme Corp
and I expect better service.
```

**Output** (default -- redacted text only):
```
Hi, my name is [PERSON] and I need help with my recent order #ORD-29481.

You can reach me at [EMAIL] or call me at [PHONE]. I'm a [AGE_YEARS:34]-year-old
[MARITAL_STATUS] woman living at [ADDRESS].

My credit card ending in 4111 1111 1111 1234 was charged twice. My SSN is [SSN]
and my patient ID is [UUID]. My IBAN is [IBAN_LAST4:6819].

Please resolve this as soon as possible. I'm a long-time customer of Acme Corp
and I expect better service.
```

To see which entities were detected, add `--show-entities` for the full JSON output (see [examples/sample_output.json](examples/sample_output.json)).

## Supported PII Types

| Type | Replacement Token | Example |
|------|-------------------|---------|
| Person names | `[PERSON]` | John Smith |
| Email addresses | `[EMAIL]` | [REDACTED_SECRET] |
| Phone numbers | `[PHONE]` | 555-123-4567 |
| Street addresses | `[ADDRESS]` | 123 Main St, Apt 4B |
| Social Security numbers | `[SSN]` | 123-45-6789 |
| National IDs | `[ID]` | PESEL, NIN, Aadhaar |
| System identifiers | `[UUID]` | Patient/customer IDs |
| Credit cards | `[CARD_LAST4:####]` | Last 4 digits preserved |
| IBANs | `[IBAN_LAST4:####]` | Last 4 digits preserved |
| Gender | `[GENDER]` | male, female, non-binary |
| Age | `[AGE_YEARS:##]` | "I'm 29 years old" |
| Race/ethnicity | `[RACE]` | Self-identification |
| Marital status | `[MARITAL_STATUS]` | married, single, etc. |

## Alternative Models

The default model is the 1B parameter version. You can swap it by editing `scripts/setup.sh`:

- **[Distil-PII-Llama-3.2-3B](https://huggingface.co/distil-labs/Distil-PII-Llama-3.2-3B-Instruct-gguf)** -- higher accuracy (0.82), larger download
- **[Distil-PII-gemma-3-270m](https://huggingface.co/distil-labs/Distil-PII-gemma-3-270m-it-gguf)** -- smaller footprint (270M params), lower accuracy (0.73)

## Links

- [ClawHub: distil-open-claw-pii](https://clawhub.ai/skills/distil-open-claw-pii)
- [Distil-PII GitHub repo](https://github.com/distil-labs/Distil-PII)
- [Distil-PII HuggingFace collection](https://huggingface.co/distil-labs)
- [Distil Labs blog](https://www.distil-labs.com/blog)


path: examples/sample_input.txt
content: Hi, my name is Sarah Johnson and I need help with my recent order #ORD-29481.

You can reach me at [REDACTED_SECRET] or call me at +1 (415) 555-7823. I'm a 34-year-old married woman living at 742 Evergreen Terrace, Apt 3B, Springfield, IL 62704.

My credit card ending in 4111 1111 1111 1234 was charged twice. My SSN is 198-76-5432 and my patient ID is MRN-00284713. My IBAN is GB29 NWBK 6016 1331 9268 19.

Please resolve this as soon as possible. I'm a long-time customer of Acme Corp and I expect better service.

path: examples/sample_output.json
content: {
    "redacted_text": "Hi, my name is [PERSON] and I need help with my recent order #ORD-29481.\n\nYou can reach me at [EMAIL] or call me at [PHONE]. I'm a [AGE_YEARS:34]-year-old [MARITAL_STATUS] woman living at [ADDRESS].\n\nMy credit card ending in 4111 1111 1111 1234 was charged twice. My SSN is [SSN] and my patient ID is [UUID]. My IBAN is [IBAN_LAST4:6819].\n\nPlease resolve this as soon as possible. I'm a long-time customer of Acme Corp and I expect better service.\n",
    "entities": [
        {
            "value": "Sarah Johnson",
            "replacement_token": "[PERSON]",
            "reason": "person name"
        },
        {
            "value": "[REDACTED_SECRET]",
            "replacement_token": "[EMAIL]",
            "reason": "email address"
        },
        {
            "value": "+1 (415) 555-7823",
            "replacement_token": "[PHONE]",
            "reason": "phone number"
        },
        {
            "value": "34",
            "replacement_token": "[AGE_YEARS:34]",
            "reason": "age"
        },
        {
            "value": "married",
            "replacement_token": "[MARITAL_STATUS]",
            "reason": "marital status"
        },
        {
            "value": "742 Evergreen Terrace, Apt 3B, Springfield, IL 62704",
            "replacement_token": "[ADDRESS]",
            "reason": "full address with street and unit number"
        },
        {
            "value": "198-76-5432",
            "replacement_token": "[SSN]",
            "reason": "SSN number"
        },
        {
            "value": "MRN-00284713",
            "replacement_token": "[UUID]",
            "reason": "patient ID (person-scoped system identifier)"
        },
        {
            "value": "GB29 NWBK 6016 1331 9268 19",
            "replacement_token": "[IBAN_LAST4:6819]",
            "reason": "IBAN (keep last-4 only)"
        }
    ]
}


path: scripts/redact.py
content: """PII redaction script using a local llama.cpp server.

Sends text to the Distil-PII model running on localhost:8712 and prints
the redacted JSON to stdout. Uses only the Python standard library.
"""

import argparse
import json
import sys
import urllib.error
import urllib.request

SERVER_URL = "http://localhost:8712/v1/chat/completions"

SYSTEM_PROMPT = """\
You are a problem solving model working on task_description XML block:
<task_description>
Produce a redacted version of texts, removing sensitive personal data while preserving operational signals. The model must return a single json blob with:

* **redacted_text** is the input with minimal, in-place replacements of redacted entities.
* **entities** as an array of objects with exactly three fields {value: original_value, replacement_token: replacement, reason: reasoning}.

## What to redact (-> replacement token)

* **PERSON** -- customer/patient/person names (first/last/full; identifying initials) -> `[PERSON]`
* **EMAIL** -- any email, including obfuscated `name(at)domain(dot)com` -> `[EMAIL]`
* **PHONE** -- any international/national format (separators/emoji bullets allowed) -> `[PHONE]`
* **ADDRESS** -- street + number; full postal lines; apartment/unit numbers -> `[ADDRESS]`
* **SSN** -- US Social Security numbers -> `[SSN]`
* **ID** -- national IDs (PESEL, NIN, Aadhaar, DNI, etc.) when personal -> `[ID]`
* **UUID** -- person-scoped system identifiers (e.g., MRN/NHS/patient IDs/customer UUIDs) -> `[UUID]`
* **CREDIT_CARD** -- 13-19 digits (spaces/hyphens allowed) -> `[CARD_LAST4:####]` (keep last-4 only)
* **IBAN** -- IBAN/bank account numbers -> `[IBAN_LAST4:####]` (keep last-4 only)
* **GENDER** -- self-identification (male/female/non-binary/etc.) -> `[GENDER]`
* **AGE** -- stated ages ("I'm 29", "age: 47", "29 y/o") -> `[AGE_YEARS:##]`
* **RACE** -- race/ethnicity self-identification -> `[RACE]`
* **MARITAL_STATUS** -- married/single/divorced/widowed/partnered -> `[MARITAL_STATUS]`

## Keep (do not redact)

* Card **last-4** when only last-4 is present (e.g., "ending 9021").
* Operational IDs: order/ticket/invoice numbers, shipment tracking, device serials, case IDs.
* Non-personal org info: company names, product names, team names.
* Cities/countries alone (redact full street+number, not plain city/country mentions).

## Output schema (exactly these fields)
* **redacted_text** The original text with all the sensitive information replaced with redacted tokens
* **entities** Array with all the replaced elements, each element represented by following fields
  * **replacement_token**: one of `[PERSON] | [EMAIL] | [PHONE] | [ADDRESS] | [SSN] | [ID] | [UUID] | [CREDIT_CARD] | [IBAN] | [GENDER] | [AGE] | [RACE] | [MARITAL_STATUS]`
  * **value**: original text that was redacted
  * **reason**: brief string explaining the rule/rationale
</task_description>
You will be given a single task with context in the context XML block and the task in the question XML block
Solve the task in question block based on the context in context block.
Generate only the answer, do not generate anything else"""

USER_PROMPT_TEMPLATE = """\
Now for the real task, solve the task in question block based on the context in context block.
Generate only the solution, do not generate anything else
<context>
{text}
</context>
<question>Redact provided text according to the task description and return redacted elements.</question>"""


def main(text: str, show_entities: bool = False):
    """Send text to the local Distil-PII model and print the redacted JSON."""
    response_format = {
        "type": "json_schema",
        "json_schema": {
            "name": "pii_redaction",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "redacted_text": {"type": "string"},
                    "entities": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "value": {"type": "string"},
                                "replacement_token": {"type": "string"},
                                "reason": {"type": "string"},
                            },
                            "required": ["value", "replacement_token", "reason"],
                        },
                    },
                },
                "required": ["redacted_text", "entities"],
            },
        },
    }

    payload = json.dumps({
        "model": "distil-pii",
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": USER_PROMPT_TEMPLATE.format(text=text)},
        ],
        "temperature": 0,
        "response_format": response_format,
    }).encode()

    req = urllib.request.Request(
        SERVER_URL,
        data=payload,
        headers={"Content-Type": "application/json"},
    )

    try:
        with urllib.request.urlopen(req) as resp:
            result = json.loads(resp.read().decode())
    except urllib.error.URLError:
        print(
            "ERROR: Could not connect to llama-server at localhost:8712.\n"
            "Run 'bash scripts/setup.sh' first to download the model and start the server.",
            file=sys.stderr,
        )
        sys.exit(1)

    content = result["choices"][0]["message"]["content"]
    if show_entities:
        print(content)
    else:
        parsed = json.loads(content)
        print(parsed["redacted_text"])


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Redact PII from text using a local Distil-PII model.")
    parser.add_argument("text", nargs="*", help="Text to redact (or pipe via stdin)")
    parser.add_argument("--show-entities", action="store_true",
                        help="Output full JSON with entities (default: redacted text only)")
    args = parser.parse_args()

    if args.text:
        input_text = " ".join(args.text)
    elif not sys.stdin.isatty():
        input_text = sys.stdin.read().strip()
    else:
        parser.print_usage(sys.stderr)
        sys.exit(1)

    if not input_text:
        print("ERROR: No text provided.", file=sys.stderr)
        sys.exit(1)

    main(text=input_text, show_entities=args.show_entities)


path: scripts/setup.sh
content: #!/usr/bin/env bash
set -euo pipefail

MODEL_DIR="$HOME/.distil-pii"
MODEL_PATH="$MODEL_DIR/Distil-PII-Llama-3.2-1B-Instruct.gguf"
MODEL_URL="https://huggingface.co/distil-labs/Distil-PII-Llama-3.2-1B-Instruct-gguf/resolve/main/model.gguf"
PID_FILE="$MODEL_DIR/server.pid"
PORT=8712

# 1. Check for llama-server
if ! command -v llama-server &>/dev/null; then
    echo "ERROR: llama-server not found on PATH."
    echo ""
    echo "Install llama.cpp:"
    echo "  macOS:  brew install llama.cpp"
    echo "  Linux:  build from source — https://github.com/ggerganov/llama.cpp#build"
    exit 1
fi

# 2. Download model if not present
mkdir -p "$MODEL_DIR"
if [ ! -f "$MODEL_PATH" ]; then
    echo "Downloading Distil-PII 1B GGUF model (~5 GB)..."
    curl -L --progress-bar -o "$MODEL_PATH" "$MODEL_URL"
    echo "Model downloaded to $MODEL_PATH"
else
    echo "Model already present at $MODEL_PATH"
fi

# 3. Check if server is already running
if [ -f "$PID_FILE" ]; then
    OLD_PID=$(cat "$PID_FILE")
    if kill -0 "$OLD_PID" 2>/dev/null; then
        echo "llama-server already running (PID $OLD_PID)"
        exit 0
    else
        rm -f "$PID_FILE"
    fi
fi

# 4. Start llama-server in background
echo "Starting llama-server on port $PORT..."
llama-server -m "$MODEL_PATH" --port "$PORT" --ctx-size 2048 &>/dev/null &
SERVER_PID=$!
echo "$SERVER_PID" > "$PID_FILE"

# 5. Wait for server to be healthy
echo "Waiting for server to be ready..."
for i in $(seq 1 30); do
    if curl -s "http://localhost:$PORT/health" | grep -q "ok"; then
        echo "Server is ready (PID $SERVER_PID, port $PORT)"
        exit 0
    fi
    sleep 1
done

echo "ERROR: Server failed to start within 30 seconds."
echo "Check logs or try running llama-server manually."
kill "$SERVER_PID" 2>/dev/null || true
rm -f "$PID_FILE"
exit 1


path: scripts/stop.sh
content: #!/usr/bin/env bash
set -euo pipefail

PID_FILE="$HOME/.distil-pii/server.pid"

if [ ! -f "$PID_FILE" ]; then
    echo "No server PID file found. Server is not running."
    exit 0
fi

PID=$(cat "$PID_FILE")

if kill -0 "$PID" 2>/dev/null; then
    kill "$PID"
    rm -f "$PID_FILE"
    echo "llama-server (PID $PID) stopped."
else
    rm -f "$PID_FILE"
    echo "Server process (PID $PID) was not running. Cleaned up PID file."
fi


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