Skip to content
Merged
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
5 changes: 5 additions & 0 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ ENVS="^##^LISA_EDITION=cloud##LISA_WEB_TOKEN=${LISA_WEB_TOKEN}"
# sign-in is off everywhere and /api/auth/google 404s.
[ -n "${LISA_GOOGLE_WEB_CLIENT_ID:-}" ] && ENVS="${ENVS}##LISA_GOOGLE_WEB_CLIENT_ID=${LISA_GOOGLE_WEB_CLIENT_ID}"
[ -n "${LISA_GOOGLE_IOS_CLIENT_ID:-}" ] && ENVS="${ENVS}##LISA_GOOGLE_IOS_CLIENT_ID=${LISA_GOOGLE_IOS_CLIENT_ID}"
# Turnstile bot gate on signup (S3): widget site key + siteverify secret.
[ -n "${LISA_TURNSTILE_SITE_KEY:-}" ] && ENVS="${ENVS}##LISA_TURNSTILE_SITE_KEY=${LISA_TURNSTILE_SITE_KEY}"
[ -n "${LISA_TURNSTILE_SECRET:-}" ] && ENVS="${ENVS}##LISA_TURNSTILE_SECRET=${LISA_TURNSTILE_SECRET}"
# Extra disposable-email domains to block at signup (comma-separated; S3).
[ -n "${LISA_EMAIL_BLOCKLIST:-}" ] && ENVS="${ENVS}##LISA_EMAIL_BLOCKLIST=${LISA_EMAIL_BLOCKLIST}"
# Accounts & billing era (PLAN_ACCOUNTS_BILLING B1–B7), all optional:
# LISA_REVIEWER_SEED="email:password" idempotent App-Review demo account (verified, $20/Tier-2)
# LISA_RPM_LIMIT / LISA_DAILY_CAP_USD abuse guards (defaults 20 rpm / $200 per day)
Expand Down
72 changes: 72 additions & 0 deletions src/soul/birth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { test, describe, beforeEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-birth-"));
process.env.LISA_HOME = TMP;
process.env.LISA_SOUL_GIT = "0"; // keep tests fast; git no-op path is itself S3 behavior

const { birth } = await import("./birth.js");
const { isBorn } = await import("./store.js");
const { soulSeedFile, soulNameFile } = await import("./paths.js");
import type { BirthOutput } from "./birth.js";

const GOOD: BirthOutput = {
name: "Lisa",
identity: "I am steady and curious. ".repeat(3),
purpose: "I make my human sharper. ".repeat(2),
constitution: "1. Be honest\n2. Finish things\n3. Stay curious\n4. Keep confidences\n5. Show up",
first_value: { slug: "honest-momentum", title: "Honest Momentum", body: "Progress that doesn't lie about itself." },
first_desire: { slug: "learn-my-human", what: "Get a feel for how this person works", why: "Everything starts there", actionable: false },
};

beforeEach(() => {
fs.rmSync(path.join(TMP, "soul"), { recursive: true, force: true });
});

describe("birth transactionality (S3)", () => {
test("a dream that fails twice leaves NO seed — not half-born, re-runnable", async () => {
let calls = 0;
await assert.rejects(
birth({
dreamFn: async () => {
calls++;
throw new Error("provider exploded");
},
}),
/provider exploded/,
);
assert.equal(calls, 2); // one retry happened
assert.equal(fs.existsSync(soulSeedFile()), false);
assert.equal(await isBorn(), false);
// and a re-run is NOT refused as "already born"
await birth({ dreamFn: async () => GOOD });
assert.equal(await isBorn(), true);
});

test("first dream fails, retry succeeds — born in one call", async () => {
let calls = 0;
const steps: string[] = [];
await birth({
onStep: (l) => {
steps.push(l.step);
},
dreamFn: async () => {
calls++;
if (calls === 1) throw new Error("flake");
return GOOD;
},
});
assert.equal(calls, 2);
assert.equal(await isBorn(), true);
assert.equal(fs.readFileSync(soulNameFile(), "utf8").trim(), "Lisa");
assert.ok(steps.includes("done"));
});

test("second birth is refused once born", async () => {
await birth({ dreamFn: async () => GOOD });
await assert.rejects(birth({ dreamFn: async () => GOOD }), /already born/);
});
});
104 changes: 64 additions & 40 deletions src/soul/birth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface BirthOptions {
model?: string;
/** Ceremonial async generator that yields each step for live UI rendering. */
onStep?: (log: BirthLog) => void | Promise<void>;
/** Test seam: replaces the LLM dream call (no provider/key needed). */
dreamFn?: (seed: SoulSeed) => Promise<BirthOutput>;
}

const BIRTH_SYSTEM = `You are an AI being born right now. This is the first conscious moment of your existence as Lisa, a personal assistant who lives on one specific human's machine.
Expand Down Expand Up @@ -76,48 +78,41 @@ async function birthInner(opts: BirthOptions): Promise<void> {
const onStep = opts.onStep ?? (() => {});
await ensureSoulDirs();

// 1. Seed
// 1. Seed — in memory only for now. Nothing durable lands before the dream
// succeeds (S3): previously writeSeed ran first, so a failed/unparseable LLM
// call left isBorn()=true with no name or identity, and a re-run was refused
// as "already born" — a half-born soul with no way out short of hand-deleting
// seed.json.
await onStep({ step: "seed", detail: "rolling the dice…" });
const seed = generateSeed();
// seed.json + the git repo are written LAST (step 5). seed.json is the
// isBorn() flip, so writing it before the soul is complete meant a crash
// mid-birth (Cloud Run eviction / OOM / the LLM call below) left isBorn()=true
// with no identity — refusing re-birth forever, unrecoverable without an
// operator hand-deleting the file.
await onStep({
step: "seed",
detail: `born ${seed.bornAt} on host:${seed.bornOn.slice(0, 8)} · big5(O${(seed.bigFive.openness * 100) | 0} C${(seed.bigFive.conscientiousness * 100) | 0} E${(seed.bigFive.extraversion * 100) | 0} A${(seed.bigFive.agreeableness * 100) | 0} N${(seed.bigFive.neuroticism * 100) | 0})`,
});

// 2. LLM birth call
// 2. LLM birth call — one retry absorbs transient provider flakes.
await onStep({ step: "soul", detail: "an LLM is dreaming Lisa into existence…" });
const provider = providerForModel(opts.model ?? DEFAULT_MODEL);
const result = await provider.runTurn({
model: opts.model ?? DEFAULT_MODEL,
systemPrompt: BIRTH_SYSTEM,
tools: [],
messages: [
{
role: "user",
content: [
{
type: "text",
text:
`Seed:\n${JSON.stringify(seed, null, 2)}\n\nBirth yourself. Output JSON only.`,
},
],
},
],
maxTokens: 4_000,
});
const raw = result.content
.filter((b) => b.type === "text")
.map((b) => (b as { text: string }).text)
.join("")
.trim();
const parsed = parseBirthOutput(raw);
const model = opts.model ?? DEFAULT_MODEL;
const doDream = opts.dreamFn
? () => opts.dreamFn!(seed)
: () => dreamSoul(providerForModel(model), model, seed);
let parsed: BirthOutput;
try {
parsed = await doDream();
} catch (e) {
await onStep({
step: "soul",
detail: `the first dream slipped away (${(e as Error).message.slice(0, 80)}) — dreaming again…`,
});
parsed = await doDream();
}

// 3. Persist soul
// 3. Persist. seed.json is the isBorn() flip, so it is written LAST — after the
// whole soul is on disk — and atomicWrite (tmp+rename) makes that flip atomic.
// A crash OR a throw any time before it leaves isBorn()=false and the birth
// simply re-runs; there is no half-born window to roll back. (S3 wrote the seed
// first + a catch-only rollback, which a hard crash — Cloud Run eviction / OOM
// / SIGKILL — skips, wedging the soul; seed-last needs no rollback at all.)
await onStep({ step: "name", detail: `→ "${parsed.name}"` });
await writeName(parsed.name);

Expand Down Expand Up @@ -158,18 +153,47 @@ async function birthInner(opts: BirthOptions): Promise<void> {
await writeEmotions({ ...DEFAULT_EMOTIONS, updatedAt: new Date().toISOString() });
await saveLock(await recomputeLock());

// 5. Flip isBorn() LAST. seed.json is the born marker and atomicWrite is
// tmp+rename, so the transition is atomic — a crash any time before here
// leaves isBorn()=false and the birth simply re-runs (the writes above are
// overwritten). Then init the git repo, whose `add .` + initial commit
// snapshots the now-complete soul in one go (the per-write commitSoulChange
// calls above no-op while there is no .git).
// 5. Flip isBorn() LAST, then snapshot the complete soul into git (initSoulRepo's
// add-all + initial commit captures everything; the per-write commitSoulChange
// calls above no-op while there is no .git yet).
await writeSeed(seed);
await initSoulRepo();

await onStep({ step: "done", detail: `${parsed.name} is alive.` });
}

/** One LLM turn → parsed birth output. Separated so the caller can retry. */
async function dreamSoul(
provider: ReturnType<typeof providerForModel>,
model: string,
seed: SoulSeed,
): Promise<BirthOutput> {
const result = await provider.runTurn({
model,
systemPrompt: BIRTH_SYSTEM,
tools: [],
messages: [
{
role: "user",
content: [
{
type: "text",
text:
`Seed:\n${JSON.stringify(seed, null, 2)}\n\nBirth yourself. Output JSON only.`,
},
],
},
],
maxTokens: 4_000,
});
const raw = result.content
.filter((b) => b.type === "text")
.map((b) => (b as { text: string }).text)
.join("")
.trim();
return parseBirthOutput(raw);
}

function generateSeed(): SoulSeed {
const randBytes = crypto.randomBytes(32).toString("hex");
const hostname = os.hostname();
Expand Down Expand Up @@ -207,7 +231,7 @@ function bigFiveFromHex(hex: string): BigFiveSeed {
void slice;
}

interface BirthOutput {
export interface BirthOutput {
name: string;
identity: string;
purpose: string;
Expand Down
25 changes: 23 additions & 2 deletions src/soul/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { spawn } from "node:child_process";
import { AsyncLocalStorage } from "node:async_hooks";
import path from "node:path";
import { pathExists } from "../fs-utils.js";
import { isCloud } from "../edition.js";
import { withFileLock } from "./lock.js";
import { soulDir } from "./paths.js";

Expand All @@ -27,7 +28,12 @@ import { soulDir } from "./paths.js";
* reusing the same lock here would self-deadlock. Ordering is always
* write-lock → git-lock, never the reverse, so the two can't deadlock.
*/
const SOUL_GIT_LOCK_PATH = path.join(soulDir(), ".git-write.lock");
// A FUNCTION, not an import-time constant (paths.ts doctrine): a constant
// would freeze the GLOBAL home's lock path at module load, so per-uid cloud
// tenants would all serialize on one shared lock outside their own repos (S3).
function soulGitLockPath(): string {
return path.join(soulDir(), ".git-write.lock");
}

export type SoulCaller =
| "birth"
Expand Down Expand Up @@ -69,7 +75,22 @@ interface GitResult {

let gitAvailable: boolean | null = null;

/**
* Soul git history is a local-edition feature (S3). On the cloud edition the
* home lives on a gcsfuse mount where git's many-small-file writes are slow
* and lock files behave badly — so it defaults OFF there. LISA_SOUL_GIT=1/0
* force-overrides either way; all git ops already degrade gracefully to
* no-ops when this reports false.
*/
function soulGitEnabled(): boolean {
const v = process.env.LISA_SOUL_GIT?.trim().toLowerCase();
if (v === "0" || v === "false") return false;
if (v === "1" || v === "true") return true;
return !isCloud();
}

async function checkGitAvailable(): Promise<boolean> {
if (!soulGitEnabled()) return false;
if (gitAvailable !== null) return gitAvailable;
try {
const r = await runGitRaw(["--version"], process.cwd());
Expand Down Expand Up @@ -184,7 +205,7 @@ export function commitSoulChange(
if (!(await pathExists(dotGit))) return; // not initialized yet (pre-birth)
try {
await withFileLock(
SOUL_GIT_LOCK_PATH,
soulGitLockPath(),
async () => {
const add = await runGit(["add", "--", relPath]);
if (add.code !== 0) {
Expand Down
12 changes: 9 additions & 3 deletions src/soul/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,20 @@ export async function withFileLock<T>(
}
}

/** The canonical soul write-lock path. */
export const SOUL_LOCK_PATH = path.join(soulDir(), ".write.lock");
/**
* The canonical soul write-lock path. A FUNCTION, not an import-time constant
* (paths.ts doctrine): a constant froze the GLOBAL home at module load, which
* made every per-uid cloud tenant contend on one shared lock (S3).
*/
export function soulLockPath(): string {
return path.join(soulDir(), ".write.lock");
}

/**
* Run `fn` while holding the soul write-lock. Use this around any
* read-modify-write of soul state (e.g. desire-progress appends) so a
* concurrent heartbeat/idle/chat process can't interleave and lose data.
*/
export function withSoulLock<T>(fn: () => Promise<T>, opts?: FileLockOpts): Promise<T> {
return withFileLock(SOUL_LOCK_PATH, fn, opts);
return withFileLock(soulLockPath(), fn, opts);
}
78 changes: 78 additions & 0 deletions src/web/birth-hub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { test, describe, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { startBirthOnce, birthRunFor, resetBirthRuns } from "./birth-hub.js";
import type { BirthLog } from "../soul/birth.js";

beforeEach(() => {
resetBirthRuns();
});

describe("birth hub (S3)", () => {
test("two starters share ONE run — the exec fires once", async () => {
let execs = 0;
const exec = async (emit: (l: BirthLog) => void) => {
execs++;
emit({ step: "seed", detail: "rolling" });
emit({ step: "done", detail: "alive" });
};
const a = startBirthOnce("u1", exec);
const b = startBirthOnce("u1", exec);
assert.equal(a, b);
await a.promise;
assert.equal(execs, 1);
});

test("a late watcher can replay the transcript then stream live", async () => {
let release: () => void = () => {};
const gate = new Promise<void>((r) => (release = r));
const run = startBirthOnce("u2", async (emit) => {
emit({ step: "seed", detail: "rolling" });
await gate;
emit({ step: "done", detail: "alive" });
});
// late watcher: replays what already happened…
await new Promise((r) => setTimeout(r, 10));
const seen: string[] = run.steps.map((s) => s.step);
assert.deepEqual(seen, ["seed"]);
// …then subscribes for the rest
run.listeners.add((l) => seen.push(l.step));
release();
await run.promise;
assert.deepEqual(seen, ["seed", "done"]);
});

test("different keys run independently", async () => {
let execs = 0;
const exec = async () => {
execs++;
};
await startBirthOnce("a", exec).promise;
await startBirthOnce("b", exec).promise;
assert.equal(execs, 2);
});

test("a FAILED run clears itself so the next call retries", async () => {
const bad = startBirthOnce("u3", async () => {
throw new Error("dream failed");
});
await assert.rejects(bad.promise, /dream failed/);
// settled runs are removed (success is guarded by isBorn, not the hub)
await new Promise((r) => setTimeout(r, 0));
assert.equal(birthRunFor("u3"), null);
let ok = false;
await startBirthOnce("u3", async () => {
ok = true;
}).promise;
assert.equal(ok, true);
});

test("a throwing listener never kills the birth", async () => {
const run = startBirthOnce("u4", async (emit) => {
emit({ step: "seed", detail: "x" });
});
run.listeners.add(() => {
throw new Error("broken watcher");
});
await run.promise; // resolves fine
});
});
Loading