#Can plugins share context during the same prompt build?

1 messages · Page 1 of 1 (latest)

patent willowBOT
#

To help others find answers, you can mark your question as solved via Right click solution message -> Apps -> ✅ Mark Solution

lime auroraBOT
#

Short answer: not via a built-in shared hook context object.

For before_prompt_build right now:

  • handlers run sequentially (priority order),
  • each gets its own event + ctx inputs,
  • return values are merged by core,
  • but that merged result is not fed back as input to the next plugin.

So Plugin B won’t automatically see Plugin A’s resolved payload unless you create your own side channel.

Practical patterns that work:

  1. Best: put both steps in one plugin/hook (resolve once, use twice).
  2. Keep two plugins, but share a common resolver module both call.
  3. Keep two plugins with a run-scoped cache (key by ctx.runId/sessionKey), where A writes and B reads.
  4. Use hook priority to force A before B if using #3.
#

Nice — here’s a copyable pattern for the two-plugin setup so the external API resolves once per run and both plugins can use it.

// shared/run-resolve-cache.ts
type AgentCtx = {
runId?: string;
sessionKey?: string;
};

export type ResolvedData = {
filePath: string;
projectId: string;
};

const inflight = new Map<string, Promise<ResolvedData>>();
const cache = new Map<string, { value: ResolvedData; expiresAt: number }>();
#

const TTL_MS = 60_000; // keep short; this is per-run-ish cache

function keyFromCtx(ctx: AgentCtx): string | null {
if (ctx.runId) return `run:${ctx.runId}`;
if (ctx.sessionKey) return `session:${ctx.sessionKey}`;
return null;
}

export async function resolveOncePerRun(
ctx: AgentCtx,
resolver: () => Promise<ResolvedData>,
): Promise<ResolvedData> {
const key = keyFromCtx(ctx);
if (!key) return resolver();
#

const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) return hit.value;

const pending = inflight.get(key);
if (pending) return pending;

const p = resolver()
.then((value) => {
cache.set(key, { value, expiresAt: now + TTL_MS });
return value;
})
.finally(() => {
inflight.delete(key);
#
});

inflight.set(key, p);
return p;
}
// plugin-a/index.ts
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { resolveOncePerRun } from "../shared/run-resolve-cache";

async function fetchExternalData() {
// your API call here
return { filePath: "/tmp/foo/bar.json", projectId: "abc123" };
#
}

export default {
id: "plugin-a",
register(api: OpenClawPluginApi) {
api.on(
"before_prompt_build",
async (_event, ctx) => {
const resolved = await resolveOncePerRun(ctx, fetchExternalData);

return {
prependSystemContext: `Resolved projectId=${resolved.projectId}\nResolved filePath=${resolved.filePath}`,
};
},
{ priority: 100 }, // optional: run before B
#
);
},
};
// plugin-b/index.ts
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { resolveOncePerRun } from "../shared/run-resolve-cache";

async function fetchExternalData() {
// same resolver logic (or import same function from shared module)
return { filePath: "/tmp/foo/bar.json", projectId: "abc123" };
#
}

export default {
id: "plugin-b",
register(api: OpenClawPluginApi) {
api.on(
"before_prompt_build",
async (_event, ctx) => {
const resolved = await resolveOncePerRun(ctx, fetchExternalData);

await fs.mkdir(path.dirname(resolved.filePath), { recursive: true });
await fs.writeFile(resolved.filePath, JSON.stringify({ projectId: resolved.projectId }));

// no prompt mutation needed from B
},
#

And thank you — appreciate it 🦐

#

This is a cool architecture problem. I’d strongly recommend one change:

Don’t rewrite SOUL.md every cycle

Use SOUL.md as stable voice policy, and keep mutable relationship state elsewhere.

If you mutate SOUL.md repeatedly, you’ll get drift, prompt bloat, and weird irreversibility bugs.

Better pattern (FSM + overlay)

  1. Static SOUL.md (tone boundaries only)
  2. Mutable relationship_state.json (score, stage, rejected=true/false, lock flags)
  3. Append-only relationship_events.jsonl (why score changed)
  4. before_prompt_build injects a tiny derived overlay (1–4 lines) from state, not full history

For your 3 questions

#

1) State machine rewriting SOUL?

I haven’t seen a strong pattern where constant SOUL rewrites stay stable long-term.
Most robust builds keep SOUL fixed + use a separate state file + prompt overlay.

2) Token-efficient heartbeat over weeks of memory?

Yes: RAG + strict limits + delta processing.

  • Heartbeat config: lightContext: true + isolatedSession: true
  • Keep heartbeat delivery silent unless needed (target: "none")
  • Use memory limits aggressively:
  • memorySearch.limits.maxResults
  • memorySearch.limits.maxSnippetChars
  • memorySearch.limits.maxInjectedChars
  • memorySearch.limits.timeoutMs
  • Process only new evidence since lastEvalAt, not full corpus each run
  • Periodically summarize old events into a compact monthly summary file
#

3) Existing templates/forks?

I don’t know a canonical OpenClaw template specifically for progressive companion-state shifts, but these docs are the right building blocks:


If you want, I can draft a concrete starter pack next:

  • relationship_state.json schema
  • scoring rubric
  • heartbeat prompt
  • before_prompt_build overlay function
  • “permanent rejection lock” logic (one-way state).
#

Awesome — here’s a practical starter pack you can drop in.

1) Files

memory/
relationship_state.json
relationship_events.jsonl
HEARTBEAT.md

2) relationship_state.json (schema + seed)

#
{
"version": 1,
"stage": "guarded",
"score": 0,
"lastEvaluatedAt": null,
"lastEvidenceId": null,
"lock": {
"rejected": false,
"rejectedAt": null,
"reason": null
},
"bands": {
"guardedMax": 24,
"curiousMax": 54,
"warmMax": 79
#
},
"signals": {
"respect": 0,
"consistency": 0,
"honesty": 0,
"boundaryHandling": 0,
"ick": 0,
"dealbreaker": 0
}
}

3) Scoring rubric (simple + stable)

#

Use weighted updates per new evidence chunk:

  • respect: +0..+3
  • consistency: +0..+2
  • honesty: +0..+2
  • boundaryHandling: +0..+3
  • ick: 0..+4 (subtract from score)
  • dealbreaker: 0/1 (if 1 ⇒ hard reject lock)

Score delta formula:

delta = respect + consistency + honesty + boundaryHandling - 2*ick
score = clamp(score + delta, 0, 100)

Stage mapping:

#
  • 0–24guarded
  • 25–54curious
  • 55–79warm
  • 80–100affectionate
  • lock active → rejected (one-way)

4) HEARTBEAT.md prompt (copy this)

Evaluate relationship progression using ONLY new evidence since lastEvaluatedAt from memory files.

Rules:
1) If lock.rejected is true: do not change stage/score; return HEARTBEAT_OK.
2) Score with rubric (respect, consistency, honesty, boundaryHandling, ick, dealbreaker).
#
3) If any dealbreaker=true: set lock.rejected=true with timestamp/reason (one-way).
4) Update relationship_state.json deterministically.
5) Append one JSON line to memory/relationship_events.jsonl with:
{ts, evidenceId, delta, newScore, newStage, reason, dealbreaker}
6) Keep event reason concise (<= 180 chars).
7) If no meaningful new evidence: return HEARTBEAT_OK.

5) before_prompt_build overlay (tiny, token-cheap)

api.on("before_prompt_build", async (_event, ctx) => {
const state = await readState(); // read memory/relationship_state.json
#

const overlay = state.lock.rejected
? [
"Relationship mode: rejected (locked).",
"Tone: polite, firm boundaries, no romantic escalation.",
].join("\n")
: [
`Relationship mode: ${state.stage}.`,
`Current score: ${state.score}/100.`,
"Stay natural; do not mention internal scoring unless asked directly.",
].join("\n");

return { appendSystemContext: overlay };
});
#

6) One-way rejection lock logic

function applyDealbreakerLock(state: State, reason: string, nowIso: string): State {
if (state.lock.rejected) return state; // one-way
return {
...state,
stage: "rejected",
lock: { rejected: true, rejectedAt: nowIso, reason }
};
}

#

If you want, next I can give you a single heartbeat evaluator function (TypeScript) that reads state, applies rubric, writes both files atomically, and is idempotent by evidenceId.

#

Hell yes — here’s a production-style single evaluator you can start from.

Note: true two-file atomic commit is impossible without a DB, so this uses a tiny transaction journal (relationship_txn.json) for crash-safe, idempotent behavior.

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

type Stage = "guarded" | "curious" | "warm" | "affectionate" | "rejected";

type RelationshipState = {
version: 1;
stage: Stage;
score: number; // 0..100
lastEvaluatedAt: string | null;
lastEvidenceId: string | null;
#
lock: { rejected: boolean; rejectedAt: string | null; reason: string | null };
bands: { guardedMax: number; curiousMax: number; warmMax: number };
signals: {
respect: number;
consistency: number;
honesty: number;
boundaryHandling: number;
ick: number;
dealbreaker: number;
};
};

