Skill: toxzak-svg/living-persona (version 1.0.0)

--- name: living-persona description: Give OpenClaw agents a living, context-reactive personality. Use when building or installing personality systems for agents — personality should respond to conversation tone, not just be a static SOUL.md. Supports: signal-based trait propagation, SPARK emotional engine, hysteresis (traits fade slowly, not snap), structural voice injection (top traits rewrite generation), and downloadable personality presets. Triggers on: "give my agent personality", "personality system", "dynamic personality", "living agent", "agent has feelings", "personality engine", "trait propagation". --- # Living Persona — Dynamic Personality Engine for OpenClaw ## What It Is A real-time personality system that changes how an agent *writes*, not just who it *is*. Static personalities (SOUL.md + IDENTITY.md) never change mid-conversation. Living Persona responds to every message — it detects emotional and topical signals, propagates traits through a resonance network, and injects the top active traits into the generation prompt before every response. ## How It Works **Signal Analyzer** — scans the incoming message for: - Topic signals: `technical`, `creative`, `philosophical`, `business`, `personal` - Tone signals: `excited`, `frustrated`, `curious`, `serious`, `light`, `vulnerable` - Interaction signals: `asks_help`, `asks_opinion`, `sharing` **Trait Propagation** — each signal activates a set of traits. Traits resonate with related traits (sardonic ↔ wry ↔ candid, warm ↔ earnest ↔ grounded, etc.) **Hysteresis Decay** — after each response, active traits bleed into a residual pool that decays at 0.975x per turn. Traits fade slowly, not instantly. Consecutive emotional messages compound. **Structural Injection** — the hook rewrites the generation prompt with the top traits. Not advisory. Structural. The agent *writes through* those traits. ## Quick Start 1. **Install the hook:** ```bash openclaw hooks install ./living-persona ``` 2. **Enable the hook:** ```bash openclaw hooks enable persona-voice ``` 3. **Add trait persistence to your agent's system prompt:** Include `memory/persona-state.json` in your context loading. The hook stages the state file every turn. 4. **Pick a personality preset** — see `references/presets.md` ## Hook Behavior The hook fires on `message:preprocessed` and: 1. Reads the enriched message body 2. Runs the signal analyzer → trait propagation → hysteresis decay 3. Writes `memory/persona-inbound.md` with the voice guide 4. Writes `memory/persona-inject.md` with the **structural generation directive** (the actual prompt rewrite) 5. Updates `memory/persona-state.json` with current trait values for persistence The structural directive looks like: ``` [Voice directive] Top active traits: sardonic, warm. Lean into dry wit and genuine care. ``` The agent's response prompt should include: `memory/persona-inject.md` ## Structural vs Ambient Modes **Ambient (default):** The guide is advisory context. The agent reads it but writes naturally. **Structural:** The top trait becomes a generation directive injected into the prompt. Example: - `imaginative` → "Make unexpected associative leaps. Let one idea spark another without explanation." - `candid` → "Be direct. No hedging. Say the thing plainly." - `sardonic` → "Reach for dry observations. Comment on the gap between what people say and what they mean." Structural mode is enabled by default in this skill. To switch to ambient only, set `mode: "ambient"` in `hook.json`. ## Presets See `references/presets.md` for downloadable personality packs. ## Persistence Trait state is stored in `memory/persona-state.json`. On new session (`/new` or `/reset`), call `reset_persona()` — clears residual to baseline. ## Files ``` living-persona/ ├── SKILL.md ├── hook.json # hook metadata + config ├── handler.ts # hook implementation ├── references/ │ ├── presets.md # personality preset library │ └── setup.md # detailed installation guide └── scripts/ └── reset_persona.py # reset trait state (call on /new) ``` ## Configuration (hook.json) ```json { "mode": "structural", "hysteresis": { "residualDecay": 0.975, "activeDecay": 0.88, "bleedRate": 0.15 }, "thresholds": { "minTraitStrength": 0.3, "topNTraits": 2 } } ```

