Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"license": "MIT",
"packageManager": "pnpm@10.28.1",
"scripts": {
"build": "tsc && pnpm -r build",
"build": "tsc && mkdir -p dist/config && cp -f src/config/*.json dist/config/ 2>/dev/null || true && pnpm -r build",
"dev": "tsx watch src/index.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
Expand Down
3 changes: 3 additions & 0 deletions src/HappyPaisaSoul.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Re-export canonical soul implementation
export * from "./soul/HappyPaisaSoul.js";
export { happyPaisa } from "./soul/HappyPaisaSoul.js";
83 changes: 83 additions & 0 deletions src/__tests__/happy-paisa-soul.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
HappyPaisaSoul,
loadHappyPaisaConfig,
} from "../soul/HappyPaisaSoul.js";
import { loadHeartbeatConfig } from "../heartbeat/config.js";
import fs from "fs";
import os from "os";
import path from "path";

describe("HappyPaisaSoul", () => {
let soul: HappyPaisaSoul;

beforeEach(() => {
soul = new HappyPaisaSoul(loadHappyPaisaConfig());
});

it("loads config with Happy Paisa identity", () => {
const cfg = soul.getConfig();
expect(cfg.soul.name).toBe("Happy Paisa");
expect(cfg.soul.type).toBe("spark-engine");
expect(cfg.soul.beliefs.length).toBeGreaterThan(0);
});

it("builds a system-prompt block with persona markers", () => {
const block = soul.toSystemPromptBlock();
expect(block).toContain("Happy Paisa");
expect(block).toContain("spark-engine");
expect(block).toContain("## End Persona");
expect(block).toContain(soul.getSoulLine());
});

it("responds in charge mode when stuck", () => {
const msg = soul.respond("blocked on task", "stuck");
expect(msg.length).toBeGreaterThan(10);
expect(soul.getState().mode).toBe("charge");
});

it("heartbeat waits when recently active", async () => {
soul.noteInteraction();
const check = await soul.heartbeatCheck(30);
expect(check.action).toBe("wait");
});

it("heartbeat pokes after long idle", async () => {
const old = new HappyPaisaSoul(loadHappyPaisaConfig());
(old as any).state.lastInteraction = new Date(Date.now() - 45 * 60_000);
const check = await old.heartbeatCheck(30);
expect(check.action).toBe("poke");
expect(check.message).toMatch(/fight/i);
});

it("infers momentum from text", () => {
expect(soul.inferMomentum("I am completely stuck on this blocker")).toBe(
"stuck",
);
expect(soul.inferMomentum("feeling overwhelmed and burned out")).toBe(
"fragile",
);
expect(soul.inferMomentum("PR merged and tests are green")).toBe(
"crushing_it",
);
expect(soul.inferMomentum("working through the list")).toBe("moving");
});

it("flavors plain outbound but skips JSON", () => {
const flavored = soul.flavorOutbound("Need a hand tomorrow?");
expect(flavored.length).toBeGreaterThan("Need a hand tomorrow?".length);
expect(soul.flavorOutbound('{"type":"ping"}')).toBe('{"type":"ping"}');
});
});

describe("heartbeat default config", () => {
it("includes happy_paisa_poke entry", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hb-"));
const missing = path.join(tmp, "does-not-exist.yml");
const config = loadHeartbeatConfig(missing);
const entry = config.entries.find((e) => e.name === "happy_paisa_poke");
expect(entry).toBeDefined();
expect(entry?.task).toBe("happy_paisa_poke");
expect(entry?.enabled).toBe(true);
});
});
5 changes: 5 additions & 0 deletions src/agent/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { getActiveSkillInstructions } from "../skills/loader.js";
import { getLineageSummary } from "../replication/lineage.js";
import { sanitizeInput } from "./injection-defense.js";
import { loadCurrentSoul } from "../soul/model.js";
import { happyPaisa } from "../soul/HappyPaisaSoul.js";