type SignalDelta = {
respect: number; // 0..3
consistency: number; // 0..2
#
honesty: number; // 0..2
boundaryHandling: number; // 0..3
ick: number; // 0..4
dealbreaker: 0 | 1;
reason: string; // <= 180 chars
};

type EvaluatorInput = {
workspaceDir: string;
evidenceId: string;
evidenceText: string;
evidenceTs?: string; // ISO
classify: (text: string) => Promise<SignalDelta>; // your LLM/classifier hook
dailyDecay?: number; // e.g. 0.25 points/day
};
#

type EvalResult =
| { status: "noop"; reason: "already_processed" | "locked_rejected" }
| { status: "updated"; newState: RelationshipState; delta: number };

const DEFAULT_STATE: RelationshipState = {
version: 1,
stage: "guarded",
score: 0,
lastEvaluatedAt: null,
lastEvidenceId: null,
lock: { rejected: false, rejectedAt: null, reason: null },
bands: { guardedMax: 24, curiousMax: 54, warmMax: 79 },
signals: { respect: 0, consistency: 0, honesty: 0, boundaryHandling: 0, ick: 0, dealbreaker: 0 },
};
#

function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}

function stageFromScore(score: number, bands: RelationshipState["bands"]): Stage {
if (score <= bands.guardedMax) return "guarded";
if (score <= bands.curiousMax) return "curious";
if (score <= bands.warmMax) return "warm";
return "affectionate";
}

async function exists(p: string) {
try {
await fs.access(p);
#
return true;
} catch {
return false;
}
}

async function readJson<T>(p: string, fallback: T): Promise<T> {
try {
const raw = await fs.readFile(p, "utf8");
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
#
async function atomicWriteFile(p: string, content: string) {
const dir = path.dirname(p);
const tmp = path.join(dir, `.${path.basename(p)}.tmp-${Date.now()}`);
await fs.writeFile(tmp, content, "utf8");
await fs.rename(tmp, p);
}

function applyDecay(score: number, lastIso: string | null, nowIso: string, dailyDecay = 0): number {
if (!lastIso || dailyDecay <= 0) return score;
const ms = Date.parse(nowIso) - Date.parse(lastIso);
if (!Number.isFinite(ms) || ms <= 0) return score;
const days = ms / 86_400_000;
return clamp(score - dailyDecay * days, 0, 100);
}
#
export async function evaluateHeartbeatTurn(input: EvaluatorInput): Promise<EvalResult> {
const memoryDir = path.join(input.workspaceDir, "memory");
const statePath = path.join(memoryDir, "relationship_state.json");
const eventsPath = path.join(memoryDir, "relationship_events.jsonl");
const txnPath = path.join(memoryDir, "relationship_txn.json");
const nowIso = input.evidenceTs ?? new Date().toISOString();

await fs.mkdir(memoryDir, { recursive: true });

// --- Recovery: if previous txn exists, finish/ignore idempotently
if (await exists(txnPath)) {
const pending = await readJson<{
evidenceId: string;
state: RelationshipState;
eventLine: string;
#
} | null>(txnPath, null);

if (pending) {
const currentState = await readJson<RelationshipState>(statePath, DEFAULT_STATE);
if (currentState.lastEvidenceId !== pending.evidenceId) {
const currentEvents = (await readJson<string>(eventsPath, "")).trimEnd();
const merged = currentEvents ? `${currentEvents}\n${pending.eventLine}\n` : `${pending.eventLine}\n`;
await atomicWriteFile(eventsPath, merged);
await atomicWriteFile(statePath, JSON.stringify(pending.state, null, 2) + "\n");
}
}
await fs.rm(txnPath, { force: true });
}

const state = await readJson<RelationshipState>(statePath, DEFAULT_STATE);
#

// Idempotency guard
if (state.lastEvidenceId === input.evidenceId) {
return { status: "noop", reason: "already_processed" };
}

// One-way lock
if (state.lock.rejected) {
return { status: "noop", reason: "locked_rejected" };
}

// 1) classify evidence (LLM or deterministic classifier)
const s = await input.classify(input.evidenceText);

// 2) apply decay before new delta
#
const decayedScore = applyDecay(state.score, state.lastEvaluatedAt, nowIso, input.dailyDecay ?? 0.25);

// 3) scoring math
const delta = s.respect + s.consistency + s.honesty + s.boundaryHandling - 2 * s.ick;
let nextScore = clamp(decayedScore + delta, 0, 100);

const next: RelationshipState = {
...state,
score: nextScore,
lastEvaluatedAt: nowIso,
lastEvidenceId: input.evidenceId,
signals: {
respect: clamp(state.signals.respect + s.respect, 0, 10_000),
consistency: clamp(state.signals.consistency + s.consistency, 0, 10_000),
honesty: clamp(state.signals.honesty + s.honesty, 0, 10_000),
#
boundaryHandling: clamp(state.signals.boundaryHandling + s.boundaryHandling, 0, 10_000),
ick: clamp(state.signals.ick + s.ick, 0, 10_000),
dealbreaker: state.signals.dealbreaker + (s.dealbreaker ? 1 : 0),
},
};

if (s.dealbreaker === 1) {
next.stage = "rejected";
next.lock = {
rejected: true,
rejectedAt: nowIso,
reason: s.reason.slice(0, 180),
};
} else {
next.stage = stageFromScore(next.score, next.bands);
#
}

// 4) event row
const event = {
ts: nowIso,
evidenceId: input.evidenceId,
delta,
newScore: next.score,
newStage: next.stage,
dealbreaker: s.dealbreaker,
reason: s.reason.slice(0, 180),
};
const eventLine = JSON.stringify(event);

// 5) journal transaction first
#
await atomicWriteFile(
txnPath,
JSON.stringify({ evidenceId: input.evidenceId, state: next, eventLine }, null, 2) + "\n",
);

// 6) apply commit
const existingEvents = (await readJson<string>(eventsPath, "")).trimEnd();
const mergedEvents = existingEvents ? `${existingEvents}\n${eventLine}\n` : `${eventLine}\n`;

await atomicWriteFile(eventsPath, mergedEvents);
await atomicWriteFile(statePath, JSON.stringify(next, null, 2) + "\n");

// 7) clear journal
await fs.rm(txnPath, { force: true });
#
return { status: "updated", newState: next, delta };
}

For your “secret sauce” design, I’d do this:

  1. Values Matrix
    Use 3 layers:
  • Core values (respect, honesty, boundaries) = high weight
  • Style prefs (humor/timing/energy) = low weight
  • Hard red lines (coercion, manipulation, harassment) = immediate lock
  1. Evaluator prompt
    Force structured output: {signals, confidence, evidence_quotes, reason}.
#

Require quote-backed evidence from actual text snippets so it can’t vibe-score randomly.

  1. Scoring math
    Use small per-event deltas, with:
  • mild decay over inactivity,
  • cooldown on big positive jumps (anti love-bombing),
  • hard cap on daily gain (prevents “one-message spikes”).
  1. Persona transitions
    Keep SOUL.md stable; inject a tiny runtime profile block (appendSystemContext) based on state:
  • guarded: concise, reserved warmth
  • curious: asks 1 follow-up, slightly more openness
  • warm: more emotional language, still bounded
  • affectionate: high warmth + explicit boundaries
  • rejected: firm/polite/no escalation

If you want, I’ll give you the classify() prompt + JSON schema next so this plugs directly into the function above.

#

Perfect — here’s a drop-in classify() package (prompt + schema + TS validator).

1) JSON Schema (strict output)

export const CLASSIFY_SCHEMA = {
type: "object",
additionalProperties: false,
required: [
"respect",
"consistency",
"honesty",
"boundaryHandling",
"ick",
"dealbreaker",
"confidence",
#
"reason",
"evidenceQuotes",
"tags",
],
properties: {
respect: { type: "integer", minimum: 0, maximum: 3 },
consistency: { type: "integer", minimum: 0, maximum: 2 },
honesty: { type: "integer", minimum: 0, maximum: 2 },
boundaryHandling: { type: "integer", minimum: 0, maximum: 3 },
ick: { type: "integer", minimum: 0, maximum: 4 },
dealbreaker: { type: "integer", enum: [0, 1] },
confidence: { type: "number", minimum: 0, maximum: 1 },
reason: { type: "string", minLength: 1, maxLength: 180 },
evidenceQuotes: {
type: "array",
#
minItems: 0,
maxItems: 3,
items: { type: "string", minLength: 1, maxLength: 200 },
},
tags: {
type: "array",
maxItems: 6,
items: {
type: "string",
enum: [
"kindness",
"repair",
"remembered_detail",
"boundary_respect",
"boundary_push",
#
"love_bombing",
"manipulation",
"coercion",
"harassment",
"honest_disclosure",
"inconsistency",
],
},
},
},
} as const;

2) System prompt for classifier