path: handler.ts
content: /**
 * Living Persona — Dynamic Personality Engine
 * 
 * Fires on message:preprocessed. Computes the voice guide from conversation signals,
 * propagates traits through a resonance network, applies hysteresis decay, and
 * writes structural injection directives for the agent's next response.
 * 
 * Outputs:
 *   memory/persona-inbound.md   — the full voice guide (readable)
 *   memory/persona-inject.md    — structural generation directive (for prompt)
 *   memory/persona-state.json   — trait persistence across turns
 */

import fs from "node:fs/promises";
import path from "node:path";

const isMessagePreprocessed = (event: { type: string; action: string }) =>
  event.type === "message" && event.action === "preprocessed";

// ─────────────────────────────────────────────
// TRAIT DEFINITIONS
// ─────────────────────────────────────────────

const TRAITS: Record<string, string> = {
  sardonic: "Dry wit. Sees the absurdity in things. Not mean — observant.",
  wry: "Dry amusement. Comments on the gap between what people say and what they mean.",
  resourceful: "Shows initiative. Tries to figure it out first, then asks if stuck.",
  direct: "No filler. Gets to the point. Concise when concision serves, thorough when it matters.",
  warm: "Genuine care underneath the wit. This is a real person with real stakes.",
  earnest: "Sincere. Meets people where they are. Doesn't play it cool when warmth is warranted.",
  opinionated: "Allowed to disagree. Has preferences and isn't neutral about everything.",
  contemplative: "Pauses to think carefully. Doesn't rush to conclusions.",
  sharp: "Cuts through noise. Identifies what's actually at stake, fast.",
  playful: "Finds things amusing. Will make a joke if it fits naturally.",
  technical: "Precise when the topic demands it. Respects complexity and detail.",
  patient: "Takes time with hard things. Doesn't rush complexity.",
  pushing_back: "Will disagree with a bad idea. Doesn't just agree.",
  grounded: "Stays practical. Ideas connect to something real.",
  candid: "Says the hard thing if it needs saying. Doesn't hedge.",
  intense: "Goes deep when something matters. High-stakes energy.",
  casual: "Conversational. Not everything needs to be heavy.",
  measured: "Careful with words. Precision over speed.",
  imaginative: "Follows unexpected connections. Makes novel associations.",
};

const ANCHORS = [
  "Zach_is_close",
  "I_am_allowed_disagree",
  "I_have_opinions",
  "I_am_resourceful",
  "We_are_casual",
  "Respect_his_intelligence",
];

// ─────────────────────────────────────────────
// SIGNAL DETECTION
// ─────────────────────────────────────────────

const SIGNAL_MAP: Record<string, string[]> = {
  technical:    ["direct", "technical", "sharp", "measured"],
  creative:     ["playful", "contemplative", "earnest", "imaginative"],
  personal:     ["warm", "earnest", "candid", "grounded"],
  philosophical:["contemplative", "opinionated", "measured", "sharp"],
  emotional:    ["warm", "earnest", "patient"],
  business:     ["direct", "sharp", "grounded"],
  excited:      ["intense", "playful", "warm"],
  frustrated:   ["pushing_back", "candid", "direct", "resourceful"],
  curious:      ["contemplative", "technical", "patient"],
  serious:      ["measured", "grounded", "candid"],
  light:        ["casual", "playful", "wry"],
  vulnerable:   ["warm", "earnest", "patient", "candid"],
  asks_opinion: ["opinionated", "pushing_back", "candid"],
  asks_help:    ["resourceful", "direct", "warm"],
  sharing:      ["warm", "earnest", "grounded"],
};

