Wave 7 chunk B: remove the embeddings machinery — the ladder collapses

The owner's wave-7 ruling: embeddings die entirely (git history is the
stash); clustering is birth-session derived (chunk A, 950ddfd), labels
are session names, and the prober's graph tools replace injection.

- settings: GraphSettings collapses to { enabled } — embedding,
  embeddingModel, clustering, labeling all die. Migration story: stale
  record keys are UNREAD and dropped by the sanitizer's reconstruction;
  the next graph-section PUT (replace-whole) persists the collapsed
  shape. The settings-panel GraphSection renders one master row.
- deleted: src/server/embeddings.ts (adapters, resolveEmbeddingPortFor,
  embedNewNodes, embeddingPort, embeddingModelCatalog), the
  GET /api/models/embeddings route, scripts/embed-graph.ts, tests for
  all three, and the /api/models/embeddings picker surfaces in the UI.
- prices: EMBEDDING_PRICES overlay + callCost fallback die. cost-summary:
  the embedding stage fold dies — back to three stages (extraction/
  audit/naming) per the owner ruling; historical stages.embedding spend
  in old run records is simply not displayed.
- ports: EmbeddingPort/EmbeddingResult/EmbeddingTask die. The embeddings
  LEDGER surface (readEmbeddings/writeEmbeddings + EmbeddingsLedger +
  EMBEDDINGS_FILE + graph-deletion's vector pruning) STAYS — inert data,
  kept only so graph-context.ts compiles until Teacher's pass removes
  it; the seam dies in the follow-up interlude.
- chat-handler (approved cross-lane unwiring, wave-6 precedent): the
  extraction pipeline's embedder becomes a documented no-op (nothing
  embedded, nothing spent, ledger untouched) until Teacher's pass
  removes the phase; the graph-context injection dispatch gate, the
  graphContext deps wiring, the setContextRespawn seam, and
  turn-service's triggerGraphContext are gone — injection is dead
  code pending Teacher's file deletions.
- scripts: backfill-graph/import-notes pass the same no-op embedder;
  replay-layout derives clusters from birthSessionPartition (the same
  partition the real settle uses) instead of the embeddings ledger.
- settings-body: parseGraphSettingsBody reads the master switch only —
  old-shape keys in a body are ignored, not rejected; bad shapes 400.
- next.config: the @huggingface tracing-exclusion guards die with the
  last reference.