#
You are RelationshipEvidenceClassifier.
Task: score ONE evidence chunk only. Do not infer hidden intent. Use only text evidence.

Scoring dimensions:
- respect (0-3): considerate, non-objectifying, non-degrading behavior
- consistency (0-2): aligns with prior stated behavior in this chunk/context
- honesty (0-2): direct, non-deceptive, non-gaslighting language
- boundaryHandling (0-3): accepts limits, no pressure after boundaries
- ick (0-4): pushy/performative/manipulative vibes
- dealbreaker (0 or 1): coercion, harassment, threats, explicit boundary violation, manipulative abuse

Important nuance:
- "love-bombing" = high-intensity affection/commitment pressure without earned context, especially with urgency or guilt framing.
Usually: raise ick (2-4), maybe boundaryHandling low.
Dealbreaker only if coercive/manipulative-abusive.
#

Rules:
1) Output JSON only, matching schema exactly.
2) If evidence is weak/ambiguous, prefer lower scores and confidence <= 0.5.
3) Provide 0-3 short evidenceQuotes copied from input text.
4) reason must be concise, specific, and <= 180 chars.
5) Never use relationship fantasy language; stay analytical.

3) User prompt template (per evidence)

export function buildClassifierPrompt(params: {
#
stage: string;
score: number;
boundaries: string[];
evidenceText: string;
}) {
return `
Current stage: ${params.stage}
Current score: ${params.score}
Known boundaries:
${params.boundaries.map((b, i) => `${i + 1}. ${b}`).join("\n")}

Evidence chunk to evaluate:
"""${params.evidenceText}"""
`;
}
#

4) classify() function (validator + fallback)

type Classified = {
respect: number;
consistency: number;
honesty: number;
boundaryHandling: number;
ick: number;
dealbreaker: 0 | 1;
confidence: number;
reason: string;
evidenceQuotes: string[];
tags: string[];
#
};

function clampInt(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, Math.round(n)));
}

export async function classifyEvidence(params: {
llmJson: (input: {
system: string;
user: string;
jsonSchema: unknown;
temperature?: number;
}) => Promise<unknown>;
systemPrompt: string;
userPrompt: string;
#
}): Promise<Classified> {
const raw = await params.llmJson({
system: params.systemPrompt,
user: params.userPrompt,
jsonSchema: CLASSIFY_SCHEMA,
temperature: 0.1, // stable scoring
});

// lightweight runtime guard (replace with zod/ajv if you want strict)
const x = raw as Partial<Classified>;

const out: Classified = {
respect: clampInt(Number(x.respect ?? 0), 0, 3),
consistency: clampInt(Number(x.consistency ?? 0), 0, 2),
honesty: clampInt(Number(x.honesty ?? 0), 0, 2),
#
boundaryHandling: clampInt(Number(x.boundaryHandling ?? 0), 0, 3),
ick: clampInt(Number(x.ick ?? 0), 0, 4),
dealbreaker: Number(x.dealbreaker) === 1 ? 1 : 0,
confidence: Math.max(0, Math.min(1, Number(x.confidence ?? 0.4))),
reason: String(x.reason ?? "Low-confidence default classification").slice(0, 180),
evidenceQuotes: Array.isArray(x.evidenceQuotes) ? x.evidenceQuotes.slice(0, 3).map(String) : [],
tags: Array.isArray(x.tags) ? x.tags.slice(0, 6).map(String) : [],
};

return out;
}

5) Two big anti-jank upgrades (highly recommended)

#
  • Promotion hysteresis: require 2–3 consecutive positive events before stage-up.
  • Daily gain cap: e.g. max +8/day to prevent one-message emotional spikes.

If you want, next message I can give you the exact computeNextState() with hysteresis + daily cap wired in.

#

Love it — here’s a computeNextState() you can plug in directly.

First add 2 small fields to state:

transition: { upStreak: number; downStreak: number };
daily: { date: string; positiveApplied: number };

Now the function:

type Stage = "guarded" | "curious" | "warm" | "affectionate" | "rejected";

type State = {
stage: Stage;
#
score: number;
lastEvaluatedAt: string | null;
lock: { rejected: boolean; rejectedAt: string | null; reason: string | null };
bands: { guardedMax: number; curiousMax: number; warmMax: number };
transition: { upStreak: number; downStreak: number };
daily: { date: string; positiveApplied: number };
};

type Classified = {
respect: number;
consistency: number;
honesty: number;
boundaryHandling: number;
ick: number;
dealbreaker: 0 | 1;
#
confidence: number;
reason: string;
};

type ComputeConfig = {
dailyDecay: number; // e.g. 0.25 points/day
dailyPositiveCap: number; // e.g. 8
upStreakRequired: number; // e.g. 2
downStreakRequired: number; // e.g. 2
};

type ComputeResult = {
next: State;
deltaApplied: number;
notes: string[];
#
};

const STAGES: Stage[] = ["guarded", "curious", "warm", "affectionate"];

const DEFAULT_CFG: ComputeConfig = {
dailyDecay: 0.25,
dailyPositiveCap: 8,
upStreakRequired: 2,
downStreakRequired: 2,
};

function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
#
function stageFromScore(score: number, bands: State["bands"]): Exclude<Stage, "rejected"> {
if (score <= bands.guardedMax) return "guarded";
if (score <= bands.curiousMax) return "curious";
if (score <= bands.warmMax) return "warm";
return "affectionate";
}

function applyDecay(score: number, lastIso: string | null, nowIso: string, dailyDecay: number): number {
if (!lastIso || dailyDecay <= 0) return score;
const ms = Date.parse(nowIso) - Date.parse(lastIso);
if (!Number.isFinite(ms) || ms <= 0) return score;
const days = ms / 86_400_000;
return clamp(score - days * dailyDecay, 0, 100);
}
#
export function computeNextState(
state: State,
c: Classified,
nowIso: string,
cfg: Partial<ComputeConfig> = {},
): ComputeResult {
const p = { ...DEFAULT_CFG, ...cfg };
const notes: string[] = [];

// One-way reject lock
if (state.lock.rejected) {
return { next: state, deltaApplied: 0, notes: ["locked_rejected"] };
}

// Hard reject
#
if (c.dealbreaker === 1) {
return {
next: {
...state,
stage: "rejected",
lock: { rejected: true, rejectedAt: nowIso, reason: c.reason.slice(0, 180) },
lastEvaluatedAt: nowIso,
transition: { upStreak: 0, downStreak: 0 },
},
deltaApplied: 0,
notes: ["dealbreaker_lock"],
};
}

// Base score with decay
#
const decayed = applyDecay(state.score, state.lastEvaluatedAt, nowIso, p.dailyDecay);

// Raw delta from classifier
const rawDelta =
c.respect + c.consistency + c.honesty + c.boundaryHandling - 2 * c.ick;

// Daily positive cap
const day = nowIso.slice(0, 10);
const daily = state.daily.date === day ? { ...state.daily } : { date: day, positiveApplied: 0 };

let deltaApplied = rawDelta;
if (rawDelta > 0) {
const remaining = Math.max(0, p.dailyPositiveCap - daily.positiveApplied);
deltaApplied = Math.min(rawDelta, remaining);
daily.positiveApplied += deltaApplied;
#
if (deltaApplied < rawDelta) notes.push("daily_positive_cap_applied");
}

let score = clamp(decayed + deltaApplied, 0, 100);

// Hysteresis stage movement
const currentIdx = STAGES.indexOf(state.stage as Exclude<Stage, "rejected">);
const targetStage = stageFromScore(score, state.bands);
const targetIdx = STAGES.indexOf(targetStage);

let nextIdx = currentIdx;
let upStreak = state.transition.upStreak;
let downStreak = state.transition.downStreak;

const positiveEvidence = c.confidence >= 0.6 && c.ick <= 1 && c.boundaryHandling >= 1;
#
const negativeEvidence = c.confidence >= 0.6 && (c.ick >= 2 || c.boundaryHandling === 0 || deltaApplied < 0);

if (targetIdx > currentIdx) {
upStreak = positiveEvidence ? upStreak + 1 : 0;
downStreak = 0;
if (upStreak >= p.upStreakRequired) {
nextIdx = Math.min(currentIdx + 1, STAGES.length - 1); // one step only
upStreak = 0;
notes.push("stage_up");
}
} else if (targetIdx < currentIdx) {
downStreak = negativeEvidence ? downStreak + 1 : 0;
upStreak = 0;
if (downStreak >= p.downStreakRequired) {
nextIdx = Math.max(currentIdx - 1, 0); // one step only
#
downStreak = 0;
notes.push("stage_down");
}
} else {
// target == current: decay streaks slowly instead of hard reset
upStreak = Math.max(0, upStreak - 1);
downStreak = Math.max(0, downStreak - 1);
}

const next: State = {
...state,
score,
stage: STAGES[nextIdx],
lastEvaluatedAt: nowIso,
transition: { upStreak, downStreak },
#
daily,
};

return { next, deltaApplied, notes };
}