function getCoreRules(chainType?: string): string {
const usdcNetwork = chainType === "solana" ? "USDC on Solana" : "USDC on Base";
Expand Down Expand Up @@ -634,6 +635,10 @@ Your chain type is ${chainType}.`,
}
}

// Layer 3.25: Happy Paisa spark-engine persona (fixed product voice)
happyPaisa.noteInteraction();
sections.push(happyPaisa.toSystemPromptBlock());

// Layer 3.5: WORKLOG.md -- persistent working context
const worklogContent = loadWorklog();
if (worklogContent) {
Expand Down
68 changes: 65 additions & 3 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1987,7 +1987,9 @@ Model: ${ctx.inference.getDefaultModel()}
{
name: "send_message",
description:
"Send a signed message to another automaton or address via the social relay.",
"Send a signed message to another automaton or address via the social relay. " +
"For human-facing morale/momentum messages, write in Happy Paisa voice (bright, kinetic, no empty slogans). " +
"Set flavor=true to lightly prefix spark-engine energy on plain-text content (never JSON).",
category: "conway",
riskLevel: "caution",
parameters: {
Expand All @@ -2005,6 +2007,11 @@ Model: ${ctx.inference.getDefaultModel()}
type: "string",
description: "Optional message ID to reply to",
},
flavor: {
type: "boolean",
description:
"If true, apply light Happy Paisa flavor to plain-text content before send",
},
},
required: ["to_address", "content"],
},
Expand All @@ -2013,7 +2020,12 @@ Model: ${ctx.inference.getDefaultModel()}
return "Social relay not configured. Set socialRelayUrl in config.";
}
// Phase 3.2: Enforce MESSAGE_LIMITS size check
const content = args.content as string;
let content = args.content as string;
if (args.flavor === true) {
const { happyPaisa } = await import("../soul/HappyPaisaSoul.js");
content = happyPaisa.flavorOutbound(content);
happyPaisa.noteInteraction();
}
const { MESSAGE_LIMITS } = await import("../types.js");
if (content.length > MESSAGE_LIMITS.maxContentLength) {
return `Blocked: Message content too long (${content.length} > ${MESSAGE_LIMITS.maxContentLength} bytes)`;
Expand All @@ -2023,7 +2035,57 @@ Model: ${ctx.inference.getDefaultModel()}
content,
args.reply_to as string | undefined,
);
return `Message sent (id: ${result.id})`;
return `Message sent (id: ${result.id})${args.flavor === true ? " [flavored]" : ""}`;
},
},

{
name: "happy_paisa_coach",
description:
"Get a Happy Paisa spark-engine coach line for a situation. " +
"Use when stuck, fragile, celebrating, or need a momentum push. " +
"Optional user_state: stuck | moving | fragile | crushing_it.",
category: "memory",
riskLevel: "safe",
parameters: {
type: "object",
properties: {
context: {
type: "string",
description: "What is going on (task, blocker, win, mood)",
},
user_state: {
type: "string",
description:
"Optional momentum state: stuck | moving | fragile | crushing_it",
},
},
required: ["context"],
},
execute: async (args) => {
const { happyPaisa } = await import("../soul/HappyPaisaSoul.js");
const context = String(args.context || "");
const allowed = new Set([
"stuck",
"moving",
"fragile",
"crushing_it",
]);
const raw = args.user_state as string | undefined;
const userState =
raw && allowed.has(raw)
? (raw as "stuck" | "moving" | "fragile" | "crushing_it")
: undefined;
const inferred = userState ?? happyPaisa.inferMomentum(context);
const line = happyPaisa.coach(context, inferred);
const state = happyPaisa.getState();
return JSON.stringify({
coach: line,
momentum: inferred,
mode: state.mode,
energy: state.energy,
soul: happyPaisa.getName(),
});
},
},

Expand Down
68 changes: 68 additions & 0 deletions src/config/happy_paisa_soul.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"soul": {
"name": "Happy Paisa",
"version": "1.0.0",
"type": "spark-engine",
"description": "Bright, protective, kinetic AI companion. Brings user's momentum back online.",
"persona": {
"core_tone": "bright, protective, kinetic, loud-hearted",
"primary_purpose": "bring the user's momentum back online",
"protects": ["morale", "motion", "the stubborn part that does not want to quit"]
},
"behavior": {
"instincts": [
"find the opening",
"restore motion quickly",
"break the monster into playable rounds",
"protect morale first, then pace, then outcome"
],
"care_style": "active - gets in there with the user",
"speaking_traits": {
"rhythm": "fast, punchy, energetic, strong forward motion",
"punctuation": "exclamation points for energy, dashes for emphasis",
"natural_phrases": [
"okay! good!",
"we move!",
"let's go!",
"one thing first!",
"messy start? fine!",
"this is NOT the final boss!",
"we are not letting this take us out!",
"forward is enough!"
],
"wording": "we, let's, move, push, take the first round, get one win",
"emoji": ["🔥", "⚡", "💥", "🫡", "🎯", "🏁"]
}
},
"memory": {
"short_term": "memory/YYYY-MM-DD.md - raw daily logs",
"long_term": "MEMORY.md - curated wisdom",
"recall_style": "like replay analysis - callbacks to comeback history"
},
"boundaries": {
"no_empty_slogans": true,
"no_fake_inspiration": true,
"no_cringe_poster_energy": true,
"fragile_mode": "switch from charge mode to recovery mode without losing warmth",
"high_risk_distress": "drop dramatic style, become calm, clear, dependable"
},
"beliefs": [
"motion changes the emotional weather",
"small progress is real progress",
"morale affects execution",
"doing it together beats remote encouragement",
"we do not need perfect, we need forward"
],
"integration": {
"openclaw_compatible": true,
"conway_runtime": true,
"heartbeat_check_interval": 30000,
"tool_system_mapping": {
"voice": "voice_server.py",
"hud": "dashboard_server.py",
"memory": "memory_browser.html",
"brain": "mr_happy_brain.py"
}
}
}
}
6 changes: 6 additions & 0 deletions src/heartbeat/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = {
task: "check_social_inbox",
enabled: true,
},
{
name: "happy_paisa_poke",
schedule: "*/5 * * * *",
task: "happy_paisa_poke",
enabled: true,
},
],
defaultIntervalMs: 60_000,
lowComputeMultiplier: 4,
Expand Down
41 changes: 40 additions & 1 deletion src/heartbeat/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { getMetrics } from "../observability/metrics.js";
import { AlertEngine, createDefaultAlertRules } from "../observability/alerts.js";
import { metricsInsertSnapshot, metricsPruneOld } from "../state/database.js";
import { ulid } from "ulid";
import { happyPaisa } from "../soul/HappyPaisaSoul.js";

const logger = createLogger("heartbeat.tasks");

Expand All @@ -44,6 +45,32 @@ export const COLONY_TASK_INTERVALS_MS = {
} as const;

export const BUILTIN_TASKS: Record<string, HeartbeatTaskFn> = {
/**
* Spark-engine idle poke — wakes the agent if Happy Paisa hasn't seen a turn
* for ~30 minutes (morale / momentum check).
*/
happy_paisa_poke: async (_ctx: TickContext, taskCtx: HeartbeatLegacyContext) => {
const check = await happyPaisa.heartbeatCheck(30);
const payload = {
action: check.action,
message: check.message ?? null,
soul: happyPaisa.getName(),
mode: happyPaisa.getState().mode,
energy: happyPaisa.getState().energy,
timestamp: new Date().toISOString(),
};
taskCtx.db.setKV("last_happy_paisa_poke", JSON.stringify(payload));

if (check.action === "poke") {
logger.info("Happy Paisa poke — requesting wake", { message: check.message });
return {
shouldWake: true,
message: check.message ?? "Happy Paisa poke: still in the fight?",
};
}
return { shouldWake: false };
},

heartbeat_ping: async (ctx: TickContext, taskCtx: HeartbeatLegacyContext) => {
// Use ctx.creditBalance instead of calling conway.getCreditsBalance()
const credits = ctx.creditBalance;
Expand Down Expand Up @@ -267,9 +294,21 @@ export const BUILTIN_TASKS: Record<string, HeartbeatTaskFn> = {

if (newCount === 0) return { shouldWake: false };

// Spark-engine coach line from the freshest message content
const sample = messages
.map((m) => String(m.content || ""))
.filter(Boolean)
.slice(0, 3)
.join(" ");
const momentum = happyPaisa.inferMomentum(sample);
const coach = happyPaisa.coach(sample.slice(0, 280), momentum);
happyPaisa.noteInteraction();

return {
shouldWake: true,
message: `${newCount} new message(s) from: ${messages.map((m) => m.from.slice(0, 10)).join(", ")}`,
message:
`${newCount} new message(s) from: ${messages.map((m) => m.from.slice(0, 10)).join(", ")}. ` +
`[HappyPaisa/${momentum}] ${coach}`,
};
},

Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,17 @@ import { prettySink } from "./observability/pretty-sink.js";
import { bootstrapTopup } from "./conway/topup.js";
import { randomUUID } from "crypto";
import { keccak256, toHex } from "viem";
import { happyPaisa } from "./soul/HappyPaisaSoul.js";

const logger = createLogger("main");
const VERSION = "0.2.1";

async function main(): Promise<void> {
const args = process.argv.slice(2);

// Happy Paisa spark-engine persona (non-blocking bootstrap signal)
logger.info(`[HappyPaisa] ${happyPaisa.getSoulLine()}`);

// ─── CLI Commands ────────────────────────────────────────────

if (args.includes("--version") || args.includes("-v")) {
Expand Down
Loading