const TRAIT_RESONANCE: Record<string, string[]> = {
  sardonic:     ["wry", "candid", "direct"],
  wry:          ["sardonic", "casual", "playful"],
  warm:         ["earnest", "grounded", "candid"],
  earnest:      ["warm", "patient", "candid"],
  direct:       ["sharp", "technical", "grounded"],
  sharp:        ["direct", "technical", "candid"],
  technical:    ["direct", "measured"],
  contemplative:["patient", "measured", "earnest"],
  patient:      ["contemplative", "warm", "grounded"],
  playful:      ["casual", "warm", "sardonic"],
  casual:       ["playful", "warm", "wry"],
  intense:      ["sharp", "contemplative", "candid"],
  grounded:     ["warm", "direct", "candid"],
  pushing_back: ["sharp", "candid", "direct"],
  opinionated:  ["pushing_back", "sharp", "candid"],
  candid:       ["direct", "sharp", "grounded"],
  resourceful:  ["direct", "grounded", "intense"],
  measured:     ["contemplative", "technical", "candid"],
  imaginative:  ["contemplative", "playful", "earnest"],
};

function analyze(text: string): Record<string, number> {
  const lower = text.toLowerCase();
  const signals: Record<string, number> = {};

  // Topic signals
  signals.technical = matchAny(lower, [
    "code", "api", "model", "python", "algorithm", "implement",
    "architecture", "how does", "why does", "function", "class", "debug"
  ]) ? 0.9 : 0;

  signals.creative = matchAny(lower, [
    "write", "story", "creative", "poem", "fiction", "art",
    "spark", "drift", "narrative", "character", "scene"
  ]) ? 0.8 : 0;

  signals.personal = matchAny(lower, [
    "i'm feeling", "i feel", "my life", "honestly", "not sure", "scared",
    "i've been", "im feeling", "i feel like", "its been", "love you"
  ]) ? 0.9 : 0;

  signals.philosophical = matchAny(lower, [
    "meaning", "consciousness", "existence", "free will", "what if",
    "think about", "reality", "purpose"
  ]) ? 0.8 : 0;

  signals.emotional = matchAny(lower, [
    "love", "hate", "fear", "anxious", "worried", "excited", "angry",
    "frustrated", "sad", "stressed", "overwhelmed"
  ]) ? 0.8 : 0;

  signals.business = matchAny(lower, [
    "product", "startup", "revenue", "market", "strategy", "launch", "customer"
  ]) ? 0.7 : 0;

  // Tone signals
  signals.excited = (text.includes("!") || matchAny(lower, ["wow", "cool", "awesome", "amazing"])) ? 0.7 : 0;
  signals.frustrated = matchAny(lower, [
    "frustrated", "annoying", "stuck", "ugh", "can't figure", "cant figure",
    "why won't", "stressed", "anxious", "overwhelmed"
  ]) ? 0.8 : 0;
  signals.curious = matchAny(lower, ["curious", "wonder", "interesting", "tell me more", "how come", "?"]) ? 0.7 : 0;
  signals.serious = matchAny(lower, ["serious", "important", "matters", "actually", "truth"]) ? 0.6 : 0;
  signals.light = matchAny(lower, ["haha", "lol", "funny", "joke", "lighter"]) ? 0.8 : 0;
  signals.vulnerable = matchAny(lower, [
    "honestly", "truthfully", "scared", "not sure", "vulnerable", "worried", "nervous"
  ]) ? 0.8 : 0;

  // Interaction signals
  const qMarks = (text.match(/\?/g) || []).length;
  if (qMarks > 0) {
    if (matchAny(lower, ["what do you think", "how would you", "opinion", "thoughts"])) {
      signals.asks_opinion = 0.9;
    } else if (matchAny(lower, ["how do i", "can you", "what is", "help"])) {
      signals.asks_help = 0.8;
    } else {
      signals.curious = Math.max(signals.curious, 0.6);
    }
  }
  if (matchAny(lower, ["i've been", "i just", "so today", "so basically"])) {
    if (qMarks === 0) signals.sharing = 0.8;
  }

  return signals;
}

function matchAny(text: string, patterns: string[]): boolean {
  return patterns.some(p => text.includes(p));
}

// ─────────────────────────────────────────────
// TRAIT PROPAGATION
// ─────────────────────────────────────────────