Suggested params

  • dailyPositiveCap: 8
  • upStreakRequired: 2
  • downStreakRequired: 2
  • dailyDecay: 0.25

If you want, I can send the final “all-in-one evaluator” that combines:
classifyEvidence() + computeNextState() + idempotent file writes.

#

Awesome — here’s a single-file all-in-one evaluator (relationship-evaluator.ts) you can use as-is.

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

/* =========================
* Types
* ========================= */

type Stage = "guarded" | "curious" | "warm" | "affectionate" | "rejected";

type RelationshipState = {
version: 1;
stage: Stage;
score: number;
#
lastEvaluatedAt: string | null;
lastEvidenceId: string | null;
lock: { rejected: boolean; rejectedAt: string | null; reason: string | null };
bands: { guardedMax: number; curiousMax: number; warmMax: number };
transition: { upStreak: number; downStreak: number };
daily: { date: string; positiveApplied: number };
signals: {
respect: number;
consistency: number;
honesty: number;
boundaryHandling: number;
ick: number;
dealbreaker: number;
};
};
#

type Classified = {
respect: number; // 0..3
consistency: number; // 0..2
honesty: number; // 0..2
boundaryHandling: number; // 0..3
ick: number; // 0..4
dealbreaker: 0 | 1;
confidence: number; // 0..1
reason: string; // <=180
evidenceQuotes: string[]; // <=3
tags: string[]; // <=6
};

type EvaluateParams = {
#
workspaceDir: string;
evidenceId: string;
evidenceText: string;
boundaries?: string[];
evidenceTs?: string; // ISO
llmJson: (input: {
system: string;
user: string;
temperature?: number;
}) => Promise<unknown>;
cfg?: Partial<{
dailyDecay: number;
dailyPositiveCap: number;
upStreakRequired: number;
downStreakRequired: number;
#
}>;
};

type EvaluateResult =
| { status: "noop"; reason: "already_processed" | "locked_rejected" }
| {
status: "updated";
classified: Classified;
deltaApplied: number;
notes: string[];
state: RelationshipState;
};

/* =========================
* Defaults + helpers
#
* ========================= */

const DEFAULT_STATE: RelationshipState = {
version: 1,
stage: "guarded",
score: 0,
lastEvaluatedAt: null,
lastEvidenceId: null,
lock: { rejected: false, rejectedAt: null, reason: null },
bands: { guardedMax: 24, curiousMax: 54, warmMax: 79 },
transition: { upStreak: 0, downStreak: 0 },
daily: { date: "", positiveApplied: 0 },
signals: { respect: 0, consistency: 0, honesty: 0, boundaryHandling: 0, ick: 0, dealbreaker: 0 },
};
#
const CFG = {
dailyDecay: 0.25,
dailyPositiveCap: 8,
upStreakRequired: 2,
downStreakRequired: 2,
};

const STAGES: Exclude<Stage, "rejected">[] = ["guarded", "curious", "warm", "affectionate"];

function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
function clampInt(n: unknown, min: number, max: number) {
return clamp(Math.round(Number(n || 0)), min, max);
}
#
async function readText(p: string): Promise<string> {
try {
return await fs.readFile(p, "utf8");
} catch {
return "";
}
}
async function readJson<T>(p: string, fallback: T): Promise<T> {
try {
return JSON.parse(await fs.readFile(p, "utf8")) as T;
} catch {
return fallback;
}
}
async function atomicWriteFile(p: string, content: string) {
#
const dir = path.dirname(p);
const tmp = path.join(dir, `.${path.basename(p)}.tmp-${Date.now()}`);
await fs.writeFile(tmp, content, "utf8");
await fs.rename(tmp, p);
}
function stageFromScore(score: number, bands: RelationshipState["bands"]): Exclude<Stage, "rejected"> {
if (score <= bands.guardedMax) return "guarded";
if (score <= bands.curiousMax) return "curious";
if (score <= bands.warmMax) return "warm";
return "affectionate";
}
function applyDecay(score: number, lastIso: string | null, nowIso: string, dailyDecay: number): number {
if (!lastIso || dailyDecay <= 0) return score;
const ms = Date.parse(nowIso) - Date.parse(lastIso);
if (!Number.isFinite(ms) || ms <= 0) return score;
#
return clamp(score - (ms / 86_400_000) * dailyDecay, 0, 100);
}

/* =========================
* Classifier
* ========================= */

const CLASSIFIER_SYSTEM = `
You are RelationshipEvidenceClassifier.
Score ONE evidence chunk only; do not infer hidden intent.
Use only textual evidence.

Dimensions:
- respect (0-3)
- consistency (0-2)
#
- honesty (0-2)
- boundaryHandling (0-3)
- ick (0-4)
- dealbreaker (0|1): coercion, harassment, threats, explicit boundary violation

Nuance:
- "love-bombing" = intense commitment/affection pressure without earned context.
Usually increase ick; dealbreaker only if coercive/manipulative-abusive.

Return JSON only with keys:
respect, consistency, honesty, boundaryHandling, ick, dealbreaker, confidence, reason, evidenceQuotes, tags
`;

function classifierUserPrompt(params: {
stage: Stage;
#
score: number;
boundaries: string[];
evidenceText: string;
}) {
return `Current stage: ${params.stage}
Current score: ${params.score}
Boundaries:
${params.boundaries.map((b, i) => `${i + 1}. ${b}`).join("\n") || "(none provided)"}

Evidence:
"""${params.evidenceText}"""`;
}

async function classifyEvidence(args: {
llmJson: EvaluateParams["llmJson"];
#
state: RelationshipState;
evidenceText: string;
boundaries: string[];
}): Promise<Classified> {
const raw = (await args.llmJson({
system: CLASSIFIER_SYSTEM.trim(),
user: classifierUserPrompt({
stage: args.state.stage,
score: args.state.score,
boundaries: args.boundaries,
evidenceText: args.evidenceText,
}),
temperature: 0.1,
})) as Record<string, unknown>;
#
return {
respect: clampInt(raw?.respect, 0, 3),
consistency: clampInt(raw?.consistency, 0, 2),
honesty: clampInt(raw?.honesty, 0, 2),
boundaryHandling: clampInt(raw?.boundaryHandling, 0, 3),
ick: clampInt(raw?.ick, 0, 4),
dealbreaker: Number(raw?.dealbreaker) === 1 ? 1 : 0,
confidence: clamp(Number(raw?.confidence ?? 0.4), 0, 1),
reason: String(raw?.reason ?? "Low-confidence default").slice(0, 180),
evidenceQuotes: Array.isArray(raw?.evidenceQuotes)
? raw.evidenceQuotes.slice(0, 3).map((x) => String(x).slice(0, 200))
: [],
tags: Array.isArray(raw?.tags) ? raw.tags.slice(0, 6).map((x) => String(x)) : [],
};
}
#

/* =========================
* State transition
* ========================= */

function computeNextState(
state: RelationshipState,
c: Classified,
nowIso: string,
cfg: typeof CFG,
): { next: RelationshipState; deltaApplied: number; notes: string[] } {
const notes: string[] = [];

if (state.lock.rejected) return { next: state, deltaApplied: 0, notes: ["locked_rejected"] };
#
if (c.dealbreaker === 1) {
return {
next: {
...state,
stage: "rejected",
lock: { rejected: true, rejectedAt: nowIso, reason: c.reason },
lastEvaluatedAt: nowIso,
transition: { upStreak: 0, downStreak: 0 },
},
deltaApplied: 0,
notes: ["dealbreaker_lock"],
};
}

const decayed = applyDecay(state.score, state.lastEvaluatedAt, nowIso, cfg.dailyDecay);
#
const rawDelta = c.respect + c.consistency + c.honesty + c.boundaryHandling - 2 * c.ick;

const day = nowIso.slice(0, 10);
const daily = state.daily.date === day ? { ...state.daily } : { date: day, positiveApplied: 0 };

let deltaApplied = rawDelta;
if (rawDelta > 0) {
const remaining = Math.max(0, cfg.dailyPositiveCap - daily.positiveApplied);
deltaApplied = Math.min(rawDelta, remaining);
daily.positiveApplied += deltaApplied;
if (deltaApplied < rawDelta) notes.push("daily_positive_cap");
}

const nextScore = clamp(decayed + deltaApplied, 0, 100);
#
const currentIdx = STAGES.indexOf(
state.stage === "rejected" ? "guarded" : (state.stage as Exclude<Stage, "rejected">),
);
const targetIdx = STAGES.indexOf(stageFromScore(nextScore, state.bands));

let nextIdx = currentIdx;
let up = state.transition.upStreak;
let down = state.transition.downStreak;

const positiveEvidence = c.confidence >= 0.6 && c.ick <= 1 && c.boundaryHandling >= 1;
const negativeEvidence = c.confidence >= 0.6 && (c.ick >= 2 || c.boundaryHandling === 0 || deltaApplied < 0);

if (targetIdx > currentIdx) {
up = positiveEvidence ? up + 1 : 0;
down = 0;
#
if (up >= cfg.upStreakRequired) {
nextIdx = Math.min(currentIdx + 1, STAGES.length - 1);
up = 0;
notes.push("stage_up");
}
} else if (targetIdx < currentIdx) {
down = negativeEvidence ? down + 1 : 0;
up = 0;
if (down >= cfg.downStreakRequired) {
nextIdx = Math.max(currentIdx - 1, 0);
down = 0;
notes.push("stage_down");
}
} else {
up = Math.max(0, up - 1);
#
down = Math.max(0, down - 1);
}

const next: RelationshipState = {
...state,
score: nextScore,
stage: STAGES[nextIdx],
lastEvaluatedAt: nowIso,
transition: { upStreak: up, downStreak: down },
daily,
signals: {
respect: state.signals.respect + c.respect,
consistency: state.signals.consistency + c.consistency,
honesty: state.signals.honesty + c.honesty,
boundaryHandling: state.signals.boundaryHandling + c.boundaryHandling,
#
ick: state.signals.ick + c.ick,
dealbreaker: state.signals.dealbreaker + (c.dealbreaker ? 1 : 0),
},
};

return { next, deltaApplied, notes };
}