Cross-lane flags: extraction.ts/graph-context.ts keep stale embed
comments + the embed phase until Teacher's full pass (their parallel
interlude owns those files — zero overlap kept by design);
semantic-clusters.ts + its test stay one more round (extraction.test.ts,
Teacher's file, still imports it — they delete it with the namer cases);
ChatSettings.graphContextTokens + the graph-context-control UI stay
until Teacher+UI.

Gates: tsc clean, eslint clean, 885/885 tests, build compiles.
Live-verified on the port-3004 dev tree: PUT /api/settings/graph with
dead keys collapses on write, bad shapes 400, GET reads {enabled: true},
/api/models/embeddings 404s.
This commit is contained in:
amosblomqvist
2026-09-23 15:02:55 +03:00
parent 0fcffdffaf
commit c86430e8bf
26 changed files with 120 additions and 1823 deletions
+14 -315
View File
@@ -16,39 +16,10 @@
* persist but resolve inert — the switch shows stored, the dim effective).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MenuShell } from "@/components/menu-shell";
import { useSettingsSaved } from "@/components/settings-signal";
import { EMBEDDING_PRICES, type AppearanceSettings, type GraphSettings } from "@/src/core";
import type { AppearanceSettings, GraphSettings } from "@/src/core";
import { useAppearance } from "./appearance.tsx";
import { useSettingsDialog } from "./settings-dialog.tsx";
import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
/** One row of GET /api/models/embeddings (the live-federated BYOK
* catalog). The server twin is src/server/embeddings.ts's
* EmbeddingCatalogModel — components may not import the server layer,
* and the shape is exactly these two strings. */
interface EmbeddingCatalogModel {
id: string;
name: string;
}
/** Pure: the price line for a catalog row — USD per 1M INPUT tokens
* (embeddings emit no output). Unlisted models stay honestly "—"
* (never zero). The label convention is the model menu's. */
export function embeddingPriceLabel(id: string): string {
const price = EMBEDDING_PRICES[id];
if (!price) return "—";
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
return `${fmt(price.input)}/1M in`;
}
/** Pure: which ladder rungs need a configured embedding model to do
* anything — everything but the master switch. */
export function needsEmbeddingModel(key: "enabled" | "embedding" | "clustering" | "labeling"): boolean {
return key !== "enabled";
}
export function AppearanceSection({
appearance,
onSectionChange,
@@ -167,17 +138,6 @@ export function AppearanceSection({
);
}
const LADDER: readonly {
key: "enabled" | "embedding" | "clustering" | "labeling";
label: string;
needs: "enabled" | "embedding" | "clustering" | "labeling" | null;
}[] = [
{ key: "enabled", label: "Graph", needs: null },
{ key: "embedding", label: "Embedding", needs: "enabled" },
{ key: "clustering", label: "Clustering", needs: "embedding" },
{ key: "labeling", label: "Labeling", needs: "clustering" },
];
export function GraphSection({
graph,
onSectionChange,
@@ -185,280 +145,19 @@ export function GraphSection({
graph: GraphSettings | undefined;
onSectionChange: (next: GraphSettings) => void;
}) {
const { open: openSettingsSection } = useSettingsDialog();
// The BYOK embedding catalog — null until the first fetch lands.
const [catalog, setCatalog] = useState<EmbeddingCatalogModel[] | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement>(null);
// The re-embed gate: a pick that differs from the stored model, held
// until the user answers the confirm strip (or the graph turns out to
// be empty — nothing to re-embed, the write lands directly).
const [pending, setPending] = useState<EmbeddingCatalogModel | null>(null);
// null while the count fetch is in flight; -1 when it failed (the
// confirm line then omits the number rather than guessing one).
const [pendingCount, setPendingCount] = useState<number | null>(null);
// Stored values (the switches show these) vs EFFECTIVE values (a
// stored-on rung under a broken chain is inert — the schema's ladder).
const stored = {
enabled: graph?.enabled ?? true,
embedding: graph?.embedding ?? true,
clustering: graph?.clustering ?? true,
labeling: graph?.labeling ?? true,
};
const effective = {
enabled: stored.enabled,
embedding: stored.enabled && stored.embedding,
clustering: stored.enabled && stored.embedding && stored.clustering,
labeling: stored.enabled && stored.embedding && stored.clustering && stored.labeling,
};
// No embedding model = the whole ladder under the master switch is
// inert (the embedding port refuses without one) — those rows gray
// out with a hint pointing at the picker below (owner, wave 6).
const modelUnset = !graph?.embeddingModel;
const commitPickRef = useRef(onSectionChange);
commitPickRef.current = onSectionChange;
const commitRef = useRef<(id: string | null) => void>(null);
commitRef.current = (id) => commitPickRef.current({ ...graph, embeddingModel: id });
// Catalog: fetch on mount and re-read on any settings save — keying a
// provider changes what this list can offer.
const refreshCatalog = useCallback(async () => {
try {
const resp = await fetch("/api/models/embeddings");
if (!resp.ok) return;
const list = (await resp.json()) as EmbeddingCatalogModel[];
if (Array.isArray(list)) setCatalog(list);
} catch {
// leave the last known list; the empty-add path stays clickable
}
}, []);
useEffect(() => {
void refreshCatalog();
}, [refreshCatalog]);
useSettingsSaved(() => {
void refreshCatalog();
});
// Dismissal: the agent-section contract — outside pointerdown closes
// unless it lands inside the menu or on the anchor; Escape is CLAIMED
// so the dialog behind stays put.
useEffect(() => {
if (!menuOpen) return;
const onPointer = (e: PointerEvent) => {
const t = e.target as HTMLElement;
if (t.closest("[data-lh-menu]") || anchorRef.current?.contains(t)) return;
setMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
setMenuOpen(false);
}
};
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("pointerdown", onPointer);
document.removeEventListener("keydown", onKey, true);
};
}, [menuOpen]);
// A held pick: count the graph's propositions. Zero = nothing to
// re-embed, so the write lands without a confirm (the strip would be
// noise). Otherwise the strip shows the honest number.
useEffect(() => {
if (!pending) return;
let cancelled = false;
(async () => {
let n = -1;
try {
const resp = await fetch("/api/graph");
const snap = (await resp.json()) as { nodes?: unknown };
if (Array.isArray(snap.nodes)) n = snap.nodes.length;
} catch {
// count unknown — the strip reads "all propositions"
}
if (cancelled) return;
if (n === 0) {
setPending(null);
setPendingCount(null);
commitRef.current?.(pending.id);
} else {
setPendingCount(n);
}
})();
return () => {
cancelled = true;
};
}, [pending]);
const pick = (m: EmbeddingCatalogModel | null) => {
setMenuOpen(false);
if (m === null) {
// Unset never re-embeds — the port just stops resolving.
if (graph?.embeddingModel != null) commitRef.current?.(null);
return;
}
if (m.id === graph?.embeddingModel) return;
setPending(m);
setPendingCount(null);
};
// The trigger's label: the catalog row's name when known, else the raw
// slug (a key may have been removed after the model was set).
const currentName = useMemo(() => {
const id = graph?.embeddingModel ?? null;
if (!id) return null;
return catalog?.find((m) => m.id === id)?.name ?? id;
}, [graph?.embeddingModel, catalog]);
// Wave 7 collapse (owner ruling 2026-09-23): the section is the MASTER
// SWITCH ONLY. The embedding/clustering/labeling rungs and the BYOK
// embedding-model picker are gone — clustering is the free birth-session
// partition derived on read, labels ARE session names, and the prober's
// graph tools read the ledgers directly. There is nothing left to
// configure but on/off.
return (
<>
{LADDER.map(({ key, label, needs }) => {
// Touchable only when every ancestor is EFFECTIVELY on, and
// (below the master switch) only when a model is configured.
const parentOn = needs === null ? true : effective[needs];
const inert = !parentOn || (needsEmbeddingModel(key) && modelUnset);
return (
<SettingsRow
key={key}
label={label}
testid={`graph-row-${key}`}
dimmed={inert}
hint={parentOn && needsEmbeddingModel(key) && modelUnset ? "Needs an embedding model" : undefined}
>
<Toggle
checked={stored[key]}
disabled={inert}
label={`graph ${key}`}
onChange={(on) => onSectionChange({ ...graph, [key]: on })}
/>
</SettingsRow>
);
})}
{/* The embedding-model picker — the setting every rung above
depends on. Empty catalog means no provider key is set: the
trigger BECOMES the deep link to the Keys section. */}
<SettingsRow label="Embedding model" testid="graph-row-embedding-model">
{catalog === null ? (
<span className="text-xs text-muted-foreground">Loading…</span>
) : catalog.length === 0 ? (
<button
type="button"
data-testid="embedding-model-empty"
onClick={() => openSettingsSection("keys")}
className="rounded-lg border border-border/60 px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted"
>
Add a provider key first…
</button>
) : (
<button
ref={anchorRef}
type="button"
data-testid="embedding-model-trigger"
onClick={() => setMenuOpen((o) => !o)}
className="max-w-56 truncate rounded-lg border border-border/60 px-2 py-1 text-xs text-card-foreground transition-colors hover:bg-muted"
title={graph?.embeddingModel ?? undefined}
>
{currentName ?? "—"}
</button>
)}
</SettingsRow>
{/* The re-embed confirm — switching models rewrites every vector
in the graph (the staleness machinery, next background pass).
Honesty over comfort: the count is the user's decision input. */}
{pending && (
<div
data-testid="embedding-reembed-confirm"
className="rounded-xl border border-border/60 bg-background p-3 text-xs"
>
<p className="text-card-foreground">Switch to {pending.name}?</p>
<p className="mt-1 text-muted-foreground">
{pendingCount === null
? "Counting the graph’s propositions…"
: pendingCount > 0
? `All ${pendingCount.toLocaleString()} propositions in the graph will be re-embedded with the new model (runs with the next background pass).`
: "All propositions in the graph will be re-embedded with the new model (runs with the next background pass)."}
</p>
{pendingCount !== null && (
<div className="mt-2 flex gap-2">
<button
type="button"
data-testid="embedding-reembed-yes"
onClick={() => {
commitRef.current?.(pending.id);
setPending(null);
setPendingCount(null);
}}
className="rounded-lg border border-border/60 px-2.5 py-1 text-card-foreground transition-colors hover:bg-muted"
>
Switch model
</button>
<button
type="button"
data-testid="embedding-reembed-no"
onClick={() => {
setPending(null);
setPendingCount(null);
}}
className="rounded-lg border border-border/60 px-2.5 py-1 text-muted-foreground transition-colors hover:bg-muted"
>
Cancel
</button>
</div>
)}
</div>
)}
{menuOpen && catalog !== null && catalog.length > 0 && (
<MenuShell
anchorRef={anchorRef}
testid="embedding-model-menu"
preferredSide="down"
align="right"
menuClass="flex w-[20rem] flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
>
<button
type="button"
data-testid="embedding-option-unset"
onClick={() => pick(null)}
className={`flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
modelUnset ? "bg-muted/60" : ""
}`}
>
<span className="text-card-foreground">Unset</span>
<span className="text-muted-foreground">stop embedding</span>
</button>
{catalog.map((m) => (
<button
key={m.id}
type="button"
data-testid={`embedding-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`}
onClick={() => pick(m)}
className={`flex flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
m.id === graph?.embeddingModel ? "bg-muted/60" : ""
}`}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span
className="shrink-0 tabular-nums text-muted-foreground"
title="USD per 1M input tokens"
>
{embeddingPriceLabel(m.id)}
</span>
</span>
{/* The full slug — ids are colon-safe (openrouter:openai/…),
never split, shown as stored. */}
<span className="truncate text-muted-foreground">{m.id}</span>
</button>
))}
</MenuShell>
)}
</>
<SettingsRow label="Graph" testid="graph-row-enabled">
<Toggle
checked={graph?.enabled ?? true}
label="graph enabled"
onChange={(on) => onSectionChange({ enabled: on })}
/>
</SettingsRow>
);
}
+5 -4
View File
@@ -150,10 +150,11 @@ export function SettingsDialogBody({
};
}, []);
const [active, setActive] = useState<SectionId>(initialSection);
// A deep-link fired while the dialog is ALREADY open (wave 6: the
// embedding picker's "Add a provider key first…" lands on Keys from
// inside the dialog) changes initialSection under our feet — follow it,
// or the link silently does nothing.
// A deep-link fired while the dialog is ALREADY open (first exercised by
// the wave-6 embedding picker's "Add a provider key first…" link — the
// picker itself died with wave 7, the mechanism is general) changes
// initialSection under our feet — follow it, or the link silently does
// nothing.
useEffect(() => {
setActive(initialSection);
}, [initialSection]);