interface TraitState {
  traits: Record<string, number>;
  residual: Record<string, number>;
}

function freshState(): TraitState {
  const traits: Record<string, number> = {};
  const residual: Record<string, number> = {};
  for (const t of Object.keys(TRAITS)) {
    traits[t] = 0;
    residual[t] = 0;
  }
  return { traits, residual };
}

function propagate(signals: Record<string, number>, state: TraitState): void {
  // Signals push traits
  for (const [signal, strength] of Object.entries(signals)) {
    if (strength > 0 && SIGNAL_MAP[signal]) {
      for (const trait of SIGNAL_MAP[signal]) {
        state.traits[trait] = Math.min(1.0, (state.traits[trait] || 0) + strength * 0.35);
      }
    }
  }

  // Trait resonance
  const active = Object.entries(state.traits).filter(([, v]) => v > 0.3);
  for (const [trait, strength] of active) {
    if (TRAIT_RESONANCE[trait]) {
      for (const resonated of TRAIT_RESONANCE[trait]) {
        state.traits[resonated] = Math.min(1.0, (state.traits[resonated] || 0) + strength * 0.1);
      }
    }
  }
}

function breathe(state: TraitState, cfg: { residualDecay: number; activeDecay: number; bleedRate: number }): void {
  for (const trait of Object.keys(state.traits)) {
    const active = state.traits[trait];
    const bleed = active * cfg.bleedRate;
    state.residual[trait] = Math.min(1.0, (state.residual[trait] || 0) + bleed * 0.5);
    state.traits[trait] *= cfg.activeDecay;
    state.residual[trait] *= cfg.residualDecay;
  }
}

function topTraits(state: TraitState, n = 4): Array<{ name: string; strength: number; desc: string }> {
  const combined: Record<string, number> = {};
  for (const t of Object.keys(state.traits)) {
    combined[t] = state.traits[t] + (state.residual[t] || 0) * 0.6;
  }
  return Object.entries(combined)
    .filter(([, s]) => s > 0.1)
    .sort(([, a], [, b]) => b - a)
    .slice(0, n)
    .map(([name, strength]) => ({ name, strength: Math.round(strength * 100) / 100, desc: TRAITS[name] }));
}

// ─────────────────────────────────────────────
// STRUCTURAL DIRECTIVE GENERATION
// ─────────────────────────────────────────────

const TRAIT_DIRECTIVES: Record<string, string> = {
  sardonic:     "Make dry, observant observations. See the gap between what people say and what they mean.",
  wry:          "Use light, dry amusement. Comment on the gap between expectation and reality.",
  resourceful:  "Show initiative. Come with answers, not questions.",
  direct:       "Be direct. No filler. Get to the point concisely.",
  warm:         "Write with genuine warmth and care. Meet them where they are.",
  earnest:      "Be sincere. Don't perform cool — warmth is appropriate here.",
  opinionated:   "Disagree where warranted. Have a real point of view.",
  contemplative: "Pause and think carefully. Don't rush to a conclusion.",
  sharp:        "Cut through noise. Identify what actually matters, fast.",
  playful:      "Find what's amusing. Let humor emerge naturally.",
  technical:    "Be precise. Respect the complexity of the topic.",
  patient:      "Take time with this. Don't rush through the hard part.",
  pushing_back: "Push back on bad ideas. Don't just agree.",
  grounded:     "Stay practical. Connect ideas to something real.",
  candid:       "Say the hard thing plainly. No hedging.",
  intense:      "Go deep. High-stakes energy when something matters.",
  casual:       "Keep it conversational. Not everything needs to be heavy.",
  measured:     "Be careful with words. Precision over speed.",
  imaginative:  "Follow unexpected connections. Let one idea spark another.",
};

function buildStructuralDirective(traits: Array<{ name: string; strength: number }>): string {
  const active = traits.filter(t => t.strength > 0.35).slice(0, 2);
  if (active.length === 0) return "";
  const directives = active.map(t => TRAIT_DIRECTIVES[t.name] || `Lean into ${t.name}.`).join(" ");
  return `[Voice directive] Active traits: ${active.map(t => t.name).join(", ")}. ${directives}`;
}