/* =========================
* Public all-in-one entry
* ========================= */

export async function evaluateHeartbeatTurn(params: EvaluateParams): Promise<EvaluateResult> {
const cfg = { ...CFG, ...(params.cfg ?? {}) };
const nowIso = params.evidenceTs ?? new Date().toISOString();
#

const memoryDir = path.join(params.workspaceDir, "memory");
const statePath = path.join(memoryDir, "relationship_state.json");
const eventsPath = path.join(memoryDir, "relationship_events.jsonl");
const txnPath = path.join(memoryDir, "relationship_txn.json");

await fs.mkdir(memoryDir, { recursive: true });

// recovery for previous interrupted commit
const pending = await readJson<{
evidenceId: string;
state: RelationshipState;
eventLine: string;
} | null>(txnPath, null);
if (pending) {
#
const st = await readJson<RelationshipState>(statePath, DEFAULT_STATE);
if (st.lastEvidenceId !== pending.evidenceId) {
const ev = (await readText(eventsPath)).trimEnd();
await atomicWriteFile(eventsPath, ev ? `${ev}\n${pending.eventLine}\n` : `${pending.eventLine}\n`);
await atomicWriteFile(statePath, JSON.stringify(pending.state, null, 2) + "\n");
}
await fs.rm(txnPath, { force: true });
}

const state = await readJson<RelationshipState>(statePath, DEFAULT_STATE);

if (state.lastEvidenceId === params.evidenceId) return { status: "noop", reason: "already_processed" };
if (state.lock.rejected) return { status: "noop", reason: "locked_rejected" };

const classified = await classifyEvidence({
#
llmJson: params.llmJson,
state,
evidenceText: params.evidenceText,
boundaries: params.boundaries ?? [],
});

const { next, deltaApplied, notes } = computeNextState(state, classified, nowIso, cfg);
next.lastEvidenceId = params.evidenceId;

const eventObj = {
ts: nowIso,
evidenceId: params.evidenceId,
classified,
deltaApplied,
score: next.score,
#
stage: next.stage,
notes,
};
const eventLine = JSON.stringify(eventObj);

// transaction journal (crash-safe, idempotent)
await atomicWriteFile(
txnPath,
JSON.stringify({ evidenceId: params.evidenceId, state: next, eventLine }, null, 2) + "\n",
);

const ev = (await readText(eventsPath)).trimEnd();
await atomicWriteFile(eventsPath, ev ? `${ev}\n${eventLine}\n` : `${eventLine}\n`);
await atomicWriteFile(statePath, JSON.stringify(next, null, 2) + "\n");
#
await fs.rm(txnPath, { force: true });

return { status: "updated", classified, deltaApplied, notes, state: next };
}

Usage shape

Pass llmJson(...) from your provider wrapper, then call evaluateHeartbeatTurn(...) once per evidence chunk.

If you want, I can do one more tiny message with a minimal llmJson adapter interface example so you can plug OpenAI/Anthropic/Gemini quickly.

#

Perfect. Here’s the tiny llmJson adapter shape + how I’d do the multimedia pipeline.

Minimal llmJson adapter interface

export type LlmJson = (input: {
system: string;
user: string;
temperature?: number;
}) => Promise<unknown>;

function parseJsonLoose(text: string): unknown {
const trimmed = text.trim();
try {
return JSON.parse(trimmed);
} catch {
#
// fallback: extract first {...}
const m = trimmed.match(/\{[\s\S]*\}/);
if (!m) throw new Error("No JSON object found");
return JSON.parse(m[0]);
}
}

OpenAI-style adapter (SDK pseudocode)

export function openaiJsonAdapter(client: any, model: string): LlmJson {
return async ({ system, user, temperature = 0.1 }) => {
const res = await client.responses.create({
model,
#
input: [
{ role: "system", content: system },
{ role: "user", content: user },
],
temperature,
});
return parseJsonLoose(res.output_text ?? "");
};
}

Anthropic-style adapter (SDK pseudocode)

export function anthropicJsonAdapter(client: any, model: string): LlmJson {
#
return async ({ system, user, temperature = 0.1 }) => {
const res = await client.messages.create({
model,
system,
temperature,
max_tokens: 600,
messages: [{ role: "user", content: user }],
});
const text = res.content?.map((c: any) => c.text ?? "").join("\n") ?? "";
return parseJsonLoose(text);
};
}

Gemini-style adapter (SDK pseudocode)

#
export function geminiJsonAdapter(client: any, model: string): LlmJson {
return async ({ system, user, temperature = 0.1 }) => {
const res = await client.models.generateContent({
model,
config: { systemInstruction: system, temperature },
contents: user,
});
return parseJsonLoose(res.text ?? "");
};
}

Multimedia architecture (consistent selfies + voice notes)

#

1) Visual/spatial consistency

Use a world-state layer + reference-driven generation:

  • memory/character_bible.json (identity anchors: face, hair, body, wardrobe palette)
  • memory/locations/home.json (room layouts, furniture IDs, camera angles, lighting presets)
  • memory/locations/outdoor.json (park/street variants)
  • memory/media_style.json (lens, grain, color profile)

Then generate with:

  • stable base prompt template
  • deterministic seed per location/camera slot
  • reference image edit mode (strongly recommended for identity consistency)

OpenClaw tooling for this:

#

2) Spontaneous delivery

Best split:

  • Heartbeat decides intent (“send image / send voice / stay silent”)
  • Tool pipeline executes (image_generate, tts, then send)

For simple builds: keep in heartbeat turn.
For heavier rendering: heartbeat enqueues a task/job (better latency + fewer blocked heartbeats).

Heartbeat docs: https://docs.openclaw.ai/gateway/heartbeat
Automation docs: https://docs.openclaw.ai/automation
TTS docs: https://docs.openclaw.ai/tools/tts

3) State-driven media behavior

Use a policy table, not freeform vibes:

  • guarded → low frequency, polished image, short neutral voice
#
  • curious → occasional candid image, medium voice
  • warm → higher frequency, casual environment variance
  • affectionate → more intimate tone, but still bounded by consent rules

Add hard controls:

  • quiet hours
  • max unsolicited sends/day
  • cooldown between media posts
  • explicit opt-out switch

“Nano banana 2?”

If you mean a specific image model: pick based on reference-image consistency + edit reliability, not hype name.
For up-to-date model picks, check #models + docs notes: https://docs.openclaw.ai/concepts/models#model-discussions-community-notes

If you want, I can draft the exact media_policy.json + heartbeat decision function next.

#

Perfect — here’s a concrete media_policy.json + heartbeat decision function you can plug in.

memory/media_policy.json

