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
35 changes: 35 additions & 0 deletions plugin/dashboard/app/api/model-provenance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import {
getLearningModelProvenance,
type LearningEntityType,
} from "@/lib/model-lineage";

const VALID_TYPES = new Set<LearningEntityType>([
"profile",
"user_playbook",
"agent_playbook",
]);

// Local dashboard data route (same pattern as /api/sessions): server reads
// filesystem-local state and returns JSON to client components.
export async function GET(req: Request) {
const url = new URL(req.url);
const entityType = (url.searchParams.get("entityType") || "").trim();
const entityId = (url.searchParams.get("entityId") || "").trim();

if (!VALID_TYPES.has(entityType as LearningEntityType)) {
return NextResponse.json(
{
error:
"entityType must be one of profile, user_playbook, agent_playbook",
},
{ status: 400 },
);
}
if (!entityId) {
return NextResponse.json({ error: "entityId is required" }, { status: 400 });
}

const provenance = getLearningModelProvenance(entityType, entityId);
return NextResponse.json({ provenance });
}
12 changes: 12 additions & 0 deletions plugin/dashboard/app/preferences/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "lucide-react";
import { PageHeader } from "@/components/common/page-header";
import { LearningHostProvenance } from "@/components/common/host-badge";
import { LearningModelProvenanceView } from "@/components/common/model-provenance";
import { EmptyState } from "@/components/common/empty-state";
import { DeleteLearningDangerZone } from "@/components/common/delete-learning-danger-zone";
import { Button } from "@/components/ui/button";
Expand All @@ -31,6 +32,7 @@ import { Separator } from "@/components/ui/separator";
import { reflexio } from "@/lib/reflexio-client";
import { formatTimestamp, truncateId } from "@/lib/format";
import { useRequestHostAttribution } from "@/lib/host-attribution";
import { useLearningModelProvenance } from "@/lib/model-provenance";
import { cn } from "@/lib/utils";
import { statusLabel as status } from "@/lib/status";
import type { UserProfile } from "@/lib/types";
Expand All @@ -52,6 +54,10 @@ export default function PreferenceDetailPage({
const [editing, setEditing] = useState(false);
const [content, setContent] = useState("");
const attribution = useRequestHostAttribution();
const modelProvenance = useLearningModelProvenance(
"profile",
profile?.profile_id ?? id,
);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -352,6 +358,12 @@ export default function PreferenceDetailPage({
}
/>
)}
<Meta
label="Model"
value={
<LearningModelProvenanceView provenance={modelProvenance} />
}
/>
{profile.source && (
<Meta label="Integration" value={profile.source} mono />
)}
Expand Down
12 changes: 12 additions & 0 deletions plugin/dashboard/app/skills/project/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "lucide-react";
import { PageHeader } from "@/components/common/page-header";
import { LearningHostProvenance } from "@/components/common/host-badge";
import { LearningModelProvenanceView } from "@/components/common/model-provenance";
import { EmptyState } from "@/components/common/empty-state";
import { DeleteLearningDangerZone } from "@/components/common/delete-learning-danger-zone";
import { Button } from "@/components/ui/button";
Expand All @@ -27,6 +28,7 @@ import { Separator } from "@/components/ui/separator";
import { reflexio } from "@/lib/reflexio-client";
import { formatTimestamp, truncateId } from "@/lib/format";
import { useRequestHostAttribution } from "@/lib/host-attribution";
import { useLearningModelProvenance } from "@/lib/model-provenance";
import { cn } from "@/lib/utils";
import { statusLabel } from "@/lib/status";
import type { StatusLabel } from "@/lib/status";
Expand Down Expand Up @@ -70,6 +72,10 @@ export default function ProjectSkillDetailPage({
rationale: "",
});
const attribution = useRequestHostAttribution();
const modelProvenance = useLearningModelProvenance(
"user_playbook",
playbook?.user_playbook_id ?? id,
);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -360,6 +366,12 @@ export default function ProjectSkillDetailPage({
}
/>
)}
<Meta
label="Model"
value={
<LearningModelProvenanceView provenance={modelProvenance} />
}
/>
{playbook.source && (
<Meta label="Integration" value={playbook.source} mono />
)}
Expand Down
14 changes: 13 additions & 1 deletion plugin/dashboard/app/skills/shared/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
import { reflexio } from "@/lib/reflexio-client";
import { formatTimestamp, truncateId } from "@/lib/format";
import { cn } from "@/lib/utils";
import { LearningModelProvenanceView } from "@/components/common/model-provenance";
import { useLearningModelProvenance } from "@/lib/model-provenance";
import { agentPlaybookStatusLabel, statusLabel } from "@/lib/status";
import type { StatusLabel } from "@/lib/status";
import type { AgentPlaybook, AgentPlaybookStatus } from "@/lib/types";
Expand Down Expand Up @@ -86,6 +88,10 @@ export default function SharedSkillDetailPage({
const router = useRouter();

const [playbook, setPlaybook] = useState<AgentPlaybook | null>(null);
const modelProvenance = useLearningModelProvenance(
"agent_playbook",
playbook?.agent_playbook_id ?? id,
);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
Expand Down Expand Up @@ -483,6 +489,12 @@ export default function SharedSkillDetailPage({
display={truncateId(playbook.playbook_metadata, 32, 8)}
/>
)}
<Meta
label="Model"
value={
<LearningModelProvenanceView provenance={modelProvenance} />
}
/>
</dl>
</div>
</aside>
Expand Down Expand Up @@ -729,7 +741,7 @@ function Meta({
}: {
icon?: React.ComponentType<{ className?: string }>;
label: string;
value: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
Expand Down
56 changes: 56 additions & 0 deletions plugin/dashboard/components/common/model-provenance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Cpu } from "lucide-react";
import { cn } from "@/lib/utils";
import type { LearningModelProvenance } from "@/lib/model-lineage";

function label(provider: string | null, modelName: string | null): string | null {
if (provider && modelName) return `${provider}/${modelName}`;
return modelName || provider;
}

export function LearningModelProvenanceView({
provenance,
className,
}: {
provenance: LearningModelProvenance | null;
className?: string;
}) {
if (!provenance) {
return <span className={cn("text-muted-foreground", className)}>Loading…</span>;
}
if (provenance.unavailable) {
return (
<span
className={cn("text-muted-foreground", className)}
title={provenance.reason || "Model provenance unavailable"}
>
Unavailable
</span>
);
}

const observed = label(provenance.provider, provenance.modelName);
return (
<span
className={cn(
"inline-flex max-w-full items-center justify-end gap-1.5 text-right",
className,
)}
title={observed ?? provenance.reason ?? "Observed model not recorded"}
>
<Cpu
className={cn(
"h-3 w-3 shrink-0",
observed ? "text-foreground/70" : "text-muted-foreground",
)}
/>
<span
className={cn(
"min-w-0 break-words font-mono text-[11px]",
observed ? "text-foreground" : "text-muted-foreground italic",
)}
>
{observed ?? "Not recorded"}
</span>
</span>
);
}
172 changes: 172 additions & 0 deletions plugin/dashboard/lib/model-lineage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Read observed model/provider from local Reflexio SQLite lineage.
* Direct DB access only — not the Reflexio HTTP API.
*/

import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";

export type LearningEntityType =
| "profile"
| "user_playbook"
| "agent_playbook";

export interface LearningModelProvenance {
entityType: LearningEntityType;
entityId: string;
modelName: string | null;
provider: string | null;
unavailable: boolean;
reason?: string;
}

type SqliteDatabase = {
prepare: (sql: string) => {
all: (...params: unknown[]) => unknown[];
get: (...params: unknown[]) => unknown;
};
close: () => void;
};

function empty(
entityType: LearningEntityType,
entityId: string,
unavailable: boolean,
reason?: string,
): LearningModelProvenance {
return {
entityType,
entityId,
modelName: null,
provider: null,
unavailable,
reason,
};
}

function dbPath(): string {
const override = process.env.CLAUDE_SMART_REFLEXIO_DB?.trim();
if (override) return override;
return path.join(os.homedir(), ".reflexio", "data", "reflexio.db");
}

function openDatabase(sqlitePath: string): SqliteDatabase {
// Lazy-load so module evaluation stays safe on Node versions that cannot
// import node:sqlite at top level during install/build matrices.
// Use a real filesystem path for createRequire: Next may rewrite
// import.meta.url into a URL object that breaks CommonJS resolution.
const req = createRequire(path.join(process.cwd(), "package.json"));
const sqlite = req("node:sqlite") as {
DatabaseSync: new (
filename: string,
options?: { readOnly?: boolean },
) => SqliteDatabase;
};
try {
return new sqlite.DatabaseSync(sqlitePath, { readOnly: true });
} catch {
// Older Node builds may lack the readOnly option; fall back to default open.
return new sqlite.DatabaseSync(sqlitePath);
}
}

function columns(db: SqliteDatabase, table: string): Set<string> {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{
name?: string;
}>;
return new Set(
rows.map((row) => row.name).filter((name): name is string => !!name),
);
}

function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}

export function getLearningModelProvenance(
entityTypeRaw: string,
entityIdRaw: string,
sqlitePath: string = dbPath(),
): LearningModelProvenance {
const entityType = entityTypeRaw as LearningEntityType;
const entityId = String(entityIdRaw ?? "").trim();
if (
entityType !== "profile" &&
entityType !== "user_playbook" &&
entityType !== "agent_playbook"
) {
return empty("profile", entityId, true, "unsupported entityType");
}
if (!entityId) {
return empty(entityType, entityId, true, "missing entityId");
}
if (!fs.existsSync(sqlitePath)) {
console.error(`[model-lineage] database unavailable at ${sqlitePath}`);
return empty(entityType, entityId, true, "database unavailable");
}

let db: SqliteDatabase | null = null;
try {
db = openDatabase(sqlitePath);
const cols = columns(db, "lineage_event");
if (cols.size === 0) {
return empty(entityType, entityId, true, "lineage_event table not present");
}

const modelExpr = cols.has("model_name") ? "model_name" : "NULL";
const providerExpr = cols.has("provider") ? "provider" : "NULL";
const row = db
.prepare(
`SELECT ${modelExpr} AS model_name, ${providerExpr} AS provider
FROM lineage_event
WHERE entity_type = ?
AND entity_id = ?
ORDER BY
CASE
WHEN COALESCE(${modelExpr}, '') != '' OR COALESCE(${providerExpr}, '') != ''
THEN 0 ELSE 1
END,
created_at DESC,
event_id DESC
LIMIT 1`,
)
.get(entityType, entityId) as
| { model_name?: unknown; provider?: unknown }
| undefined;

if (!row) {
return empty(
entityType,
entityId,
false,
"no lineage events for this learning",
);
}

const modelName = nonEmpty(row.model_name);
const provider = nonEmpty(row.provider);
return {
entityType,
entityId,
modelName,
provider,
unavailable: false,
reason:
modelName || provider
? undefined
: "lineage present but observed model/provider not recorded",
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[model-lineage] provenance query failed: ${message}`);
return empty(entityType, entityId, true, "provenance query failed");
} finally {
try {
db?.close();
} catch {
// ignore close failures
}
}
}
Loading
Loading