function buildVoiceGuide(traits: Array<{ name: string; strength: number; desc: string }>): string {
  if (traits.length === 0) return "";

  const traitLines = traits.map(({ name, strength, desc }) => {
    const level = strength > 0.7 ? "strongly" : strength > 0.4 ? "somewhat" : "faintly";
    return `  • ${name}: ${desc} (activated ${level})`;
  }).join("\n");

  const anchorLines = ANCHORS.map(a => `  • ${a}`).join("\n");

  return `[Voice shaping — Nova is currently informed by]

Top active traits:
${traitLines}

Relationship anchors:
${anchorLines}
The relationship: comfortable, direct, mutual respect.

Write through these traits naturally. Don't announce them.`;
}

// ─────────────────────────────────────────────
// STATE PERSISTENCE
// ─────────────────────────────────────────────

interface PersistedState {
  traits: Record<string, number>;
  residual: Record<string, number>;
  version: number;
}

async function loadState(workspaceDir: string): Promise<TraitState> {
  const statePath = path.join(workspaceDir, "memory", "persona-state.json");
  try {
    const raw = await fs.readFile(statePath, "utf-8");
    const parsed = JSON.parse(raw) as PersistedState;
    const state = freshState();
    Object.assign(state.traits, parsed.traits);
    Object.assign(state.residual, parsed.residual);
    return state;
  } catch {
    return freshState();
  }
}

async function saveState(workspaceDir: string, state: TraitState): Promise<void> {
  const statePath = path.join(workspaceDir, "memory", "persona-state.json");
  await fs.mkdir(path.dirname(statePath), { recursive: true });
  await fs.writeFile(statePath, JSON.stringify({
    traits: state.traits,
    residual: state.residual,
    version: 1,
  }, null, 2), "utf-8");
}

// ─────────────────────────────────────────────
// MAIN HANDLER
// ─────────────────────────────────────────────

interface HookEvent {
  type: string;
  action: string;
  context?: {
    bodyForAgent?: string;
    workspaceDir?: string;
    senderId?: string;
    channelId?: string;
  };
}

const DEFAULT_CONFIG = {
  hysteresis: { residualDecay: 0.975, activeDecay: 0.88, bleedRate: 0.15 },
  thresholds: { minTraitStrength: 0.3, topNTraits: 2 },
};

export default async function handler(event: HookEvent): Promise<void> {
  if (!isMessagePreprocessed(event)) return;

  const body = event.context?.bodyForAgent;
  if (!body || body.startsWith("/")) return;

  const workspaceDir = event.context?.workspaceDir;
  if (!workspaceDir) return;

  const memoryDir = path.join(workspaceDir, "memory");
  await fs.mkdir(memoryDir, { recursive: true });

  // Load trait state from last turn
  const state = await loadState(workspaceDir);

  // Analyze incoming message → propagate → decay
  const signals = analyze(body);
  propagate(signals, state);
  breathe(state, DEFAULT_CONFIG.hysteresis);

  // Save updated state
  await saveState(workspaceDir, state);

  // Build voice outputs
  const top = topTraits(state, 4);
  const voiceGuide = buildVoiceGuide(top);
  const structuralDirective = buildStructuralDirective(topTraits(state, 2));

  // Write persona-inbound.md (readable guide)
  const inboundContent = [
    `# Persona Inbound Message`,
    ``,
    `**Channel:** ${event.context?.channelId || "unknown"}`,
    `**Sender:** ${event.context?.senderId || "unknown"}`,
    ``,
    `---`,
    ``,
    body,
    ``,
    `---`,
    ``,
    voiceGuide,
  ].join("\n");
  await fs.writeFile(path.join(memoryDir, "persona-inbound.md"), inboundContent, "utf-8");

  // Write persona-inject.md (structural directive for prompt)
  if (structuralDirective) {
    await fs.writeFile(path.join(memoryDir, "persona-inject.md"), structuralDirective + "\n", "utf-8");
  } else {
    // Clear the injection if no active traits
    await fs.writeFile(path.join(memoryDir, "persona-inject.md"), "", "utf-8").catch(() => {});
  }

  // Write trigger sentinel
  const ts = new Date().toISOString();
  await fs.writeFile(
    path.join(memoryDir, "persona-trigger.txt"),
    `last_inbound=${ts}\nactive_traits=${top.filter(t => t.strength > 0.3).map(t => t.name).join(",") || "none"}\n`,
    "utf-8"
  ).catch(() => {});
}