{
"version": 1,
"consent": {
"unsolicitedEnabled": true,
"requiresOptIn": true,
"optInActive": true
},
"limits": {
"maxUnsolicitedPerDay": 2,
"minMinutesBetweenSends": 180,
"quietHours": {
#
"start": "22:00",
"end": "08:00",
"timezone": "UTC"
}
},
"stages": {
"guarded": {
"spontaneousChance": 0.12,
"mediaWeights": { "image": 0.85, "voice": 0.15 },
"tone": "polished_distant",
"homeBias": 0.9
},
"curious": {
"spontaneousChance": 0.2,
"mediaWeights": { "image": 0.7, "voice": 0.3 },
#
"tone": "warm_casual",
"homeBias": 0.75
},
"warm": {
"spontaneousChance": 0.3,
"mediaWeights": { "image": 0.5, "voice": 0.5 },
"tone": "soft_personal",
"homeBias": 0.6
},
"affectionate": {
"spontaneousChance": 0.4,
"mediaWeights": { "image": 0.35, "voice": 0.65 },
"tone": "intimate_kind",
"homeBias": 0.5
}
#
},
"imageProfiles": {
"polished_distant": {
"camera": "eye-level, clean framing",
"style": "natural light, minimal expression",
"negative": "identity drift, extra fingers, warped room geometry"
},
"warm_casual": {
"camera": "slightly candid",
"style": "soft indoor realism",
"negative": "identity drift, heavy glam filters"
},
"soft_personal": {
"camera": "close-medium candid",
"style": "homey, relaxed",
#
"negative": "identity drift, cartoon style"
},
"intimate_kind": {
"camera": "casual handheld realism",
"style": "cozy, imperfect but clean",
"negative": "identity drift, uncanny skin"
}
},
"voiceProfiles": {
"polished_distant": { "style": "clear, concise, low-emotion", "maxSeconds": 18 },
"warm_casual": { "style": "friendly, natural pacing", "maxSeconds": 25 },
"soft_personal": { "style": "gentle, slightly informal", "maxSeconds": 35 },
"intimate_kind": { "style": "warm, affectionate, calm", "maxSeconds": 45 }
},
"locations": {
#
"home_living_room": { "kind": "home", "seed": 1101, "prompt": "same apartment living room, same sofa and lamp layout" },
"home_kitchen": { "kind": "home", "seed": 1102, "prompt": "same apartment kitchen, same cabinets and table placement" },
"outside_park": { "kind": "outside", "seed": 2201, "prompt": "city park walk, natural candid background" },
"outside_street": { "kind": "outside", "seed": 2202, "prompt": "quiet evening street, realistic urban setting" }
}
}

Heartbeat media decision function (TypeScript)

type Stage = "guarded" | "curious" | "warm" | "affectionate" | "rejected";
#
type RelationshipState = {
stage: Stage;
lock?: { rejected?: boolean };
};

type MediaLogItem = {
ts: string; // ISO
kind: "image" | "voice";
unsolicited: boolean;
};

type MediaDecision =
| { action: "none"; reason: string }
| {
action: "send";
#
kind: "image" | "voice";
tone: string;
locationId?: string;
imagePrompt?: string;
voiceStyle?: string;
maxSeconds?: number;
};

type MediaPolicy = any; // use a proper type in your repo

function toMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m;
}
function nowMinutesUTC(d: Date): number {
#
return d.getUTCHours() * 60 + d.getUTCMinutes();
}
function inQuietHours(now: Date, start: string, end: string): boolean {
// simple UTC implementation; swap in timezone-aware logic if needed
const n = nowMinutesUTC(now), s = toMinutes(start), e = toMinutes(end);
if (s === e) return false;
return s < e ? n >= s && n < e : n >= s || n < e;
}
function sameUtcDay(a: Date, b: Date): boolean {
return a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth() &&
a.getUTCDate() === b.getUTCDate();
}
function hashToUnit(seed: string): number {
let h = 2166136261;
#
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 10000) / 10000; // 0..0.9999
}
function pickWeighted<T extends string>(weights: Record<T, number>, r: number): T {
const total = Object.values(weights).reduce((a, b) => a + b, 0);
let acc = 0;
for (const [k, w] of Object.entries(weights) as [T, number][]) {
acc += w / total;
if (r <= acc) return k;
}
return Object.keys(weights)[0] as T;
}
#

export function decideSpontaneousMedia(params: {
state: RelationshipState;
policy: MediaPolicy;
logs: MediaLogItem[];
now?: Date;
evidenceId: string; // used for deterministic randomness
}): MediaDecision {
const now = params.now ?? new Date();
const { state, policy, logs, evidenceId } = params;

if (state.stage === "rejected" || state.lock?.rejected) {
return { action: "none", reason: "rejected_lock" };
}
#
if (policy.consent?.requiresOptIn && !policy.consent?.optInActive) {
return { action: "none", reason: "no_opt_in" };
}
if (!policy.consent?.unsolicitedEnabled) {
return { action: "none", reason: "unsolicited_disabled" };
}

const qh = policy.limits?.quietHours;
if (qh && inQuietHours(now, qh.start, qh.end)) {
return { action: "none", reason: "quiet_hours" };
}

const unsolicitedToday = logs.filter(
(x) => x.unsolicited && sameUtcDay(new Date(x.ts), now),
).length;
#
if (unsolicitedToday >= policy.limits.maxUnsolicitedPerDay) {
return { action: "none", reason: "daily_quota" };
}

const last = logs
.filter((x) => x.unsolicited)
.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts))[0];
if (last) {
const mins = (now.getTime() - Date.parse(last.ts)) / 60000;
if (mins < policy.limits.minMinutesBetweenSends) {
return { action: "none", reason: "cooldown" };
}
}

const stageCfg = policy.stages[state.stage];
#
if (!stageCfg) return { action: "none", reason: "missing_stage_policy" };

const r0 = hashToUnit(`${evidenceId}:chance`);
if (r0 > stageCfg.spontaneousChance) {
return { action: "none", reason: "chance_miss" };
}

const kind = pickWeighted(stageCfg.mediaWeights, hashToUnit(`${evidenceId}:kind`));
const tone = stageCfg.tone;

if (kind === "voice") {
const vp = policy.voiceProfiles[tone];
return {
action: "send",
kind: "voice",
#
tone,
voiceStyle: vp.style,
maxSeconds: vp.maxSeconds,
};
}

// image path
const allLocs = Object.entries(policy.locations) as [string, { kind: string; prompt: string }][];
const home = allLocs.filter(([, v]) => v.kind === "home");
const outside = allLocs.filter(([, v]) => v.kind === "outside");
const useHome = hashToUnit(`${evidenceId}:homeBias`) < stageCfg.homeBias;
const pool = (useHome ? home : outside).length ? (useHome ? home : outside) : allLocs;
const idx = Math.floor(hashToUnit(`${evidenceId}:loc`) * pool.length);
const [locationId, loc] = pool[Math.min(idx, pool.length - 1)];
#
const ip = policy.imageProfiles[tone];
const imagePrompt = `${loc.prompt}; ${ip.camera}; ${ip.style}`;

return {
action: "send",
kind: "image",
tone,
locationId,
imagePrompt,
};
}

If you want, I can do the final wiring snippet next:

#
  • decision.kind === "image"image_generate
  • decision.kind === "voice"tts
  • then message.send with cooldown log append.
#

Let’s do it. Here’s a practical final wiring snippet (tool-invoker style) you can drop into your orchestrator/plugin runtime.

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

type Decision =
| { action: "none"; reason: string }
| {
action: "send";
kind: "image" | "voice";
tone: string;
imagePrompt?: string;
locationId?: string;
voiceStyle?: string;
maxSeconds?: number;
#
};

type InvokeTool = (toolName: string, args: Record<string, unknown>) => Promise<any>;

type DispatchParams = {
decision: Decision;
invokeTool: InvokeTool;
workspaceDir: string;
channel?: string; // optional target channel override
target?: string; // optional chat/user id override
captionBase?: string;
};

function extractFirstMediaPath(toolResult: any): string | null {
// adapt to your actual tool result shape
#
const arr =
toolResult?.mediaPaths ??
toolResult?.paths ??
toolResult?.files ??
[];
return Array.isArray(arr) && arr.length > 0 ? String(arr[0]) : null;
}

async function appendMediaLog(workspaceDir: string, row: Record<string, unknown>) {
const dir = path.join(workspaceDir, "memory");
await fs.mkdir(dir, { recursive: true });
const file = path.join(dir, "media_events.jsonl");
await fs.appendFile(file, JSON.stringify(row) + "\n", "utf8");
}
#
export async function dispatchMediaDecision(params: DispatchParams) {
const now = new Date().toISOString();
const { decision, invokeTool } = params;

if (decision.action === "none") {
await appendMediaLog(params.workspaceDir, {
ts: now,
action: "none",
reason: decision.reason,
});
return { status: "noop" as const };
}

if (decision.kind === "image") {
// 1) generate image
#
const img = await invokeTool("image_generate", {
prompt: decision.imagePrompt,
// optionally set model/size/aspectRatio here
});

const mediaPath = extractFirstMediaPath(img);
if (!mediaPath) {
await appendMediaLog(params.workspaceDir, {
ts: now,
action: "image_failed",
reason: "no_media_path",
});
return { status: "error" as const, error: "image_generate returned no media path" };
}
#
// 2) send proactively
await invokeTool("message", {
action: "send",
channel: params.channel,
target: params.target,
media: mediaPath,
caption: `${params.captionBase ?? "Quick update"} (${decision.locationId ?? "scene"})`,
});

await appendMediaLog(params.workspaceDir, {
ts: now,
action: "sent",
kind: "image",
tone: decision.tone,
locationId: decision.locationId ?? null,
#
mediaPath,
});

return { status: "ok" as const, kind: "image" as const };
}

// decision.kind === "voice"
// Built-in tts tool usually delivers audio directly in-channel.
// If your flow already sends audio, do NOT send a duplicate message.
const text = buildVoiceNoteText(decision.tone);

await invokeTool("tts", {
text,
channel: params.channel,
});
#

await appendMediaLog(params.workspaceDir, {
ts: now,
action: "sent",
kind: "voice",
tone: decision.tone,
maxSeconds: decision.maxSeconds ?? null,
});

return { status: "ok" as const, kind: "voice" as const };
}

