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
77 changes: 73 additions & 4 deletions LifeOS/install/hooks/lib/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { parse as parseYaml } from 'yaml';
import { loadLifeosConfig } from '../../LIFEOS/TOOLS/LifeosConfig';
import {
loadLifeosConfig,
type LifeosDa,
type LifeosPrincipal,
} from '../../LIFEOS/TOOLS/LifeosConfig';

const HOME = process.env.HOME!;
const SETTINGS_PATH = join(HOME, '.claude/settings.json');
Expand Down Expand Up @@ -171,10 +175,53 @@ function mapFrontmatterPersonality(traits: any, baseVoice: string | undefined):

/**
* Get DA (Digital Assistant) identity.
* Reads settings.daidentity first (canonical runtime read point),
* falls back to DA_IDENTITY.md frontmatter (authoring source).
*
* LIFEOS_CONFIG.toml `[da]` wins for every field it carries. It is the typed
* loader, it is where setup tells the principal to name their DA, and
* LifeosConfig itself refuses to load without a non-empty `[da].name` — so it
* is the one source guaranteed to be both present and current.
*
* The settings.daidentity → DA_IDENTITY.md frontmatter → DEFAULT_IDENTITY chain
* below stays the fallback: it covers the fields config does not carry, and an
* install that has no config yet resolves exactly as it did before.
*/
export function getIdentity(): Identity {
const base = legacyIdentity();

let da: LifeosDa | undefined;
try {
da = loadLifeosConfig().da;
} catch {
return base; // no config yet (fresh install) — legacy chain governs
}
if (!da?.name) return base;

const main = da.voices?.main;
return {
...base,
name: da.name,
fullName: da.fullName || da.name,
displayName: da.displayName || da.name,
color: da.color || base.color,
mainDAVoiceID: main?.voiceId || base.mainDAVoiceID,
voice: main
? {
stability: main.stability ?? 0,
similarityBoost: main.similarityBoost ?? 0,
style: main.style ?? 0,
speed: main.speed ?? 1,
useSpeakerBoost: main.useSpeakerBoost ?? false,
volume: main.volume,
}
: base.voice,
};
}

/**
* The pre-config resolution chain: settings.daidentity (runtime read point),
* then DA_IDENTITY.md frontmatter (authoring source), then the placeholder.
*/
function legacyIdentity(): Identity {
const settings = loadSettings();
const daidentity = (settings.daidentity || {}) as any;
const voices = daidentity.voices || {};
Expand Down Expand Up @@ -212,9 +259,31 @@ export function getIdentity(): Identity {

/**
* Get Principal (human owner) identity.
* Reads frontmatter from PRINCIPAL_IDENTITY.md first, falls back to settings.principal.
*
* Same contract as getIdentity(): LIFEOS_CONFIG.toml `[principal]` wins, and
* LifeosConfig requires a non-empty name and timezone there. The
* PRINCIPAL_IDENTITY.md frontmatter → settings.principal chain is the fallback.
*/
export function getPrincipal(): Principal {
const base = legacyPrincipal();

let p: LifeosPrincipal | undefined;
try {
p = loadLifeosConfig().principal;
} catch {
return base; // no config yet (fresh install) — legacy chain governs
}
if (!p?.name) return base;

return {
name: p.name,
pronunciation: p.pronunciation || base.pronunciation,
timezone: p.timezone || base.timezone,
};
}

/** The pre-config chain: PRINCIPAL_IDENTITY.md frontmatter, then settings. */
function legacyPrincipal(): Principal {
const fm = loadPrincipalFrontmatter();
const core = fm.core ?? {};

Expand Down
106 changes: 106 additions & 0 deletions LifeOS/install/test/hooks/Identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Falsifiers for identity resolution (hooks/lib/identity.ts).
*
* LifeosConfig refuses to load without a non-empty `[da].name` and
* `[principal].name`/`timezone`, and setup points the principal at those fields
* to name their DA. Before this change getIdentity()/getPrincipal() read none of
* them, so a rename in LIFEOS_CONFIG.toml changed nothing and `[da].color` had
* no effect at all.
*
* Resolution runs in subprocesses because identity.ts derives its identity-file
* paths at module load, so an in-process env change would not reproduce a real
* session.
*/
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const INSTALL_DIR = join(import.meta.dir, '..', '..');
const IDENTITY = join(INSTALL_DIR, 'hooks/lib/identity.ts');

const PROBE = `
import { getIdentity, getDAName, getPrincipal } from ${JSON.stringify(IDENTITY)};
const i = getIdentity();
const p = getPrincipal();
console.log(JSON.stringify({
name: i.name, fullName: i.fullName, displayName: i.displayName,
color: i.color, voiceId: i.mainDAVoiceID, prosody: i.voice,
daName: getDAName(),
principalName: p.name, principalTimezone: p.timezone,
}));
`;

function resolveWith(configPath: string | undefined) {
const env = { ...process.env };
if (configPath === undefined) delete env.LIFEOS_CONFIG_PATH;
else env.LIFEOS_CONFIG_PATH = configPath;

const r = Bun.spawnSync(['bun', '-e', PROBE], { env, cwd: INSTALL_DIR });
if (r.exitCode !== 0) {
throw new Error(`probe exited ${r.exitCode}: ${r.stderr.toString()}`);
}
return JSON.parse(r.stdout.toString().trim().split('\n').at(-1)!);
}

function fixtureConfig(daName: string): string {
const path = join(
mkdtempSync(join(tmpdir(), 'lifeos-identity-')),
'LIFEOS_CONFIG.toml',
);
writeFileSync(
path,
[
'[principal]',
'name = "Fixture Principal"',
'timezone = "UTC"',
'',
'[da]',
`name = "${daName}"`,
'color = "#123456"',
'',
'[da.voices.main]',
'voice_id = "fixture-voice-id"',
'stability = 0.5',
'',
].join('\n'),
);
return path;
}

describe('DA identity resolution', () => {
test('[da].name governs the identity, not the placeholder', () => {
const id = resolveWith(fixtureConfig('Testbot'));
expect(id.name).toBe('Testbot');
expect(id.daName).toBe('Testbot');
// full_name/display_name are optional in config and derive from the
// configured name — never from DEFAULT_IDENTITY.
expect(id.fullName).toBe('Testbot');
expect(id.displayName).toBe('Testbot');
});

test('[da].color and [da.voices.main] reach the identity', () => {
const id = resolveWith(fixtureConfig('Testbot'));
expect(id.color).toBe('#123456');
expect(id.voiceId).toBe('fixture-voice-id');
expect(id.prosody.stability).toBe(0.5);
});

test('a rename in config is what a rename means', () => {
expect(resolveWith(fixtureConfig('First')).name).toBe('First');
expect(resolveWith(fixtureConfig('Second')).name).toBe('Second');
});

test('[principal] governs the principal, not the placeholder', () => {
const id = resolveWith(fixtureConfig('Testbot'));
expect(id.principalName).toBe('Fixture Principal');
expect(id.principalTimezone).toBe('UTC');
});

test('an install with no config resolves exactly as before', () => {
const id = resolveWith(join(tmpdir(), 'lifeos-identity-absent', 'nope.toml'));
expect(typeof id.name).toBe('string');
expect(id.name.length).toBeGreaterThan(0);
expect(id.daName).toBe(id.name);
});
});