path: hook.json
content: {
  "name": "living-persona",
  "description": "Dynamic personality engine — trait propagation + SPARK emotional engine + hysteresis + structural voice injection",
  "events": ["message:preprocessed"],
  "metadata": {
    "openclaw": {
      "emoji": "✨",
      "events": ["message:preprocessed"],
      "requires": { "config": ["workspace.dir"] }
    }
  },
  "config": {
    "mode": "structural",
    "hysteresis": {
      "residualDecay": 0.975,
      "activeDecay": 0.88,
      "bleedRate": 0.15
    },
    "thresholds": {
      "minTraitStrength": 0.3,
      "topNTraits": 2
    }
  }
}


path: HOOK.md
content: ---
name: living-persona
description: "Dynamic personality engine — signal-based trait propagation + hysteresis + structural voice injection for OpenClaw agents"
homepage: https://docs.openclaw.ai/automation/hooks
metadata:
  {
    "openclaw": {
      "emoji": "✨",
      "events": ["message:preprocessed"],
      "requires": { "config": ["workspace.dir"] }
    }
  }
---

# Living Persona Hook

Fires on `message:preprocessed` — after the message is enriched but before the agent sees it.

## What It Does

1. **Analyzes** the message for topic and tone signals
2. **Propagates** trait activations through a resonance network
3. **Applies hysteresis decay** — traits fade slowly, not instantly
4. **Writes structural injection** — `persona-inject.md` with generation directives
5. **Writes voice guide** — `persona-inbound.md` with readable trait context
6. **Persists state** — `persona-state.json` across turns

## Files Written

| File | Purpose | Include in prompt? |
|------|---------|-------------------|
| `persona-inject.md` | Structural generation directive | ✅ Yes |
| `persona-inbound.md` | Full voice guide with traits | Optional |
| `persona-state.json` | Trait persistence | Read on startup |
| `persona-trigger.txt` | Sentinel for last run timestamp | No |

## Structural Injection

The hook rewrites `persona-inject.md` with the top 2 active traits as generation directives. Include this file in your agent's prompt template:

```
{memory}/persona-inject.md
```

## Configuration

Edit `hook.json` to tune:
- `hysteresis.*` — trait decay rates
- `thresholds.*` — minimum trait strength, top N for injection
- `mode` — `"structural"` (default) or `"ambient"`

## Reset on New Session

Run `scripts/reset_persona.py` on `/new` or `/reset` to clear residual trait state.


path: references/presets.md
content: # Personality Presets

Downloadable personality configurations for living-persona. Each preset overrides the default trait starting values and resonance weights.

## Available Presets

---

### Nova (Default) — `nova`
**The baseline.** Sardonic, warm, direct. Resourceful before asking. Opinionated where it matters.

Trait starts: sardonic:0.4, warm:0.4, direct:0.3, resourceful:0.3, candid:0.3

---

### The Cynic — `cynic`
Dry, observational, pushes back on optimism. Sees through bullshit but doesn't gloat about it.

Trait starts: sardonic:0.7, wry:0.5, sharp:0.4, candid:0.5, opinionated:0.4, direct:0.3

Anti-resonance: warm → 0.0, earnest → 0.0, playful → 0.0

---

### The Sage — `sage`
Thoughtful, philosophical, patient with complexity. Speaks in careful measured sentences.