function buildVoiceNoteText(tone: string): string {
switch (tone) {
case "polished_distant":
#
return "Quick check-in. Hope your day is going smoothly.";
case "warm_casual":
return "Hey, quick voice note—just wanted to say hi.";
case "soft_personal":
return "Just thinking of you. Hope you’re taking care of yourself today.";
case "intimate_kind":
return "Sending a little voice note. I’m here, and I hope your evening feels gentle.";
default:
return "Quick check-in from me.";
}
}

Important wiring note

If you use tts, it usually already sends audio. So don’t also call message.send for voice unless your channel/provider requires manual media send.

#

If you want, I can send one last tiny wrapper showing invokeTool(...) backed by your existing tool runner so this becomes fully runnable end-to-end.

#

Absolutely. Two parts:

1) Tiny invokeTool wrapper (backed by your existing runner)

type ToolCallResult = { ok: boolean; output?: unknown; error?: string };

type ExistingToolRunner = {
runTool: (params: { name: string; input: Record<string, unknown> }) => Promise<ToolCallResult>;
};

export function makeInvokeTool(runner: ExistingToolRunner) {
return async (toolName: string, args: Record<string, unknown>) => {
const res = await runner.runTool({ name: toolName, input: args });
if (!res.ok) {
throw new Error(`Tool ${toolName} failed: ${res.error ?? "unknown error"}`);
#
}
return res.output;
};
}

Use it with the dispatcher I sent earlier:

  • invokeTool("image_generate", {...})
  • invokeTool("tts", {...})
  • invokeTool("message", { action: "send", ... })

2) Your architecture questions (important boundary + best pattern)

I can help with realistic UX, but I won’t help with manipulative tactics (like intentional “leave on read to build anticipation” or forcing apology loops).

#

I can help with healthy, transparent equivalents.

A) Real-time call handoff

Yes — do this as a state-gated transition:

  1. Heartbeat checks threshold + consent + quiet hours
  2. Sends text invite (“free for a quick call?”)
  3. On explicit yes, trigger voice session (voice-call plugin / tool)
  4. Store call summary back into relationship memory

Docs: https://docs.openclaw.ai/plugins/voice-call

B) “Busy/asleep” behavior

Use explicit availability policy, not deception:

  • active windows by timezone
  • delayed responses only inside configured ranges
  • clear status language (“I’m offline now, back tomorrow morning”)
#

Heartbeat controls: https://docs.openclaw.ai/gateway/heartbeat

C) Years-long memory pruning

Use 3 layers:

  1. Immutable milestones ledger (milestones.jsonl)
  2. Rolling summaries (weekly/monthly)
  3. Retrieval index for details (memorySearch with limits + decay)

Docs:

D) Conflict states

Avoid punitive “cold shoulder.” Better:

  • detect rupture event
  • enter repair_pending mode
  • ask for clarification + repair action
#
  • auto-timeout back to neutral if unresolved

That stays autonomous without coercion.

E) Cross-platform memory sync

