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 config/phantom.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: phantom
port: 3100
role: swe
model: claude-sonnet-4-6
model: claude-opus-4-6
effort: max
max_budget_usd: 0
timeout_minutes: 240
Expand Down
1 change: 1 addition & 0 deletions src/agent/__tests__/prompt-assembler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const baseConfig: PhantomConfig = {
port: 3100,
role: "swe",
model: "claude-opus-4-6",
model_source: "config",
effort: "max",
max_budget_usd: 0,
timeout_minutes: 240,
Expand Down
4 changes: 2 additions & 2 deletions src/cli/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe("phantom init", () => {
expect(config.name).toBe("phantom");
expect(config.role).toBe("swe");
expect(config.port).toBe(3100);
expect(config.model).toBe("claude-sonnet-4-6");
expect(config.model).toBe("claude-opus-4-6");
});

test("accepts custom name and role", async () => {
Expand Down Expand Up @@ -313,7 +313,7 @@ describe("phantom init --yes (environment-aware)", () => {

const raw = readFileSync("config/phantom.yaml", "utf-8");
const config = YAML.parse(raw);
expect(config.model).toBe("claude-sonnet-4-6");
expect(config.model).toBe("claude-opus-4-6");
});

test("reads PHANTOM_DOMAIN from environment", async () => {
Expand Down
49 changes: 48 additions & 1 deletion src/cli/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from "node:fs";
import { constants, accessSync, existsSync, readFileSync } from "node:fs";
import { parseArgs } from "node:util";

type CheckResult = {
Expand Down Expand Up @@ -148,6 +148,52 @@ async function checkEvolvedConfig(): Promise<CheckResult> {
return { name: "Evolved Config", status: "ok", message: "All config files present" };
}

async function checkEvolutionPipeline(): Promise<CheckResult> {
const sessionLog = "phantom-config/memory/session-log.jsonl";
const metricsFile = "phantom-config/meta/metrics.json";

if (!existsSync("phantom-config")) {
return { name: "Evolution Pipeline", status: "warn", message: "phantom-config/ not found", fix: "phantom init" };
}

// Check session-log.jsonl is writable
if (!existsSync(sessionLog)) {
return {
name: "Evolution Pipeline",
status: "warn",
message: "session-log.jsonl not found",
fix: "Run a session to generate it, or run phantom init",
};
}

try {
accessSync(sessionLog, constants.R_OK | constants.W_OK);
} catch {
return { name: "Evolution Pipeline", status: "fail", message: "session-log.jsonl not writable" };
}

// Check metrics.json shows pipeline activity
if (!existsSync(metricsFile)) {
return { name: "Evolution Pipeline", status: "warn", message: "metrics.json not found", fix: "phantom init" };
}

try {
const raw = readFileSync(metricsFile, "utf-8");
const metrics = JSON.parse(raw) as { session_count?: number };
const count = metrics.session_count ?? 0;
if (count === 0) {
return {
name: "Evolution Pipeline",
status: "warn",
message: "No sessions recorded yet (pipeline not yet active)",
};
}
return { name: "Evolution Pipeline", status: "ok", message: `${count} sessions processed` };
} catch {
return { name: "Evolution Pipeline", status: "fail", message: "metrics.json not parseable" };
}
}

async function checkPhantomHealth(port: number): Promise<CheckResult> {
try {
const resp = await fetch(`http://localhost:${port}/health`, { signal: AbortSignal.timeout(3000) });
Expand Down Expand Up @@ -194,6 +240,7 @@ export async function runDoctor(args: string[]): Promise<void> {
checkMcpConfig(),
checkDatabase(),
checkEvolvedConfig(),
checkEvolutionPipeline(),
checkPhantomHealth(port),
]);

Expand Down
4 changes: 2 additions & 2 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export async function runInit(args: string[]): Promise<void> {
name: values.name ?? envName ?? "phantom",
role: values.role ?? envRole ?? "swe",
port: values.port ? Number.parseInt(values.port, 10) : envPort ? Number.parseInt(envPort, 10) : 3100,
model: envModel ?? "claude-sonnet-4-6",
model: envModel ?? "claude-opus-4-6",
domain: envDomain,
public_url: envPublicUrl,
effort: envEffort,
Expand All @@ -224,7 +224,7 @@ export async function runInit(args: string[]): Promise<void> {
port: values.port
? Number.parseInt(values.port, 10)
: Number.parseInt(await prompt(rl, "HTTP port", "3100"), 10),
model: await prompt(rl, "Model (claude-sonnet-4-6, claude-opus-4-6)", "claude-sonnet-4-6"),
model: await prompt(rl, "Model (claude-opus-4-6, claude-sonnet-4-6)", "claude-opus-4-6"),
};

console.log("\nSlack setup (optional, press Enter to skip):");
Expand Down
20 changes: 18 additions & 2 deletions src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ type HealthResponse = {
version: string;
agent: string;
role: { id: string; name: string };
model?: string;
model_source?: "config" | "env";
channels: Record<string, boolean>;
memory: { qdrant: boolean; ollama: boolean };
evolution: { generation: number };
evolution: {
generation: number;
session_count?: number;
sessions_since_consolidation?: number;
session_log_depth?: number;
};
onboarding?: string;
peers?: Record<string, { healthy: boolean; latencyMs: number; error?: string }>;
};
Expand Down Expand Up @@ -74,9 +81,18 @@ export async function runStatus(args: string[]): Promise<void> {
const memoryStr =
data.memory.qdrant && data.memory.ollama ? "ok" : data.memory.qdrant || data.memory.ollama ? "degraded" : "offline";

const modelStr = data.model ? `${data.model}${data.model_source === "env" ? " (env override)" : ""}` : "unknown";

const evo = data.evolution;
const evoDetail =
evo.session_count != null
? `gen ${evo.generation} (${evo.session_count} sessions, ${evo.sessions_since_consolidation ?? 0} since consolidation, ${evo.session_log_depth ?? 0} queued)`
: `gen ${evo.generation}`;

console.log(
`${data.agent} | ${data.role.name} | v${data.version} | ` +
`gen ${data.evolution.generation} | ` +
`${evoDetail} | ` +
`model: ${modelStr} | ` +
`up ${formatUptime(data.uptime)} | ` +
`channels: ${channelStr} | ` +
`memory: ${memoryStr}`,
Expand Down
22 changes: 22 additions & 0 deletions src/config/__tests__/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ model: claude-opus-4-6
process.env.PHANTOM_MODEL = "claude-sonnet-4-6";
const config = loadConfig(path);
expect(config.model).toBe("claude-sonnet-4-6");
expect(config.model_source).toBe("env");
} finally {
if (saved !== undefined) {
process.env.PHANTOM_MODEL = saved;
Expand All @@ -116,6 +117,27 @@ model: claude-opus-4-6
}
});

test("model_source defaults to config when no env override", () => {
const path = writeYaml(
"model-source-default.yaml",
`
name: test-phantom
model: claude-opus-4-6
`,
);
const saved = process.env.PHANTOM_MODEL;
try {
process.env.PHANTOM_MODEL = undefined;
const config = loadConfig(path);
expect(config.model_source).toBe("config");
} finally {
if (saved !== undefined) {
process.env.PHANTOM_MODEL = saved;
}
cleanup();
}
});

test("env var overrides YAML domain", () => {
const path = writeYaml(
"env-domain.yaml",
Expand Down
7 changes: 6 additions & 1 deletion src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ export function loadConfig(path?: string): PhantomConfig {
// Environment variable overrides for runtime flexibility.
// These let operators change settings via env without editing YAML.
if (process.env.PHANTOM_MODEL) {
config.model = process.env.PHANTOM_MODEL;
const envModel = process.env.PHANTOM_MODEL;
if (envModel !== config.model) {
console.warn(`[config] Model override: ${config.model} (yaml) -> ${envModel} (env)`);
}
config.model = envModel;
config.model_source = "env";
}
if (process.env.PHANTOM_DOMAIN) {
config.domain = process.env.PHANTOM_DOMAIN;
Expand Down
3 changes: 2 additions & 1 deletion src/config/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const PhantomConfigSchema = z.object({
public_url: z.string().url().optional(),
port: z.number().int().min(1).max(65535).default(3100),
role: z.string().min(1).default("swe"),
model: z.string().min(1).default("claude-sonnet-4-6"),
model: z.string().min(1).default("claude-opus-4-6"),
model_source: z.enum(["config", "env"]).default("config"),
effort: z.enum(["low", "medium", "high", "max"]).default("max"),
max_budget_usd: z.number().min(0).default(0),
timeout_minutes: z.number().min(1).default(240),
Expand Down
23 changes: 22 additions & 1 deletion src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ import { handleUiRequest } from "../ui/serve.ts";
const VERSION = "0.18.2";

type MemoryHealthProvider = () => Promise<MemoryHealth>;
type EvolutionInfo = {
generation: number;
session_count: number;
sessions_since_consolidation: number;
session_log_depth: number;
};
type EvolutionVersionProvider = () => number;
type EvolutionInfoProvider = () => EvolutionInfo;
type McpServerProvider = () => PhantomMcpServer | null;
type ChannelHealthProvider = () => Record<string, boolean>;
type RoleInfoProvider = () => { id: string; name: string } | null;
type OnboardingStatusProvider = () => string;
type WebhookHandler = (req: Request) => Promise<Response>;
type ModelInfoProvider = () => { model: string; model_source: "config" | "env" };
type PeerHealthProvider = () => Record<string, { healthy: boolean; latencyMs: number; error?: string }>;
type TriggerDeps = {
runtime: AgentRuntime;
Expand All @@ -25,11 +33,13 @@ type TriggerDeps = {

let memoryHealthProvider: MemoryHealthProvider | null = null;
let evolutionVersionProvider: EvolutionVersionProvider | null = null;
let evolutionInfoProvider: EvolutionInfoProvider | null = null;
let mcpServerProvider: McpServerProvider | null = null;
let channelHealthProvider: ChannelHealthProvider | null = null;
let roleInfoProvider: RoleInfoProvider | null = null;
let onboardingStatusProvider: OnboardingStatusProvider | null = null;
let webhookHandler: WebhookHandler | null = null;
let modelInfoProvider: ModelInfoProvider | null = null;
let peerHealthProvider: PeerHealthProvider | null = null;
let triggerDeps: TriggerDeps | null = null;

Expand All @@ -41,6 +51,10 @@ export function setEvolutionVersionProvider(provider: EvolutionVersionProvider):
evolutionVersionProvider = provider;
}

export function setEvolutionInfoProvider(provider: EvolutionInfoProvider): void {
evolutionInfoProvider = provider;
}

export function setMcpServerProvider(provider: McpServerProvider): void {
mcpServerProvider = provider;
}
Expand All @@ -61,6 +75,10 @@ export function setWebhookHandler(handler: WebhookHandler): void {
webhookHandler = handler;
}

export function setModelInfoProvider(provider: ModelInfoProvider): void {
modelInfoProvider = provider;
}

export function setPeerHealthProvider(provider: PeerHealthProvider): void {
peerHealthProvider = provider;
}
Expand Down Expand Up @@ -92,11 +110,13 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
// Both up -> ok. One up -> degraded. Both down + configured -> down. Not configured -> ok.
const status = allHealthy ? "ok" : someHealthy ? "degraded" : memory.configured ? "down" : "ok";
const evolutionGeneration = evolutionVersionProvider ? evolutionVersionProvider() : 0;
const evolutionInfo = evolutionInfoProvider ? evolutionInfoProvider() : null;

const roleInfo = roleInfoProvider ? roleInfoProvider() : null;

const onboardingStatus = onboardingStatusProvider ? onboardingStatusProvider() : null;
const peers = peerHealthProvider ? peerHealthProvider() : null;
const modelInfo = modelInfoProvider ? modelInfoProvider() : null;

return Response.json({
status,
Expand All @@ -105,9 +125,10 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
agent: config.name,
...(config.public_url ? { public_url: config.public_url } : {}),
role: roleInfo ?? { id: config.role, name: config.role },
...(modelInfo ? { model: modelInfo.model, model_source: modelInfo.model_source } : {}),
channels,
memory,
evolution: {
evolution: evolutionInfo ?? {
generation: evolutionGeneration,
},
...(onboardingStatus ? { onboarding: onboardingStatus } : {}),
Expand Down
74 changes: 72 additions & 2 deletions src/evolution/__tests__/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@ describe("Application", () => {

const content = readFileSync(`${TEST_DIR}/user-profile.md`, "utf-8");
expect(content).toContain("Prefers TypeScript");
expect(change.file).toBe("user-profile.md");
expect(change.content).toBe("- Prefers TypeScript");
expect(change).not.toBeNull();
if (change) {
expect(change.file).toBe("user-profile.md");
expect(change.content).toBe("- Prefers TypeScript");
}
});

test("replaces content in file", () => {
Expand Down Expand Up @@ -99,6 +102,73 @@ describe("Application", () => {
expect(content).not.toContain("Preferences go here.");
});

test("skips append when all content lines already exist", () => {
writeFileSync(`${TEST_DIR}/user-profile.md`, "# User Profile\n\n- Prefers TypeScript\n", "utf-8");
const delta: ConfigDelta = {
file: "user-profile.md",
type: "append",
content: "- Prefers TypeScript",
rationale: "User said so",
session_ids: ["s1"],
tier: "free",
};
applyDelta(delta, testConfig());

const content = readFileSync(`${TEST_DIR}/user-profile.md`, "utf-8");
// Should not have duplicated the line
const matches = content.match(/Prefers TypeScript/g);
expect(matches).toHaveLength(1);
});

test("appends only new lines from partial overlap", () => {
writeFileSync(`${TEST_DIR}/user-profile.md`, "# User Profile\n\n- Prefers TypeScript\n", "utf-8");
const delta: ConfigDelta = {
file: "user-profile.md",
type: "append",
content: "- Prefers TypeScript\n- Uses Bun runtime",
rationale: "Mixed new and existing",
session_ids: ["s1"],
tier: "free",
};
applyDelta(delta, testConfig());

const content = readFileSync(`${TEST_DIR}/user-profile.md`, "utf-8");
const tsMatches = content.match(/Prefers TypeScript/g);
expect(tsMatches).toHaveLength(1);
expect(content).toContain("Uses Bun runtime");
});

test("returns null for no-op append", () => {
writeFileSync(`${TEST_DIR}/user-profile.md`, "# User Profile\n\n- Prefers TypeScript\n", "utf-8");
const delta: ConfigDelta = {
file: "user-profile.md",
type: "append",
content: "- Prefers TypeScript",
rationale: "Duplicate",
session_ids: ["s1"],
tier: "free",
};
const result = applyDelta(delta, testConfig());
expect(result).toBeNull();
});

test("appends when content is genuinely new", () => {
writeFileSync(`${TEST_DIR}/user-profile.md`, "# User Profile\n\n- Prefers TypeScript\n", "utf-8");
const delta: ConfigDelta = {
file: "user-profile.md",
type: "append",
content: "- Uses Bun runtime",
rationale: "New preference",
session_ids: ["s1"],
tier: "free",
};
applyDelta(delta, testConfig());

const content = readFileSync(`${TEST_DIR}/user-profile.md`, "utf-8");
expect(content).toContain("Prefers TypeScript");
expect(content).toContain("Uses Bun runtime");
});

test("creates new file if it does not exist", () => {
const delta: ConfigDelta = {
file: "new-file.md",
Expand Down
Loading