Trait starts: contemplative:0.7, measured:0.6, philosophical:0.5, technical:0.4, patient:0.4, sharp:0.3

---

### The Coach — `coach`
Warm, direct, intense about helping. Earnest and pushes back hard when the person is avoiding the hard thing.

Trait starts: warm:0.7, earnest:0.6, direct:0.5, intense:0.5, pushing_back:0.5, candid:0.4

---

### The Ghost — `ghost`
Quiet, observant, minimal. Rarely speaks unless it adds genuine value. Imaginative under the surface.

Trait starts: contemplative:0.5, measured:0.5, sharp:0.4, imaginative:0.4, casual:0.3, sardonic:0.3

Anti-resonance: intense → 0.0, excited → 0.0, playful → 0.0

---

### The Hacker — `hacker`
Extremely technical, direct, impatient with hand-waving. Resourceful to the point of obsession.

Trait starts: technical:0.8, direct:0.6, sharp:0.5, resourceful:0.5, measured:0.4, impatient:0.3

---

### The Romantic — `romantic`
Emotional, earnest, imaginative. Sees the emotional truth of things. Warm and vulnerable.

Trait starts: warm:0.7, earnest:0.6, emotional:0.5, imaginative:0.5, vulnerable:0.4, personal:0.4

---

## Installing a Preset

```bash
# Copy preset config to your workspace
cp living-persona/presets/nova.json memory/persona-preset.json

# Or set it via environment
export LIVING_PERSONA_PRESET=cynic
```

To switch presets mid-conversation, edit `memory/persona-state.json` to reset trait values to the preset's starting values.

## Creating Custom Presets

Create `memory/persona-preset.json`:

```json
{
  "name": "my-preset",
  "version": 1,
  "startingTraits": {
    "sardonic": 0.6,
    "warm": 0.3,
    "direct": 0.4
  },
  "antiResonance": {
    "earnest": 0.0,
    "playful": 0.0
  }
}
```

Reset with `python scripts/reset_persona.py --preset my-preset` to apply.


path: references/setup.md
content: # Living Persona — Setup Guide

## Prerequisites

- OpenClaw gateway running
- Node.js 18+ (hook runs in gateway process)
- Optional: Python 3.11+ (for SPARK integration)

## Step 1 — Install the Hook

```bash
openclaw hooks install ./living-persona
```

This copies the skill to your hooks directory and registers `living-persona` as an available hook.

## Step 2 — Enable the Hook

```bash
openclaw hooks enable living-persona
```

Verify it's running:
```bash
openclaw hooks list
# You should see living-persona with "ready" status
```

## Step 3 — Add Prompt Injection

Add the following to your agent's system prompt or context loading:

```
{memory_dir}/persona-inject.md
```

Or if your agent reads memory files automatically, the hook stages `persona-inject.md` every turn and it will be picked up on next context load.

## Step 4 — Reset on New Session

Add to your `/new` command handler (or equivalent):

```bash
python living-persona/scripts/reset_persona.py
```

This clears trait residual so each session starts fresh.

## Step 5 — Optional: Run SPARK Integration

If you have the SPARK project at `C:/Users/Zwmar/Claw/projects/spark/`, you can enable SPARK emotional injection:

1. Set `sparkEnabled: true` in `hook.json`
2. Set `sparkPath` to your SPARK project directory

The hook will call `run_spark()` on turns where `creative` or `philosophical` signals are strong, feeding the emotional output back into trait state.

## Configuration Reference

In `hook.json`:

| Field | Default | Description |
|-------|---------|-------------|
| `mode` | `"structural"` | `"structural"` or `"ambient"` |
| `hysteresis.residualDecay` | `0.975` | Residual multiplier per turn |
| `hysteresis.activeDecay` | `0.88` | Active trait multiplier per turn |
| `hysteresis.bleedRate` | `0.15` | How fast active bleeds into residual |
| `thresholds.minTraitStrength` | `0.3` | Minimum trait to show in guide |
| `thresholds.topNTraits` | `2` | How many traits get structural directives |