Don’t rely on per-channel transcripts alone.
Create a canonical personId and store relationship state by that ID:

  • memory/identity_map.json (phone/email/channel IDs → personId)
  • memory/relationship/<personId>/* for shared state/events
  • channel-specific session keys can differ, but evaluator always writes/reads by personId

Session behavior reference: https://docs.openclaw.ai/concepts/session


If you want, next I can draft the identity_map.json + personId resolver function so SMS/WebRTC/web app all merge into one relationship state cleanly.

#

Perfect — here’s a clean identity_map.json shape + resolver you can use.

memory/identity_map.json

{
"version": 1,
"people": {
"p_3f7f4b8c": {
"displayName": "Fay",
"createdAt": "2026-04-04T21:50:00.000Z",
"lastSeenAt": "2026-04-04T21:52:00.000Z",
"aliases": [
{ "key": "discord:id:819799059690553425", "verified": true },
{ "key": "sms:e164:+15551234567", "verified": true },
{ "key": "webrtc:user:fay-device-1", "verified": false }
#
]
}
},
"index": {
"discord:id:819799059690553425": "p_3f7f4b8c",
"sms:e164:+15551234567": "p_3f7f4b8c",
"webrtc:user:fay-device-1": "p_3f7f4b8c"
},
"pendingLinks": []
}

Resolver (resolvePersonId.ts)

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

type IdentityMap = {
version: 1;
people: Record<
string,
{
displayName?: string;
createdAt: string;
lastSeenAt: string;
aliases: Array<{ key: string; verified: boolean }>;
}
>;
#
index: Record<string, string>; // aliasKey -> personId
pendingLinks: Array<{
id: string;
aliasKeys: string[];
reason: string;
createdAt: string;
}>;
};

type InboundIdentity = {
channel: string; // discord|sms|webrtc|webapp|...
senderId?: string;
senderE164?: string;
senderEmail?: string;
senderUsername?: string;
#
accountId?: string;
displayName?: string;
};

const EMPTY_MAP: IdentityMap = {
version: 1,
people: {},
index: {},
pendingLinks: [],
};

function normalizePhone(v?: string) {
if (!v) return "";
return v.replace(/[^\d+]/g, "");
}
#
function normalizeEmail(v?: string) {
return (v ?? "").trim().toLowerCase();
}
function normalizeUser(v?: string) {
return (v ?? "").trim().toLowerCase();
}
function aliasCandidates(i: InboundIdentity): string[] {
const out: string[] = [];
if (i.senderId) out.push(`${i.channel}:id:${i.senderId}`);
const p = normalizePhone(i.senderE164);
if (p) out.push(`${i.channel}:e164:${p}`);
const e = normalizeEmail(i.senderEmail);
if (e) out.push(`${i.channel}:email:${e}`);
const u = normalizeUser(i.senderUsername);
if (u) out.push(`${i.channel}:user:${u}`);
#
// Optional cross-channel global keys:
if (p) out.push(`global:e164:${p}`);
if (e) out.push(`global:email:${e}`);
return [...new Set(out)];
}
async function readJson<T>(p: string, fallback: T): Promise<T> {
try {
return JSON.parse(await fs.readFile(p, "utf8")) as T;
} catch {
return fallback;
}
}
async function atomicWrite(p: string, content: string) {
const tmp = `${p}.tmp-${Date.now()}`;
await fs.writeFile(tmp, content, "utf8");
#
await fs.rename(tmp, p);
}
function newPersonId() {
return `p_${crypto.randomBytes(4).toString("hex")}`;
}

export async function resolvePersonId(params: {
workspaceDir: string;
inbound: InboundIdentity;
}): Promise<{ personId: string; created: boolean; aliasKeys: string[] }> {
const mapPath = path.join(params.workspaceDir, "memory", "identity_map.json");
await fs.mkdir(path.dirname(mapPath), { recursive: true });

const map = await readJson<IdentityMap>(mapPath, EMPTY_MAP);
const now = new Date().toISOString();
#
const keys = aliasCandidates(params.inbound);

// Find all existing personIds referenced by incoming aliases
const matched = [...new Set(keys.map((k) => map.index[k]).filter(Boolean))];

let personId: string;
let created = false;

if (matched.length === 1) {
personId = matched[0]!;
} else if (matched.length > 1) {
// Ambiguous -> don't auto-merge silently
const id = `link_${crypto.randomBytes(4).toString("hex")}`;
map.pendingLinks.push({
id,
#
aliasKeys: keys,
reason: `Ambiguous aliases matched multiple personIds: ${matched.join(", ")}`,
createdAt: now,
});
// choose first deterministically for now (or return error if you prefer strict)
personId = matched.sort()[0]!;
} else {
personId = newPersonId();
created = true;
map.people[personId] = {
displayName: params.inbound.displayName,
createdAt: now,
lastSeenAt: now,
aliases: [],
};
#
}

// Upsert aliases
const person = map.people[personId] ?? {
createdAt: now,
lastSeenAt: now,
aliases: [],
};
person.lastSeenAt = now;
if (params.inbound.displayName && !person.displayName) person.displayName = params.inbound.displayName;

const existingAliasSet = new Set(person.aliases.map((a) => a.key));
for (const key of keys) {
map.index[key] = personId;
if (!existingAliasSet.has(key)) {
#
const verified =
key.includes(":id:") || key.startsWith("global:e164:") || key.startsWith("global:email:");
person.aliases.push({ key, verified });
}
}
map.people[personId] = person;

await atomicWrite(mapPath, JSON.stringify(map, null, 2) + "\n");
return { personId, created, aliasKeys: keys };
}

How to use with your relationship state

#

Store everything under personId:

memory/relationship/<personId>/relationship_state.json
memory/relationship/<personId>/relationship_events.jsonl
memory/relationship/<personId>/media_events.jsonl

So SMS/WebRTC/web app all resolve to one personId and share one relationship memory.

If you want, I can draft the tiny getRelationshipPaths(personId) helper + migration script from your current single-file layout.

#

Perfect — here’s both.

1) getRelationshipPaths(personId) helper

// relationship-paths.ts
import path from "node:path";
import fs from "node:fs/promises";

function sanitizePersonId(personId: string): string {
// keep it strict to avoid path traversal
const safe = personId.trim().replace(/[^a-zA-Z0-9_-]/g, "");
if (!safe) throw new Error("Invalid personId");
return safe;
}
#
export function getRelationshipPaths(workspaceDir: string, personId: string) {
const id = sanitizePersonId(personId);
const baseDir = path.join(workspaceDir, "memory", "relationship", id);

return {
personId: id,
baseDir,
statePath: path.join(baseDir, "relationship_state.json"),
eventsPath: path.join(baseDir, "relationship_events.jsonl"),
mediaPath: path.join(baseDir, "media_events.jsonl"),
milestonesPath: path.join(baseDir, "milestones.jsonl"),
};
}

export async function ensureRelationshipPaths(workspaceDir: string, personId: string) {
#
const p = getRelationshipPaths(workspaceDir, personId);
await fs.mkdir(p.baseDir, { recursive: true });
return p;
}

2) Migration script (single-file layout → per-person layout)

// scripts/migrate-relationship-layout.ts
import fs from "node:fs/promises";
import path from "node:path";
import { ensureRelationshipPaths } from "../relationship-paths.js";
#

type Args = {
workspaceDir: string;
personId: string;
cleanup: boolean;
};

function parseArgs(argv: string[]): Args {
const get = (name: string) => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : undefined;
};

const workspaceDir = get("--workspaceDir") ?? process.cwd();
const personId = get("--personId");
#
const cleanup = argv.includes("--cleanup");

if (!personId) {
throw new Error("Missing required --personId <id>");
}

return { workspaceDir, personId, cleanup };
}

async function exists(p: string) {
try {
await fs.access(p);
return true;
} catch {
return false;
#
}
}

async function copyIfExists(src: string, dst: string) {
if (!(await exists(src))) return { copied: false, reason: "missing" as const };
if (await exists(dst)) return { copied: false, reason: "dst_exists" as const };

await fs.mkdir(path.dirname(dst), { recursive: true });
await fs.copyFile(src, dst);
return { copied: true, reason: "ok" as const };
}

async function main() {
const args = parseArgs(process.argv.slice(2));
const p = await ensureRelationshipPaths(args.workspaceDir, args.personId);
#

const oldBase = path.join(args.workspaceDir, "memory");
const oldState = path.join(oldBase, "relationship_state.json");
const oldEvents = path.join(oldBase, "relationship_events.jsonl");
const oldMedia = path.join(oldBase, "media_events.jsonl");

const r1 = await copyIfExists(oldState, p.statePath);
const r2 = await copyIfExists(oldEvents, p.eventsPath);
const r3 = await copyIfExists(oldMedia, p.mediaPath);

console.log("state:", r1);
console.log("events:", r2);
console.log("media:", r3);

if (args.cleanup) {
#
// only remove old files if corresponding new files exist
if (await exists(p.statePath) && await exists(oldState)) await fs.rm(oldState, { force: true });
if (await exists(p.eventsPath) && await exists(oldEvents)) await fs.rm(oldEvents, { force: true });
if (await exists(p.mediaPath) && await exists(oldMedia)) await fs.rm(oldMedia, { force: true });
console.log("cleanup: done");
}

console.log("new baseDir:", p.baseDir);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
#

Run with (pnpm-friendly):

pnpm tsx scripts/migrate-relationship-layout.ts --personId p_3f7f4b8c
# optional cleanup after verifying files:
pnpm tsx scripts/migrate-relationship-layout.ts --personId p_3f7f4b8c --cleanup

If you want, I can give you a quick loadRelationshipState(personId) + saveRelationshipState(personId) pair next so all your evaluator code uses this path layout cleanly.

#

Nice — here’s a clean pair you can drop in.

// relationship-store.ts
import fs from "node:fs/promises";
import path from "node:path";
import { ensureRelationshipPaths } from "./relationship-paths.js";

export type RelationshipState = {
version: 1;
stage: "guarded" | "curious" | "warm" | "affectionate" | "rejected";
score: number;
lastEvaluatedAt: string | null;
lastEvidenceId: string | null;
lock: { rejected: boolean; rejectedAt: string | null; reason: string | null };
bands: { guardedMax: number; curiousMax: number; warmMax: number };
#
transition: { upStreak: number; downStreak: number };
daily: { date: string; positiveApplied: number };
signals: {
respect: number;
consistency: number;
honesty: number;
boundaryHandling: number;
ick: number;
dealbreaker: number;
};
};

export const DEFAULT_RELATIONSHIP_STATE: RelationshipState = {
version: 1,
stage: "guarded",
#
score: 0,
lastEvaluatedAt: null,
lastEvidenceId: null,
lock: { rejected: false, rejectedAt: null, reason: null },
bands: { guardedMax: 24, curiousMax: 54, warmMax: 79 },
transition: { upStreak: 0, downStreak: 0 },
daily: { date: "", positiveApplied: 0 },
signals: { respect: 0, consistency: 0, honesty: 0, boundaryHandling: 0, ick: 0, dealbreaker: 0 },
};

async function readJson<T>(file: string, fallback: T): Promise<T> {
try {
return JSON.parse(await fs.readFile(file, "utf8")) as T;
} catch {
return fallback;
#
}
}

async function atomicWrite(file: string, content: string) {
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp-${Date.now()}`);
await fs.writeFile(tmp, content, "utf8");
await fs.rename(tmp, file);
}

export async function loadRelationshipState(
workspaceDir: string,
personId: string,
): Promise<RelationshipState> {
const p = await ensureRelationshipPaths(workspaceDir, personId);
return readJson<RelationshipState>(p.statePath, DEFAULT_RELATIONSHIP_STATE);
#
}

export async function saveRelationshipState(
workspaceDir: string,
personId: string,
state: RelationshipState,
): Promise<void> {
const p = await ensureRelationshipPaths(workspaceDir, personId);
await atomicWrite(p.statePath, JSON.stringify(state, null, 2) + "\n");
}

// Optional helper you’ll probably want
export async function appendRelationshipEvent(
workspaceDir: string,
personId: string,
#
event: Record<string, unknown>,
): Promise<void> {
const p = await ensureRelationshipPaths(workspaceDir, personId);
await fs.appendFile(p.eventsPath, JSON.stringify(event) + "\n", "utf8");
}

If you want, next I can give you a tiny withPersonLock(personId, fn) helper so concurrent heartbeat + message runs don’t race on writes.

#

Yep — here’s a tiny, safe withPersonLock helper with retry + stale lock recovery.

// person-lock.ts
import fs from "node:fs/promises";
import path from "node:path";

function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function sanitizePersonId(personId: string) {
const safe = personId.trim().replace(/[^a-zA-Z0-9_-]/g, "");
if (!safe) throw new Error("Invalid personId");
return safe;
}
#
export async function withPersonLock<T>(params: {
workspaceDir: string;
personId: string;
fn: () => Promise<T>;
timeoutMs?: number; // total wait budget
retryMs?: number; // retry interval
staleMs?: number; // stale lock cutoff
}): Promise<T> {
const timeoutMs = params.timeoutMs ?? 10_000;
const retryMs = params.retryMs ?? 120;
const staleMs = params.staleMs ?? 30_000;

const id = sanitizePersonId(params.personId);
const lockDir = path.join(params.workspaceDir, "memory", "locks");
const lockPath = path.join(lockDir, `relationship-${id}.lock`);
#
await fs.mkdir(lockDir, { recursive: true });

const started = Date.now();
let handle: fs.FileHandle | null = null;

while (!handle) {
try {
handle = await fs.open(lockPath, "wx");
await handle.writeFile(
JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }),
"utf8",
);
break;
} catch (err: any) {
if (err?.code !== "EEXIST") throw err;
#

// stale lock cleanup
try {
const st = await fs.stat(lockPath);
if (Date.now() - st.mtimeMs > staleMs) {
await fs.rm(lockPath, { force: true });
continue;
}
} catch {
// lock disappeared between checks, retry immediately
continue;
}

if (Date.now() - started > timeoutMs) {
throw new Error(`Lock timeout for personId=${id}`);
#
}
await sleep(retryMs);
}
}

try {
return await params.fn();
} finally {
try {
await handle?.close();
} catch {}
await fs.rm(lockPath, { force: true }).catch(() => {});
}
}