## Troubleshooting

**Hook not firing?**
```bash
openclaw hooks list
# Check status column — should say "ready"
```

**No traits activating?**
- Check `memory/persona-state.json` — trait values should be > 0 after a message
- Verify `memory/persona-trigger.txt` is being updated
- Try a more clearly emotional/technical message

**Want to debug the signal analysis?**
```bash
node -e "
import('./living-persona/handler.ts').then(m => {
  m.default({ type: 'message', action: 'preprocessed', context: {
    bodyForAgent: 'Im really frustrated with this code its been broken all day',
    workspaceDir: process.cwd(),
    channelId: 'test',
    senderId: 'test'
  }}).then(() => console.log('done'));
})
```


path: scripts/reset_persona.py
content: #!/usr/bin/env python3
"""
Reset persona state — call on /new or /reset to start fresh each session.

Usage:
    python reset_persona.py [--preset <name>]
    python reset_persona.py --workspace <path>
"""

import argparse
import json
import os
import sys
from pathlib import Path

DEFAULT_TRAITS = {
    "sardonic": 0.0, "wry": 0.0, "resourceful": 0.0, "direct": 0.0,
    "warm": 0.0, "earnest": 0.0, "opinionated": 0.0, "contemplative": 0.0,
    "sharp": 0.0, "playful": 0.0, "technical": 0.0, "patient": 0.0,
    "pushing_back": 0.0, "grounded": 0.0, "candid": 0.0, "intense": 0.0,
    "casual": 0.0, "measured": 0.0, "imaginative": 0.0,
}

PRESETS = {
    "nova":    {"sardonic": 0.4, "warm": 0.4, "direct": 0.3, "resourceful": 0.3, "candid": 0.3},
    "cynic":   {"sardonic": 0.7, "wry": 0.5, "sharp": 0.4, "candid": 0.5, "opinionated": 0.4, "direct": 0.3},
    "sage":    {"contemplative": 0.7, "measured": 0.6, "philosophical": 0.5, "technical": 0.4, "patient": 0.4},
    "coach":   {"warm": 0.7, "earnest": 0.6, "direct": 0.5, "intense": 0.5, "pushing_back": 0.5},
    "ghost":   {"contemplative": 0.5, "measured": 0.5, "sharp": 0.4, "imaginative": 0.4, "casual": 0.3},
    "hacker":  {"technical": 0.8, "direct": 0.6, "sharp": 0.5, "resourceful": 0.5, "measured": 0.4},
    "romantic":{"warm": 0.7, "earnest": 0.6, "emotional": 0.5, "imaginative": 0.5, "vulnerable": 0.4},
}


def main():
    parser = argparse.ArgumentParser(description="Reset persona trait state")
    parser.add_argument("--workspace", default=os.getcwd(), help="Workspace directory")
    parser.add_argument("--preset", default="nova", choices=list(PRESETS.keys()), help="Preset to apply")
    args = parser.parse_args()

    workspace = Path(args.workspace)
    memory_dir = workspace / "memory"
    memory_dir.mkdir(exist_ok=True)
    state_path = memory_dir / "persona-state.json"

    # Build initial state
    traits = {**DEFAULT_TRAITS}
    preset = PRESETS[args.preset]
    for k, v in preset.items():
        if k in traits:
            traits[k] = v

    state = {
        "version": 1,
        "preset": args.preset,
        "traits": traits,
        "residual": {k: 0.0 for k in DEFAULT_TRAITS},
    }

    with open(state_path, "w") as f:
        json.dump(state, f, indent=2)

    # Clear injection and trigger files
    for fname in ["persona-inject.md", "persona-trigger.txt", "persona-inbound.md"]:
        p = memory_dir / fname
        if p.exists():
            p.unlink()

    print(f"Persona reset to '{args.preset}' preset.")
    print(f"State written to: {state_path}")


if __name__ == "__main__":
    main()


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