diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md
index 2e2cbd55d0..6794dd57c3 100644
--- a/.changelog/NEXT.md
+++ b/.changelog/NEXT.md
@@ -12,6 +12,8 @@
## Added
+- **[creative-ingredients-catalog-upgrade-path] Catalog deployment + upgrade story.** The bible→catalog backfill is now peer-aware: when a universe arrives via federated sync carrying an `ingredientId` that doesn't exist in the receiving install's catalog, the backfill creates the local catalog row WITH that explicit id instead of minting a new one — so the same logical character keeps the same catalog id across every install in the federation, even when the catalog rows weren't in the original sync payload. A new admin endpoint `POST /api/catalog/migration/rerun` (with `{ force: true }` to ignore the applied-marker) lets users recover from a stuck backfill or fix data on a peer. Catalog DDL ships in both `server/scripts/init-db.sql` (fresh installs via docker-entrypoint-initdb.d or `setup-db.js`/`db.sh setup-native`) AND `server/lib/db.js:ensureSchema()` (idempotent runtime upgrades — runs on every server boot), so existing installs pick up the catalog tables on the first restart post-pull without needing to re-run `init-db.sql`.
+- **[creative-ingredients-catalog] New "Catalog" feature under Create.** A central, Postgres-backed store of typed creative ingredients — characters, places, objects, ideas, scenes, concepts. Paste any creative scrap (a one-line story spark, a scene snippet, a rough short-story draft) into `/catalog/ingest` and the server extracts candidate characters / places / objects via LLM, streams progress as a stage checklist, and shows a review screen where you check the ones to commit. The Catalog page (`/catalog`) lists every ingredient with type-chip filters, debounced search, and a one-click "+ New" inline form for the lighter idea/scene/concept types. Each ingredient has a detail page with source-scrap provenance and an "Appears in" panel that back-links to every universe / series / issue / work referencing it. Federation routes (`GET/POST /api/catalog/sync`) are wired and version-gated via the existing `PORTOS_SCHEMA_VERSIONS` contract (`catalog: 1`); the auto-orchestrator hookup ships in a follow-up. A boot-time migration backfilled 970 ingredients across 10 existing universes (94 characters, 140 places, 736 objects) so the catalog opens populated. **Vector embeddings are provider-agnostic** — a new Embeddings settings tab lets you pick Ollama or LM Studio + a 768-dim model (e.g. `nomic-embed-text`); embeddings auto-apply at ingest when configured and a `/api/catalog/embeddings/backfill` admin endpoint fills missing rows. Per-record sanitizer carries an `ingredientId` field through the round-trip so embedded universe canon stays linked to its catalog row.
- **Voice coding agent can target a managed app.** When you dispatch a coding task by voice, you can now name a managed app ("fix the failing test in BookLoom") and the agent runs against that app's workspace instead of PortOS itself. The app name is fuzzy-matched, so "book loom", "BookLoom", and "bookloom" all resolve to the same app; if no app matches what you said, the agent refuses with a short list of valid names rather than silently running against PortOS.
- **[voice-code-agent-status-query] Ask voice how a dispatched coding task is going.** A new voice query — "how's that coding task going?", "status of the agent", "is the agent still working?" — reports each in-flight voice-dispatched coding task with its current phase, target app, elapsed time, and a snippet of the task description. Until now you could only learn the outcome from the completion announcement; this is the mid-task check-in. Also available as "Coding agent status" in the command palette.
diff --git a/PLAN.md b/PLAN.md
index fc052c0006..bba7b2d823 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -4,6 +4,23 @@ For project goals, see [GOALS.md](./GOALS.md). For completed work, see [.changel
## Next Up
+### Creative Ingredients Catalog — follow-up slices
+
+The catalog backend, schema, federation routes, ingest extraction service, and Catalog/Ingest/Detail UI all landed in one PR (see `docs/plans/2026-05-29-creative-ingredients-catalog.md` for the design record). Three Phase-7-onward slices remain:
+
+- [ ] [catalog-universe-builder-picker] **Add "Pick from Catalog" button to `client/src/pages/UniverseBuilder.jsx` character/place/object panels (~3736 LOC file).** When a catalog ingredient is picked, copy its `payload` into a new embedded canon entry with `ingredientId` stamped, AND post `linkCatalogIngredient(id, { refKind: 'universe', refId, role: 'canon-character'|'canon-place'|'canon-object' })`. Picker component lives at `client/src/components/IngredientPicker.jsx` (already built). Deferred from the initial PR because of the file size — needs targeted exploration of the canon-panel structure first (CanonTab / BibleTab around `UniverseBuilder.jsx:1835` + `:2945`).
+- [ ] [catalog-pipeline-series-cast] **Add a "Cast (from Catalog)" section to `client/src/pages/PipelineSeries.jsx` that surfaces `listCatalogIngredientsForRef('series', seriesId)` and lets the user attach/detach via `IngredientPicker`.** Series records carry no embedded `characters[]` array, so this is purely additive — backed entirely by `catalog_ingredient_refs`. Same pattern applies to `client/src/pages/PipelineIssue.jsx` (refKind `'issue'`) and `client/src/pages/WritersRoom.jsx` work bibles (refKind `'work'`).
+- [ ] [catalog-federation-orchestrator] **Wire the catalog into `server/services/syncOrchestrator.js` (or `server/services/sharing/peerSync.js`, whichever owns memory sync orchestration) so the existing `/api/catalog/sync` + `/api/catalog/sync/apply` routes auto-replicate between peers.** Today the routes exist and are version-gated via `PORTOS_SCHEMA_VERSIONS.catalog = 1`, but no scheduler pulls from peers. Mirror how memory sync is registered (it's the only existing Postgres-backed sync category). Outbound `portosMeta.schemaVersions.catalog` is already stamped server-side. **Universe/series sync today does NOT block on catalog sync** — universes carry their full embedded canon payloads, so a peer-pushed universe still arrives complete; the receiving peer's boot-time backfill walks the freshly-arrived universe and (now, post-`[catalog-universe-builder-picker]`'s peer-reconciliation fix) creates local catalog rows with the original IDs preserved. Direct catalog sync only matters for **orphan ingredients** (idea/scene/concept rows not attached to a universe) and for **post-`[catalog-universe-builder-picker]`** universe-ingredient picker links (the `catalog_ingredient_refs` rows themselves) so the "Appears in" panel populates across peers.
+- [ ] [catalog-ddl-drift-test] **Add a server test that diffs the catalog DDL between `server/scripts/init-db.sql` and the catalog block in `server/lib/db.js:ensureSchema()`.** Both files now duplicate the four table definitions + indexes + trigger functions; a future PR that updates one without the other will leave fresh installs and upgrading installs with different schemas. Lift the catalog DDL into a shared constant array consumed by both, or write a smoke test that parses both files and compares the relevant statements. Same risk exists for the `memories`/`memory_links` DDL but has been tolerated since the memory system landed; catalog inherits it. Surfaced when the user asked about Postgres schema upgrade management (2026-05-29).
+- [ ] [catalog-bundled-universe-push] **Bundle catalog ingredients into universe peer-push payloads.** When peer A pushes a universe to peer B, the push should include the `catalog_ingredients` rows referenced by that universe's embedded canon plus the `catalog_ingredient_refs` rows linking universe→ingredient. Today the embedded canon payloads still replicate (so universes work end-to-end across peers), but the `tags`, `embedding`, `payload.summary`-style enrichments that live ONLY in the catalog row do not — they get re-derived from the embedded entry on the receiver's first backfill, which is a strictly-lossy view. Mirror the `bundleAdditionalKinds` pattern in `server/services/sharing/peerSync.js`. Schema-version gating already covers the new bundle: `RECORD_KIND_SCHEMA_CATEGORIES.universe` would need `['universes', 'catalog']` appended OR the bundling can rely on `cat-ingredient` arriving as its own already-gated kind in the same push. Pairs with `[catalog-federation-orchestrator]`.
+- [ ] [catalog-extraction-idea-scene-concept] **Extend `server/services/catalogExtraction.js` to also extract idea / scene / concept ingredients from a scrap.** Current extractor reuses `extractBible` for character/place/object only (the three storyBible-shaped types). For the other three, ship a single LLM JSON-mode call with a custom prompt; surface the new stages on `catalog:extract:progress`. Until this lands, ideas/scenes/concepts are created manually via the Catalog list's "+ New" button.
+- [ ] [catalog-fts-character-fields] **`catalog_ingredients.search_tsv` (GENERATED ALWAYS) indexes `payload->>description|notes|background|summary` but NOT `physicalDescription` or `personality`** — so a search for "tall" or "introverted" misses bible-backfilled characters whose narrative text lives in those keys. Fix needs a schema migration: DROP COLUMN search_tsv + ADD COLUMN with the expanded expression (Postgres can't ALTER a STORED generated expression), update both `server/lib/db.js` and `server/scripts/init-db.sql`, and bump `PORTOS_SCHEMA_VERSIONS.catalog`. Surfaced by gemini review pass on the catalog PR (2026-05-29).
+- [ ] [catalog-commit-transactional] **`POST /api/catalog/scraps/:id/commit` does N `createIngredient` + N `linkIngredientToSource` writes in a sequential loop with no transaction.** A mid-loop failure (DB timeout, validation throw) leaves a partial commit — some ingredients persisted, some not, and some ingredients without source-link rows. Wrap the loop in `withTransaction` and pass the client down to `createIngredient` / `linkIngredientToSource`. Requires the catalogDB write helpers to accept an optional `client` (currently they call the pool-level `query()` directly). Surfaced by gemini review pass (2026-05-29).
+- [ ] [catalog-scrap-embedding-or-search] **Scraps embed on every create (`POST /api/catalog/scraps`), but the embedding column is never read** — no semantic-search route, no "find similar scraps" UI. Either remove the LLM round-trip on create (and keep the column for future use) OR ship the search endpoint that justifies the cost. Currently every scrap creation burns a 768-dim embedding call for zero search benefit. Surfaced by gemini review pass (2026-05-29).
+- [ ] [catalog-ref-deletion-tombstones] **`catalog_ingredient_refs` uses hard DELETE on unlink — the row vanishes with no `sync_sequence` bump and no tombstone, so peers that already pulled the ref never learn it was removed and their "Appears in" panels stay stale.** Fix needs: add `deleted boolean DEFAULT false` + `deleted_at timestamptz` to `catalog_ingredient_refs` (DDL in both `server/lib/db.js` and `server/scripts/init-db.sql`), switch `unlinkIngredientFromRef` in `server/services/catalogDB.js:430` to soft-delete with `sync_sequence` bump, teach `applyRemoteChanges` in `server/services/catalogSync.js` to apply ref-delete envelopes, and bump `PORTOS_SCHEMA_VERSIONS.catalog`. Pairs with `[catalog-federation-orchestrator]` — also re-surfaced by the codex review pass on the catalog PR (2026-05-29).
+- [ ] [catalog-tests] **Tests for the catalog stack.** `server/services/catalogDB.test.js` (CRUD round-trip — bootstrap follows `server/services/memoryDB.test.js`), `catalogExtraction.test.js` (mock aiToolkit, assert Zod conformance), `catalogSync.test.js` (envelope round-trip + LWW), `server/lib/catalogValidation.test.js` (Zod boundary tests), `server/scripts/migrateBibleToCatalog.test.js` (idempotency), `client/src/pages/Catalog.test.jsx`, `client/src/components/IngredientPicker.test.jsx`.
+- [ ] [catalog-writers-room-version-refs] **Phase 9 (deferred at design time): capture catalog ingredient ids per Writers Room draft version.** Extend the draft save path in `server/services/writersRoom/local.js` to accept `referencedIngredientIds[]`. Add `scanProseForIngredientRefs(text, { universeId?, seriesId?, workId? })` to `catalogExtraction.js` (substring match scoped to refs linked to the target). UI: render referenced ingredients on each version-history chip in `client/src/pages/WritersRoom.jsx`.
+
_Batch-cleared 2026-05-25: 23 Next Up items shipped together via parallel sub-agents (env-file helper, formatBytes migration, lazy voice UI text, voice long-term-memory routing, voice tool expansion + `ui_describe_visually`, three phosphene generate_ltx2.py hardenings, the peer-sync snapshot-coverage P1 refactor + ephemeralize-then-delete, per-record tombstone-ack clamp, MediaLightbox→MediaImage, global vitest peer mock, videoHistory sync category, reverse-sub UI broadcast, palette Health disambiguation, insertXxxWithId resurrection side-effects, importer tombstone detection, TUI finish-rejection note, mortalLoom errno widen, VideoGen stale-model toast, Universes sidebar grandchildren). See `git log` + `.changelog/NEXT.md`._
- [ ] [mediacard-use-mediaimage-for-syncing-assets] **`MediaCard.jsx` grid thumbnails still use a raw ` `.** Same peer-sync placeholder/live-swap gap that `[peer-sync-medialightbox-use-mediaimage-for-syncing-assets]` fixed for the lightbox — `client/src/components/media/MediaCard.jsx` (~line 42) doesn't get the "Syncing" placeholder or the `peerSync:asset-arrived` atomic swap. Swap the raw ` ` for `MediaImage`. Surfaced by that item's cross-check during the batch-clear (2026-05-25); was outside its stated scope.
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 9de5f8d467..e642116ae5 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -43,6 +43,9 @@ const MediaModels = lazyWithReload(() => import('./pages/MediaModels'));
const Loras = lazyWithReload(() => import('./pages/Loras'));
const UniverseBuilder = lazyWithReload(() => import('./pages/UniverseBuilder'));
const Universes = lazyWithReload(() => import('./pages/Universes'));
+const Catalog = lazyWithReload(() => import('./pages/Catalog'));
+const CatalogIngest = lazyWithReload(() => import('./pages/CatalogIngest'));
+const CatalogIngredient = lazyWithReload(() => import('./pages/CatalogIngredient'));
const VideoTimeline = lazyWithReload(() => import('./pages/VideoTimeline'));
const VideoTimelineEditor = lazyWithReload(() => import('./pages/VideoTimelineEditor'));
const CreativeDirector = lazyWithReload(() => import('./pages/CreativeDirector'));
@@ -251,6 +254,9 @@ export default function App() {
at `/universes/:universeId`; `new` is the create-mode sentinel
(UniverseBuilder treats it as no-id → blank draft). Universe ids are
UUIDs, so `new` can never collide with a real record. */}
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/client/src/components/IngredientPicker.jsx b/client/src/components/IngredientPicker.jsx
new file mode 100644
index 0000000000..cf4d8a625d
--- /dev/null
+++ b/client/src/components/IngredientPicker.jsx
@@ -0,0 +1,278 @@
+/**
+ * IngredientPicker — modal for attaching catalog ingredients to a parent
+ * record (universe, series, issue, writers-room).
+ *
+ * Built on top of the shared `ui/Modal` chrome so Esc / backdrop / portal
+ * stacking match every other modal in the app.
+ *
+ * Props:
+ * open — visibility flag.
+ * onClose — fires on Esc, backdrop click, or the X button.
+ * onSelect — fires with the chosen ingredient when `multi` is false,
+ * or with an array of chosen ingredients when `multi` is
+ * true (user hit "Add Selected").
+ * type — optional `'character' | 'place' | ...` to scope the search.
+ * multi — checkbox-mode toggle; defaults to single-click select.
+ * excludeIds — ids to hide from the result list (already-attached set).
+ * refKind/refId — currently unused by this component, but accepted so
+ * callers can wire up an "Already attached" section without
+ * the API surface changing later.
+ */
+
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { Search, Plus, Loader2, X, Sparkles } from 'lucide-react';
+import Modal from './ui/Modal';
+import { listCatalogIngredients } from '../services/apiCatalog';
+
+const TYPE_BADGE = {
+ character: 'bg-blue-500/20 text-blue-300 border-blue-500/40',
+ place: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
+ object: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
+ idea: 'bg-purple-500/20 text-purple-300 border-purple-500/40',
+ scene: 'bg-pink-500/20 text-pink-300 border-pink-500/40',
+ concept: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40',
+};
+
+function snippet(payload) {
+ if (!payload || typeof payload !== 'object') return '';
+ const text = String(payload.description || payload.summary || payload.notes || '').trim().replace(/\s+/g, ' ');
+ if (text.length <= 140) return text;
+ return `${text.slice(0, 137)}…`;
+}
+
+export default function IngredientPicker({
+ open,
+ onClose,
+ onSelect,
+ type,
+ multi = false,
+ excludeIds = [],
+}) {
+ const [searchInput, setSearchInput] = useState('');
+ const [q, setQ] = useState('');
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedIds, setSelectedIds] = useState(() => new Set());
+ // Track in-flight fetch generation so a late response from a stale query
+ // can't overwrite the current results.
+ const generationRef = useRef(0);
+
+ // Debounce search input (300ms) before pinning to `q`.
+ useEffect(() => {
+ if (!open) return undefined;
+ const t = setTimeout(() => setQ(searchInput.trim()), 300);
+ return () => clearTimeout(t);
+ }, [searchInput, open]);
+
+ // Reset transient state every time the modal opens — single-shot pickers
+ // should never inherit yesterday's checkboxes. Clear `items` too so the
+ // first frame of a new open doesn't flash stale results before the fetch
+ // resolves (especially visible when `type` changes between opens).
+ useEffect(() => {
+ if (!open) return;
+ setSearchInput('');
+ setQ('');
+ setSelectedIds(new Set());
+ setItems([]);
+ }, [open]);
+
+ // Fetch results. Refetch on q / type changes while open.
+ useEffect(() => {
+ if (!open) return undefined;
+ const gen = ++generationRef.current;
+ setLoading(true);
+ listCatalogIngredients({
+ type: type || undefined,
+ q: q || undefined,
+ limit: 50,
+ silent: true,
+ })
+ .then((data) => {
+ if (gen !== generationRef.current) return;
+ setItems(Array.isArray(data?.items) ? data.items : []);
+ setLoading(false);
+ })
+ .catch(() => {
+ if (gen !== generationRef.current) return;
+ setItems([]);
+ setLoading(false);
+ });
+ return undefined;
+ }, [open, q, type]);
+
+ // Exclusion set lookup — Set is O(1), array .includes is O(n) per row.
+ const excludeSet = useMemo(() => new Set(excludeIds || []), [excludeIds]);
+ const filtered = useMemo(
+ () => items.filter((it) => !excludeSet.has(it.id)),
+ [items, excludeSet],
+ );
+
+ const toggleSelected = (id) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+ };
+
+ const handleSingleSelect = (it) => {
+ onSelect?.(it);
+ onClose?.();
+ };
+
+ const handleAddSelected = () => {
+ const picked = filtered.filter((it) => selectedIds.has(it.id));
+ if (picked.length === 0) return;
+ onSelect?.(picked);
+ onClose?.();
+ };
+
+ return (
+
+
+
+
+
+ {multi ? 'Pick ingredients' : 'Pick an ingredient'}
+ {type ? ({type}) : null}
+
+
+
+
+
+
+
+
+
+
+ Search ingredients
+ setSearchInput(e.target.value)}
+ placeholder="Search by name, tag, or text…"
+ autoFocus
+ className="w-full pl-9 pr-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm focus:outline-none focus:border-port-accent"
+ />
+
+
+
+
+ {loading ? (
+
+
+ Searching…
+
+ ) : filtered.length === 0 ? (
+
+
No matching ingredients.
+
+
+ Create new in Catalog
+
+
+ ) : (
+
+ {filtered.map((it) => {
+ const badge = TYPE_BADGE[it.type] || 'bg-gray-500/20 text-gray-300 border-gray-500/40';
+ const checked = selectedIds.has(it.id);
+ const rowBase = 'w-full text-left p-3 rounded border bg-port-bg/40 transition-colors';
+ const rowClass = multi
+ ? `${rowBase} ${checked ? 'border-port-accent' : 'border-port-border hover:border-gray-500'}`
+ : `${rowBase} border-port-border hover:border-port-accent`;
+ const inner = (
+ <>
+
+
+ {multi && (
+ toggleSelected(it.id)}
+ onClick={(e) => e.stopPropagation()}
+ className="accent-port-accent"
+ aria-label={`Select ${it.name || it.id}`}
+ />
+ )}
+
+ {it.name || '(untitled)'}
+
+
+
+ {it.type}
+
+
+ {snippet(it.payload) && (
+ {snippet(it.payload)}
+ )}
+ {Array.isArray(it.tags) && it.tags.length > 0 && (
+
+ {it.tags.slice(0, 5).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+ >
+ );
+ return (
+
+ {multi ? (
+ toggleSelected(it.id)}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelected(it.id); } }}
+ className={`${rowClass} cursor-pointer`}>
+ {inner}
+
+ ) : (
+ handleSingleSelect(it)} className={rowClass}>
+ {inner}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ {multi && (
+
+
{selectedIds.size} selected
+
+
+ Cancel
+
+
+
+ Add Selected
+
+
+
+ )}
+
+ );
+}
diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx
index 38b22ba6c4..6e419153cd 100644
--- a/client/src/components/Layout.jsx
+++ b/client/src/components/Layout.jsx
@@ -196,6 +196,7 @@ const navItems = [
icon: Sparkles,
defaultTo: '/media',
children: [
+ { to: '/catalog', label: 'Catalog', icon: Sparkles },
{ to: '/importer', label: 'Importer', icon: FileInput },
{ to: '/media', label: 'Media Gen', icon: Layers },
{ to: '/pipeline', label: 'Series Pipeline', icon: WorkflowIcon, dynamic: 'pipelineSeries' },
@@ -1019,6 +1020,10 @@ export default function Layout() {
location.pathname === '/ask' ||
location.pathname.startsWith('/ask/') ||
location.pathname.startsWith('/calendar') ||
+ // Only the Catalog DETAIL editor (/catalog/{type}/{id}) and the
+ // Ingest page (/catalog/ingest) are full-width — they own their
+ // own scroll. The /catalog list/index page stays scrolling-default.
+ location.pathname.startsWith('/catalog/') ||
location.pathname.startsWith('/cos') ||
location.pathname.startsWith('/brain') ||
location.pathname.startsWith('/digital-twin') ||
diff --git a/client/src/components/settings/EmbeddingsTab.jsx b/client/src/components/settings/EmbeddingsTab.jsx
new file mode 100644
index 0000000000..5a9042222e
--- /dev/null
+++ b/client/src/components/settings/EmbeddingsTab.jsx
@@ -0,0 +1,202 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Boxes, RefreshCw, AlertTriangle, Check } from 'lucide-react';
+import toast from '../ui/Toast';
+import { getSettings, updateSettings } from '../../services/apiSystem';
+import { getLocalLlmStatus } from '../../services/apiLocalLlm';
+
+// Provider-agnostic embeddings configuration. Powers the creative catalog's
+// semantic search + the memory system's vector storage. Vector dim is pinned
+// to 768 (matches the live `vector(768)` columns); the dropdown calls out
+// known 768-dim models so the user picks one that won't get rejected.
+
+const PROVIDERS = [
+ { id: 'none', label: 'Disabled', description: "Don't embed at ingest. Rows persist; semantic search returns empty until you backfill." },
+ { id: 'ollama', label: 'Ollama', description: 'Use a locally installed Ollama embedding model.' },
+ { id: 'lmstudio', label: 'LM Studio', description: 'Use an LM Studio embedding model.' },
+];
+
+// 768-dim models we know about. Used as the recommendation hint in the
+// dropdown — the user can still type any other model name.
+const KNOWN_768_DIM = {
+ ollama: ['nomic-embed-text', 'snowflake-arctic-embed:s'],
+ lmstudio: ['nomic-ai/nomic-embed-text-v1.5-GGUF', 'text-embedding-nomic-embed-text-v1.5'],
+};
+
+const EMBEDDING_HINT_RE = /embed|bge|nomic|mxbai|gte|e5|arctic/i;
+
+export default function EmbeddingsTab() {
+ const [provider, setProvider] = useState('none');
+ const [model, setModel] = useState('');
+ const [saved, setSaved] = useState({ provider: 'none', model: '' });
+ const [models, setModels] = useState({ ollama: [], lmstudio: [] });
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [modelsLoading, setModelsLoading] = useState(false);
+
+ const refreshModels = async () => {
+ setModelsLoading(true);
+ const status = await getLocalLlmStatus({ silent: true }).catch(() => null);
+ setModels({
+ ollama: (status?.ollama?.installedModels || []).map((m) => m.id || m.name),
+ lmstudio: (status?.lmstudio?.installedModels || []).map((m) => m.id || m.name),
+ });
+ setModelsLoading(false);
+ };
+
+ useEffect(() => {
+ (async () => {
+ const settings = await getSettings({ silent: true }).catch(() => null);
+ const cfg = settings?.embeddings || {};
+ setProvider(cfg.provider || 'none');
+ setModel(cfg.model || '');
+ setSaved({ provider: cfg.provider || 'none', model: cfg.model || '' });
+ setLoading(false);
+ refreshModels();
+ })();
+ }, []);
+
+ const candidateModels = useMemo(() => {
+ if (provider === 'none') return [];
+ const installed = models[provider] || [];
+ const filtered = installed.filter((id) => EMBEDDING_HINT_RE.test(id));
+ const known = KNOWN_768_DIM[provider] || [];
+ // Combine, dedupe, preserve order: filtered installed first, then known not-yet-installed.
+ const seen = new Set();
+ const out = [];
+ for (const m of [...filtered, ...known]) {
+ if (!seen.has(m)) { seen.add(m); out.push(m); }
+ }
+ return out;
+ }, [provider, models]);
+
+ const dirty = provider !== saved.provider || (model || '') !== (saved.model || '');
+
+ const handleSave = async () => {
+ if (!dirty || saving) return;
+ setSaving(true);
+ await updateSettings({
+ embeddings: { provider, model: model.trim() || null },
+ }).then(() => {
+ setSaved({ provider, model: model.trim() || '' });
+ toast.success('Embeddings settings saved');
+ }).catch((err) => {
+ toast.error(`Save failed: ${err?.message || 'unknown error'}`);
+ }).finally(() => setSaving(false));
+ };
+
+ if (loading) {
+ return
Loading…
;
+ }
+
+ return (
+
+
+
+
Embeddings
+
+
+
+ Vector embeddings power semantic search across the creative catalog (Characters, Ideas,
+ Scenes, …) and the memory system. PortOS expects 768-dimensional vectors;
+ pick a 768-dim model below or your saves will be rejected.
+
+
+ {/* Provider picker */}
+
+
+ Provider
+
+
{
+ const next = e.target.value;
+ setProvider(next);
+ // Clear model when provider changes so we don't carry a foreign-provider name.
+ if (next !== saved.provider) setModel('');
+ }}
+ className="w-full bg-port-bg border border-port-border rounded px-3 py-2 text-sm text-white"
+ >
+ {PROVIDERS.map((p) => (
+ {p.label}
+ ))}
+
+
+ {PROVIDERS.find((p) => p.id === provider)?.description}
+
+
+
+ {/* Model picker — hidden when provider is 'none' */}
+ {provider !== 'none' && (
+
+
+
+ Model
+
+
+
+ Refresh
+
+
+
setModel(e.target.value)}
+ placeholder="nomic-embed-text"
+ className="w-full bg-port-bg border border-port-border rounded px-3 py-2 text-sm text-white"
+ />
+
+ {candidateModels.map((m) => (
+
+ ))}
+
+ {candidateModels.length === 0 && !modelsLoading && (
+
+
+
+ No installed embedding models detected on {provider}. Install one in Local LLMs
+ (e.g. nomic-embed-text on Ollama).
+
+
+ )}
+ {(models[provider] || []).length > 0 && !EMBEDDING_HINT_RE.test(model) && model && (
+
+
+
+ {model} doesn't look like an embedding model. Embeddings must be
+ 768-dim; non-embedding models will fail at ingest time.
+
+
+ )}
+
+ )}
+
+
+
+ {saving ? 'Saving…' : (
+ <>
+
+ Save
+ >
+ )}
+
+ {!dirty && !loading && (
+
+ Saved: {saved.provider === 'none' ? 'Disabled' : `${saved.provider}${saved.model ? ` · ${saved.model}` : ''}`}
+
+ )}
+
+
+ );
+}
diff --git a/client/src/components/settings/SettingsTabsHeader.jsx b/client/src/components/settings/SettingsTabsHeader.jsx
index 20bd2c2ecc..8c84bcbae1 100644
--- a/client/src/components/settings/SettingsTabsHeader.jsx
+++ b/client/src/components/settings/SettingsTabsHeader.jsx
@@ -13,6 +13,7 @@ const TABS = [
{ id: 'autofixer', label: 'Autofixer', to: '/settings/autofixer' },
{ id: 'backup', label: 'Backup', to: '/settings/backup' },
{ id: 'database', label: 'Database', to: '/settings/database' },
+ { id: 'embeddings', label: 'Embeddings', to: '/settings/embeddings' },
{ id: 'general', label: 'General', to: '/settings/general' },
{ id: 'local-llm', label: 'Local LLMs', to: '/settings/local-llm' },
{ id: 'mortalloom', label: 'MortalLoom', to: '/settings/mortalloom' },
diff --git a/client/src/lib/bibleLimits.js b/client/src/lib/bibleLimits.js
index 3c126d3f9a..7ef9453bc5 100644
--- a/client/src/lib/bibleLimits.js
+++ b/client/src/lib/bibleLimits.js
@@ -65,4 +65,5 @@ export const BIBLE_LIMITS = Object.freeze({
TAGS_PER_ENTRY_MAX: 12,
SOURCE_SERIES_ID_MAX: 64,
VOICE_ID_MAX: 200,
+ INGREDIENT_ID_MAX: 64,
});
diff --git a/client/src/pages/Catalog.jsx b/client/src/pages/Catalog.jsx
new file mode 100644
index 0000000000..a2e758049e
--- /dev/null
+++ b/client/src/pages/Catalog.jsx
@@ -0,0 +1,349 @@
+/**
+ * Catalog page — index of Creative Ingredients.
+ *
+ * Lists every ingredient (character/place/object/idea/scene/concept) the user
+ * has captured into the catalog. Type chips along the top filter by kind and
+ * show the per-type count from `/api/catalog/stats`. The "+ New" inline form
+ * mirrors the Pipeline series-create pattern; "Ingest" links to the paste-and-
+ * extract page. Delete uses an armed two-click confirm (no window.confirm).
+ */
+
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import { Link } from 'react-router-dom';
+import { Sparkles, Plus, Search, FileInput, Trash2, Loader2 } from 'lucide-react';
+import toast from '../components/ui/Toast';
+import {
+ listCatalogIngredients,
+ createCatalogIngredient,
+ deleteCatalogIngredient,
+ getCatalogStats,
+} from '../services/apiCatalog';
+
+const TYPES = [
+ { id: 'character', label: 'Character', color: 'bg-blue-500/20 text-blue-300 border-blue-500/40' },
+ { id: 'place', label: 'Place', color: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40' },
+ { id: 'object', label: 'Object', color: 'bg-amber-500/20 text-amber-300 border-amber-500/40' },
+ { id: 'idea', label: 'Idea', color: 'bg-purple-500/20 text-purple-300 border-purple-500/40' },
+ { id: 'scene', label: 'Scene', color: 'bg-pink-500/20 text-pink-300 border-pink-500/40' },
+ { id: 'concept', label: 'Concept', color: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40' },
+];
+
+const TYPE_BY_ID = Object.fromEntries(TYPES.map((t) => [t.id, t]));
+
+// Pull a short snippet from the type-specific payload — first hit wins,
+// trimmed and ellipsised to ~120 chars. Characters use `physicalDescription`
+// (canon shape), so check it first to avoid rendering empty rows for
+// bible-backfilled characters whose only narrative text lives there.
+function payloadSnippet(payload) {
+ if (!payload || typeof payload !== 'object') return '';
+ const raw = payload.physicalDescription || payload.description || payload.summary || payload.notes || '';
+ const text = String(raw).trim().replace(/\s+/g, ' ');
+ if (text.length <= 120) return text;
+ return `${text.slice(0, 117)}…`;
+}
+
+function TypeBadge({ type }) {
+ const meta = TYPE_BY_ID[type];
+ if (!meta) return null;
+ return (
+
+ {meta.label}
+
+ );
+}
+
+export default function Catalog() {
+ const [items, setItems] = useState([]);
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [selectedType, setSelectedType] = useState('');
+ // Two-stage search: `searchInput` is what the user is typing; `q` is the
+ // debounced value that actually drives the list fetch. 300ms gap.
+ const [searchInput, setSearchInput] = useState('');
+ const [q, setQ] = useState('');
+ // Inline create form
+ const [showForm, setShowForm] = useState(false);
+ const [form, setForm] = useState({ type: 'character', name: '' });
+ const [creating, setCreating] = useState(false);
+ // Armed-row id for two-click delete (no window.confirm).
+ const [armedId, setArmedId] = useState(null);
+
+ useEffect(() => {
+ const t = setTimeout(() => setQ(searchInput.trim()), 300);
+ return () => clearTimeout(t);
+ }, [searchInput]);
+
+ const loadStats = useCallback(() => {
+ getCatalogStats({ silent: true })
+ .then((s) => setStats(s || null))
+ .catch(() => {});
+ }, []);
+
+ const loadItems = useCallback(() => {
+ let cancelled = false;
+ setLoading(true);
+ listCatalogIngredients({
+ type: selectedType || undefined,
+ q: q || undefined,
+ limit: 200,
+ silent: true,
+ })
+ .then((data) => {
+ if (cancelled) return;
+ setItems(Array.isArray(data?.items) ? data.items : []);
+ setLoading(false);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ toast.error(err?.message || 'Failed to load catalog');
+ setLoading(false);
+ });
+ return () => { cancelled = true; };
+ }, [selectedType, q]);
+
+ useEffect(() => loadItems(), [loadItems]);
+ useEffect(() => loadStats(), [loadStats]);
+
+ const totalCount = stats?.total ?? items.length;
+ const countForType = (id) => stats?.byType?.[id] || 0;
+
+ const handleCreate = async (e) => {
+ e.preventDefault();
+ const name = form.name.trim();
+ if (!name) return;
+ setCreating(true);
+ const created = await createCatalogIngredient({
+ type: form.type,
+ name,
+ payload: {},
+ tags: [],
+ }, { silent: true }).catch((err) => {
+ toast.error(err?.message || 'Failed to create ingredient');
+ return null;
+ });
+ setCreating(false);
+ if (!created) return;
+ toast.success(`Created ${form.type} "${name}"`);
+ setForm({ type: form.type, name: '' });
+ setShowForm(false);
+ // Update list locally (CLAUDE.md: prefer state update over refetch) but
+ // still refresh stats so the type-chip counts move.
+ setItems((prev) => [created, ...prev]);
+ loadStats();
+ };
+
+ const confirmDelete = async (it) => {
+ setArmedId(null);
+ // Capture the original index so a failed delete restores in place rather
+ // than jumping the row to the top of the list.
+ const originalIdx = items.findIndex((x) => x.id === it.id);
+ setItems((prev) => prev.filter((x) => x.id !== it.id));
+ await deleteCatalogIngredient(it.id, { silent: true }).catch((err) => {
+ toast.error(err?.message || 'Delete failed');
+ setItems((prev) => {
+ if (prev.some((x) => x.id === it.id)) return prev;
+ const next = [...prev];
+ next.splice(Math.max(0, originalIdx), 0, it);
+ return next;
+ });
+ });
+ loadStats();
+ };
+
+ return (
+
+
+
+
+
Catalog
+
+ {totalCount} ingredient{totalCount === 1 ? '' : 's'}
+
+
+
+
+
+ Ingest
+
+
setShowForm((v) => !v)}
+ className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-port-accent hover:bg-port-accent/90 text-white text-sm font-medium"
+ >
+
+ New
+
+
+
+
+
+ setSelectedType('')}
+ className={`text-xs px-3 py-1.5 rounded-full border ${
+ selectedType === ''
+ ? 'bg-port-accent border-port-accent text-white'
+ : 'border-port-border text-gray-300 hover:text-white'
+ }`}
+ >
+ All {totalCount}
+
+ {TYPES.map((t) => (
+ setSelectedType(selectedType === t.id ? '' : t.id)}
+ className={`text-xs px-3 py-1.5 rounded-full border ${
+ selectedType === t.id
+ ? 'bg-port-accent border-port-accent text-white'
+ : 'border-port-border text-gray-300 hover:text-white'
+ }`}
+ >
+ {t.label} {countForType(t.id)}
+
+ ))}
+
+
+
+
+ Search catalog
+ setSearchInput(e.target.value)}
+ placeholder="Search by name, tag, or text…"
+ className="w-full pl-9 pr-3 py-2 bg-port-card border border-port-border rounded-lg text-white text-sm focus:outline-none focus:border-port-accent"
+ />
+
+
+ {showForm && (
+
+ )}
+
+ {loading ? (
+
Loading catalog…
+ ) : items.length === 0 ? (
+
+
+ {q || selectedType
+ ? 'No ingredients match the current filter.'
+ : 'Your catalog is empty. Paste a scrap on the Ingest page or create one manually.'}
+
+
+ ) : (
+
+ {items.map((it) => {
+ const armed = armedId === it.id;
+ const name = it.name || '(untitled)';
+ return (
+
+
+
+ {name}
+
+ {armed ? (
+
+ Delete?
+ confirmDelete(it)}
+ className="px-2 py-0.5 rounded bg-port-error/20 text-port-error hover:bg-port-error/30 font-medium"
+ >
+ Yes
+
+ setArmedId(null)}
+ className="px-2 py-0.5 rounded text-gray-400 hover:text-white"
+ >
+ No
+
+
+ ) : (
+ setArmedId(it.id)}
+ className="p-1.5 rounded text-gray-500 hover:text-port-error"
+ aria-label={`Delete ${name}`}
+ title="Delete ingredient"
+ >
+
+
+ )}
+
+
+
+ {(it.tags || []).slice(0, 4).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ {payloadSnippet(it.payload) ? (
+ {payloadSnippet(it.payload)}
+ ) : null}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/client/src/pages/CatalogIngest.jsx b/client/src/pages/CatalogIngest.jsx
new file mode 100644
index 0000000000..f4cd807770
--- /dev/null
+++ b/client/src/pages/CatalogIngest.jsx
@@ -0,0 +1,359 @@
+/**
+ * CatalogIngest — paste-extract-review-commit flow for the catalog. Three
+ * phases gated by local state: paste → extracting (live stage checklist via
+ * `catalog:extract:progress`) → review (per-kind checkboxes with editable
+ * name + description). Full-width page; owns its own scroll.
+ */
+
+import { useState, useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Sparkles, Loader2, CheckCircle2, AlertCircle, ArrowLeft, RotateCcw, Circle } from 'lucide-react';
+import toast from '../components/ui/Toast';
+import socket from '../services/socket';
+import {
+ createCatalogScrap,
+ extractFromCatalogScrap,
+ commitCatalogScrapDraft,
+} from '../services/apiCatalog';
+
+const KIND_SECTIONS = [
+ { key: 'characters', label: 'Characters', type: 'character' },
+ { key: 'places', label: 'Places', type: 'place' },
+ { key: 'objects', label: 'Objects', type: 'object' },
+];
+
+// Initial stage list, used until the server's `start` frame supplies the real
+// one. Matches the three sections rendered in the review phase so the panel
+// never looks empty between click and first frame.
+const INITIAL_STAGES = KIND_SECTIONS.map((s) => ({
+ id: s.key, label: s.label, status: 'pending', count: 0,
+}));
+
+function StageIcon({ status }) {
+ if (status === 'completed' || status === 'done') {
+ return ;
+ }
+ if (status === 'failed' || status === 'error') {
+ return ;
+ }
+ if (status === 'running') {
+ return ;
+ }
+ return ;
+}
+
+export default function CatalogIngest() {
+ const navigate = useNavigate();
+ const [phase, setPhase] = useState('paste'); // 'paste' | 'extracting' | 'review'
+ const [title, setTitle] = useState('');
+ const [rawText, setRawText] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [committing, setCommitting] = useState(false);
+ const [scrapId, setScrapId] = useState(null);
+ const [stages, setStages] = useState(INITIAL_STAGES);
+ // The draft returned by extractFromCatalogScrap(): per-kind candidate arrays
+ // editable inline + checkbox-gated. Defaults all to selected.
+ const [draft, setDraft] = useState({ characters: [], places: [], objects: [] });
+ const [selected, setSelected] = useState({ characters: new Set(), places: new Set(), objects: new Set() });
+
+ // Track active runId so a stale frame from an earlier scrap can't mutate the
+ // current stage list (server fans these to all sockets — single-user trust
+ // model still applies, but tab refresh + a slow extract overlap is real).
+ const activeRunIdRef = useRef(null);
+ useEffect(() => {
+ const onProgress = (ev) => {
+ if (!ev || typeof ev !== 'object') return;
+ if (ev.type === 'start') {
+ activeRunIdRef.current = ev.runId;
+ const next = Array.isArray(ev.stages) && ev.stages.length > 0
+ ? ev.stages.map((s) => ({ ...s, status: s.status || 'pending' }))
+ : INITIAL_STAGES;
+ setStages(next);
+ return;
+ }
+ if (ev.runId && ev.runId !== activeRunIdRef.current) return;
+ if (ev.type === 'stage') {
+ setStages((prev) => prev.map((s) => (
+ s.id === ev.id
+ ? { ...s, status: ev.status || s.status, count: ev.count ?? s.count, error: ev.error || s.error }
+ : s
+ )));
+ }
+ };
+ socket.on('catalog:extract:progress', onProgress);
+ return () => socket.off('catalog:extract:progress', onProgress);
+ }, []);
+
+ const reset = () => {
+ activeRunIdRef.current = null;
+ setPhase('paste');
+ setScrapId(null);
+ setStages(INITIAL_STAGES);
+ setDraft({ characters: [], places: [], objects: [] });
+ setSelected({ characters: new Set(), places: new Set(), objects: new Set() });
+ };
+
+ const handleIngest = async (e) => {
+ e?.preventDefault?.();
+ const text = rawText.trim();
+ if (!text) { toast.error('Paste some text first.'); return; }
+ setSubmitting(true);
+ setPhase('extracting');
+ setStages(INITIAL_STAGES);
+ // silent: own error handling below — avoids double-toast.
+ const created = await createCatalogScrap({ rawText: text, title: title.trim() || undefined }, { silent: true })
+ .catch((err) => { toast.error(err?.message || 'Failed to save scrap'); return null; });
+ if (!created?.scrap?.id) { setSubmitting(false); setPhase('paste'); return; }
+ setScrapId(created.scrap.id);
+ const result = await extractFromCatalogScrap(created.scrap.id, {}, { silent: true })
+ .catch((err) => { toast.error(err?.message || 'Extraction failed'); return null; });
+ setSubmitting(false);
+ if (!result?.draft) { setPhase('paste'); return; }
+ const d = {
+ characters: Array.isArray(result.draft.characters) ? result.draft.characters : [],
+ places: Array.isArray(result.draft.places) ? result.draft.places : [],
+ objects: Array.isArray(result.draft.objects) ? result.draft.objects : [],
+ };
+ setDraft(d);
+ setSelected({
+ characters: new Set(d.characters.map((_, i) => i)),
+ places: new Set(d.places.map((_, i) => i)),
+ objects: new Set(d.objects.map((_, i) => i)),
+ });
+ // Prefer server-supplied stage list; otherwise mark defaults completed.
+ if (Array.isArray(result.draft.stages) && result.draft.stages.length > 0) {
+ setStages(result.draft.stages.map((s) => ({ ...s, status: s.status || 'completed' })));
+ } else {
+ setStages((prev) => prev.map((s) => ({ ...s, status: 'completed', count: d[s.id]?.length || 0 })));
+ }
+ setPhase('review');
+ };
+
+ const toggle = (kind, idx) => {
+ setSelected((prev) => {
+ const next = new Set(prev[kind]);
+ if (next.has(idx)) next.delete(idx); else next.add(idx);
+ return { ...prev, [kind]: next };
+ });
+ };
+
+ const selectAll = (kind, on) => {
+ setSelected((prev) => ({
+ ...prev,
+ [kind]: on ? new Set(draft[kind].map((_, i) => i)) : new Set(),
+ }));
+ };
+
+ const patchCandidate = (kind, idx, patch) => {
+ setDraft((prev) => ({
+ ...prev,
+ [kind]: prev[kind].map((c, i) => (i === idx ? { ...c, ...patch } : c)),
+ }));
+ };
+
+ const handleCommit = async () => {
+ if (!scrapId) return;
+ // extractBible returns flat bible-shaped objects (role, personality,
+ // background, motivations, slugline, era, significance, …); commit must
+ // carry the whole shape minus the few keys catalog stores at the top
+ // (name, tags, id — and `type` from the section, not the candidate).
+ const accepted = [];
+ for (const section of KIND_SECTIONS) {
+ const arr = draft[section.key];
+ const sel = selected[section.key];
+ for (let i = 0; i < arr.length; i += 1) {
+ if (!sel.has(i)) continue;
+ const c = arr[i];
+ const name = (c.name || '').trim();
+ if (!name) continue;
+ // eslint-disable-next-line no-unused-vars
+ const { id: _id, type: _type, name: _name, tags: _tags, payload: nestedPayload, description, ...rest } = c;
+ const payload = { ...rest, ...(nestedPayload && typeof nestedPayload === 'object' ? nestedPayload : {}) };
+ if (description !== undefined) payload.description = description;
+ accepted.push({ type: section.type, name, payload, tags: Array.isArray(c.tags) ? c.tags : [] });
+ }
+ }
+ if (accepted.length === 0) {
+ toast.error('Select at least one candidate to commit.');
+ return;
+ }
+ setCommitting(true);
+ const result = await commitCatalogScrapDraft(scrapId, accepted, { silent: true }).catch((err) => {
+ toast.error(err?.message || 'Commit failed');
+ return null;
+ });
+ setCommitting(false);
+ if (!result) return;
+ const n = Array.isArray(result.ingredients) ? result.ingredients.length : accepted.length;
+ toast.success(`Added ${n} ingredient${n === 1 ? '' : 's'} to the catalog.`);
+ navigate('/catalog');
+ };
+
+ return (
+
+
+
+
+ {phase === 'paste' && (
+
+ )}
+
+ {phase === 'extracting' && (
+
+
+
+ Extracting ingredients — this runs several AI passes.
+
+
+ {stages.map((s) => (
+
+
+
+ {s.label}
+
+ {Number.isFinite(s.count) && s.count > 0 && (
+ ({s.count})
+ )}
+ {s.error && — {s.error} }
+
+ ))}
+
+
+ )}
+
+ {phase === 'review' && (
+
+
+ Review the candidates below. Uncheck anything you don't want, edit names and descriptions inline, then commit the rest.
+
+ {KIND_SECTIONS.map((section) => (
+
toggle(section.key, idx)}
+ onSelectAll={(on) => selectAll(section.key, on)}
+ onPatch={(idx, patch) => patchCandidate(section.key, idx, patch)}
+ />
+ ))}
+
+
+ Cancel
+
+
+ {committing ? : }
+ {committing ? 'Committing…' : 'Commit Selected'}
+
+
+
+ )}
+
+
+ );
+}
+
+function ReviewSection({ section, items, selected, onToggle, onSelectAll, onPatch }) {
+ const total = items.length;
+ const count = selected.size;
+ if (total === 0) {
+ return (
+
+ {section.label}
+ None extracted from this scrap.
+
+ );
+ }
+ return (
+
+
+
+ {section.label} ({count} / {total} selected)
+
+
+ onSelectAll(true)} className="px-2 py-1 rounded border border-port-border text-gray-300 hover:text-white">
+ Select All
+
+ onSelectAll(false)} className="px-2 py-1 rounded border border-port-border text-gray-300 hover:text-white">
+ Deselect All
+
+
+
+
+
+ );
+}
diff --git a/client/src/pages/CatalogIngredient.jsx b/client/src/pages/CatalogIngredient.jsx
new file mode 100644
index 0000000000..0e43281826
--- /dev/null
+++ b/client/src/pages/CatalogIngredient.jsx
@@ -0,0 +1,328 @@
+/**
+ * CatalogIngredient — detail/editor for a single catalog ingredient. Loaded
+ * via /catalog/:type/:id; the type from the loaded record is the source of
+ * truth. Side panels surface source scraps and inbound refs (universes /
+ * pipeline series / issues / writers-room). Full-width page; owns its scroll.
+ */
+
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams, Link } from 'react-router-dom';
+import { Sparkles, Save, Trash2, ArrowLeft, Loader2, ExternalLink } from 'lucide-react';
+import toast from '../components/ui/Toast';
+import {
+ getCatalogIngredient,
+ updateCatalogIngredient,
+ deleteCatalogIngredient,
+} from '../services/apiCatalog';
+
+// Per-type payload field list. Each entry is `[key, label, kind]` where `kind`
+// is 'text' (single line) or 'textarea' (multi-line). idea/scene/concept all
+// share the lightweight shape.
+const LIGHT_FIELDS = [
+ ['summary', 'Summary', 'textarea'],
+ ['description', 'Description', 'textarea'],
+ ['notes', 'Notes', 'textarea'],
+];
+const PAYLOAD_FIELDS = {
+ character: [
+ ['role', 'Role', 'text'],
+ // Canon character shape uses `physicalDescription` (matches
+ // sanitizeCharacter and the writers-room/bible extractor). A plain
+ // `description` here would render empty for backfill-promoted characters
+ // and edits would land in a sibling field the canon doesn't read.
+ ['physicalDescription', 'Physical Description', 'textarea'],
+ ['personality', 'Personality', 'textarea'],
+ ['background', 'Background', 'textarea'],
+ ['motivations', 'Motivations', 'textarea'],
+ ['notes', 'Notes', 'textarea'],
+ ],
+ place: [
+ ['slugline', 'Slugline', 'text'],
+ ['era', 'Era', 'text'],
+ ['description', 'Description', 'textarea'],
+ ['notes', 'Notes', 'textarea'],
+ ],
+ object: [
+ ['description', 'Description', 'textarea'],
+ ['significance', 'Significance', 'textarea'],
+ ['notes', 'Notes', 'textarea'],
+ ],
+ idea: LIGHT_FIELDS,
+ scene: LIGHT_FIELDS,
+ concept: LIGHT_FIELDS,
+};
+
+const TYPE_BADGE = {
+ character: 'bg-blue-500/20 text-blue-300 border-blue-500/40',
+ place: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
+ object: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
+ idea: 'bg-purple-500/20 text-purple-300 border-purple-500/40',
+ scene: 'bg-pink-500/20 text-pink-300 border-pink-500/40',
+ concept: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40',
+};
+
+// Map a refKind onto a click-through route. Returns null for kinds we don't
+// know how to deep-link to, so callers can render the chip without a link.
+function refPath(refKind, refId) {
+ if (!refId) return null;
+ switch (refKind) {
+ case 'universe': return `/universes/${encodeURIComponent(refId)}`;
+ case 'series': return `/pipeline/series/${encodeURIComponent(refId)}`;
+ case 'issue': return `/pipeline/issues/${encodeURIComponent(refId)}/concept`;
+ case 'writers-room':
+ case 'writersRoom': return '/writers-room';
+ default: return null;
+ }
+}
+
+function REFKIND_LABEL(kind) {
+ if (kind === 'universe') return 'Universes';
+ if (kind === 'series') return 'Series';
+ if (kind === 'issue') return 'Issues';
+ if (kind === 'writers-room' || kind === 'writersRoom') return "Writers' Room";
+ return kind;
+}
+
+export default function CatalogIngredient() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const [record, setRecord] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [name, setName] = useState('');
+ const [tagsInput, setTagsInput] = useState('');
+ const [payload, setPayload] = useState({});
+ const [saving, setSaving] = useState(false);
+ const [armedDelete, setArmedDelete] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ getCatalogIngredient(id, { silent: true })
+ .then((r) => {
+ if (cancelled) return;
+ if (!r) {
+ toast.error('Ingredient not found');
+ navigate('/catalog');
+ return;
+ }
+ setRecord(r);
+ setName(r.name || '');
+ setTagsInput((r.tags || []).join(', '));
+ setPayload(r.payload && typeof r.payload === 'object' ? { ...r.payload } : {});
+ setLoading(false);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ toast.error(err?.message || 'Failed to load ingredient');
+ navigate('/catalog');
+ });
+ return () => { cancelled = true; };
+ }, [id, navigate]);
+
+ const handleSave = async () => {
+ if (!record) return;
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ toast.error('Name is required');
+ return;
+ }
+ const tags = tagsInput.split(',').map((t) => t.trim()).filter(Boolean);
+ setSaving(true);
+ const updated = await updateCatalogIngredient(record.id, {
+ name: trimmedName,
+ payload,
+ tags,
+ }, { silent: true }).catch((err) => {
+ toast.error(err?.message || 'Save failed');
+ return null;
+ });
+ setSaving(false);
+ if (!updated) return;
+ setRecord((prev) => ({ ...prev, ...updated }));
+ toast.success('Saved');
+ };
+
+ const confirmDelete = async () => {
+ if (!record) return;
+ setArmedDelete(false);
+ const ok = await deleteCatalogIngredient(record.id, { silent: true })
+ .then(() => true)
+ .catch((err) => { toast.error(err?.message || 'Delete failed'); return false; });
+ if (ok) {
+ toast.success('Deleted');
+ navigate('/catalog');
+ }
+ };
+
+ const updatePayload = (key, value) => {
+ setPayload((prev) => ({ ...prev, [key]: value }));
+ };
+
+ if (loading || !record) {
+ return (
+
+ );
+ }
+
+ const fields = PAYLOAD_FIELDS[record.type] || PAYLOAD_FIELDS.idea;
+ const badgeClass = TYPE_BADGE[record.type] || 'bg-gray-500/20 text-gray-300 border-gray-500/40';
+
+ // Group refs by kind for the "Appears in" panel. Tolerates either an array
+ // of `{ refKind, refId, role }` or a server-grouped shape.
+ const refs = Array.isArray(record.refs) ? record.refs : [];
+ const refsByKind = refs.reduce((acc, r) => {
+ const k = r.refKind || r.kind || 'other';
+ (acc[k] ||= []).push(r);
+ return acc;
+ }, {});
+
+ return (
+
+
+
+
+
+
+
+
+ {record.name || '(untitled)'}
+
+
+ {record.type}
+
+
+
{record.id}
+
+
+
+
+
Back
+
+
+ {saving ? : } Save
+
+ {armedDelete ? (
+
+ Delete this ingredient?
+
+ Yes, delete
+
+ setArmedDelete(false)}
+ className="px-3 py-2 rounded-lg text-gray-400 hover:text-white">
+ Cancel
+
+
+ ) : (
+
setArmedDelete(true)}
+ className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-port-border text-gray-400 hover:text-port-error"
+ aria-label="Delete ingredient" title="Delete">
+ Delete
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SourcesPanel({ sources }) {
+ const list = Array.isArray(sources) ? sources : [];
+ return (
+
+ Source scraps
+ {list.length === 0 ? (
+ Created manually — no source scrap.
+ ) : (
+
+ {list.map((s, i) => (
+
+ {s.scrapId}
+ {s.extractedAt && {new Date(s.extractedAt).toLocaleString()} }
+
+ ))}
+
+ )}
+
+ );
+}
+
+function RefsPanel({ refsByKind }) {
+ const kinds = Object.keys(refsByKind);
+ return (
+
+ Appears in
+ {kinds.length === 0 ? (
+ Not yet linked to any universe, series, or issue.
+ ) : (
+
+ {kinds.map((kind) => (
+
+
+ {REFKIND_LABEL(kind)}
+
+
+ {refsByKind[kind].map((r, i) => {
+ const path = refPath(kind, r.refId);
+ const label = r.refName || r.refId || '(unnamed)';
+ const role = r.role ? ` · ${r.role}` : '';
+ const chip = (
+
+ {label}{role}
+ {path && }
+
+ );
+ return path ? (
+
+ {chip}
+
+ ) : (
+ {chip}
+ );
+ })}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx
index a63cd4e944..47cf7f243f 100644
--- a/client/src/pages/Settings.jsx
+++ b/client/src/pages/Settings.jsx
@@ -2,6 +2,7 @@ import { useParams, Navigate } from 'react-router-dom';
import { AutofixerTab } from '../components/settings/AutofixerTab';
import { BackupTab } from '../components/settings/BackupTab';
import { DatabaseTab } from '../components/settings/DatabaseTab';
+import EmbeddingsTab from '../components/settings/EmbeddingsTab';
import { LocalLlmTab } from '../components/settings/LocalLlmTab';
import { TelegramTab } from '../components/settings/TelegramTab';
import { GeneralTab } from '../components/settings/GeneralTab';
@@ -31,6 +32,7 @@ export default function Settings() {
case 'autofixer': return ;
case 'backup': return ;
case 'database': return ;
+ case 'embeddings': return ;
case 'local-llm': return ;
case 'sharing': return ;
case 'voice': return ;
diff --git a/client/src/services/api.js b/client/src/services/api.js
index 0d0f651908..57eeffcaaa 100644
--- a/client/src/services/api.js
+++ b/client/src/services/api.js
@@ -13,6 +13,7 @@ export * from './apiRuns.js';
export * from './apiHistory.js';
export * from './apiCommands.js';
export * from './apiGit.js';
+export * from './apiCatalog.js';
export * from './apiMedia.js';
export * from './apiAgents.js';
export * from './apiPersonalities.js';
diff --git a/client/src/services/apiCatalog.js b/client/src/services/apiCatalog.js
new file mode 100644
index 0000000000..c79feb0d73
--- /dev/null
+++ b/client/src/services/apiCatalog.js
@@ -0,0 +1,82 @@
+import { request } from './apiCore.js';
+
+// Creative Ingredients Catalog API surface. Every helper takes an optional
+// `options` second arg so callers with their own `.catch` toast can pass
+// `{ silent: true }` per the project convention (avoids double-toast).
+//
+// All path params are URL-encoded — refId and refKind in particular flow
+// from arbitrary record ids and could contain `/`, `?`, `#`, or `%`. The
+// list-query params already round-trip through URLSearchParams which encodes.
+
+const enc = encodeURIComponent;
+
+export const getCatalogStats = (options) => request('/catalog/stats', options);
+
+// --- Scraps -------------------------------------------------------------
+
+export const createCatalogScrap = (body = {}, options) =>
+ request('/catalog/scraps', { method: 'POST', body: JSON.stringify(body), ...options });
+
+export const listCatalogScraps = ({ limit, offset, ...options } = {}) => {
+ const params = new URLSearchParams();
+ if (limit) params.set('limit', String(limit));
+ if (offset) params.set('offset', String(offset));
+ return request(`/catalog/scraps${params.toString() ? `?${params}` : ''}`, options);
+};
+
+export const getCatalogScrap = (id, options) => request(`/catalog/scraps/${enc(id)}`, options);
+
+export const updateCatalogScrap = (id, patch, options) =>
+ request(`/catalog/scraps/${enc(id)}`, { method: 'PATCH', body: JSON.stringify(patch), ...options });
+
+export const deleteCatalogScrap = (id, options) =>
+ request(`/catalog/scraps/${enc(id)}`, { method: 'DELETE', ...options });
+
+export const extractFromCatalogScrap = (id, body = {}, options) =>
+ request(`/catalog/scraps/${enc(id)}/extract`, { method: 'POST', body: JSON.stringify(body), ...options });
+
+export const commitCatalogScrapDraft = (id, accepted, options) =>
+ request(`/catalog/scraps/${enc(id)}/commit`, { method: 'POST', body: JSON.stringify({ accepted }), ...options });
+
+// --- Ingredients --------------------------------------------------------
+
+export const listCatalogIngredients = ({ type, tag, q, limit, offset, ...options } = {}) => {
+ const params = new URLSearchParams();
+ if (type) params.set('type', type);
+ if (tag) params.set('tag', tag);
+ if (q) params.set('q', q);
+ if (limit) params.set('limit', String(limit));
+ if (offset) params.set('offset', String(offset));
+ return request(`/catalog/ingredients${params.toString() ? `?${params}` : ''}`, options);
+};
+
+export const getCatalogIngredient = (id, options) =>
+ request(`/catalog/ingredients/${enc(id)}`, options);
+
+export const createCatalogIngredient = (body = {}, options) =>
+ request('/catalog/ingredients', { method: 'POST', body: JSON.stringify(body), ...options });
+
+export const updateCatalogIngredient = (id, patch, options) =>
+ request(`/catalog/ingredients/${enc(id)}`, { method: 'PATCH', body: JSON.stringify(patch), ...options });
+
+export const deleteCatalogIngredient = (id, options) =>
+ request(`/catalog/ingredients/${enc(id)}`, { method: 'DELETE', ...options });
+
+// --- Linking (catalog ↔ universe/series/work) ---------------------------
+
+export const linkCatalogIngredient = (id, body, options) =>
+ request(`/catalog/ingredients/${enc(id)}/link`, { method: 'POST', body: JSON.stringify(body), ...options });
+
+export const unlinkCatalogIngredient = (id, body, options) =>
+ request(`/catalog/ingredients/${enc(id)}/link`, { method: 'DELETE', body: JSON.stringify(body), ...options });
+
+export const listCatalogIngredientsForRef = (refKind, refId, options) =>
+ request(`/catalog/refs/${enc(refKind)}/${enc(refId)}/ingredients`, options);
+
+// --- Admin --------------------------------------------------------------
+
+export const backfillCatalogEmbeddings = ({ limit, ...options } = {}) =>
+ request('/catalog/embeddings/backfill', { method: 'POST', body: JSON.stringify({ limit }), ...options });
+
+export const rerunCatalogMigration = ({ force, ...options } = {}) =>
+ request('/catalog/migration/rerun', { method: 'POST', body: JSON.stringify({ force }), ...options });
diff --git a/docs/plans/2026-05-29-creative-ingredients-catalog.md b/docs/plans/2026-05-29-creative-ingredients-catalog.md
new file mode 100644
index 0000000000..ff3e8d8782
--- /dev/null
+++ b/docs/plans/2026-05-29-creative-ingredients-catalog.md
@@ -0,0 +1,339 @@
+# Creative Ingredients Catalog
+
+## Context
+
+Today, PortOS stores creative content in three siloed JSON shapes:
+
+- **Universes** (`data/universes/{id}/index.json`) own their cast (characters), settings (places), and props (objects) as embedded arrays under `canon.*`.
+- **Series + Issues** (`data/pipeline-series/`, `data/pipeline-issues/`) own a parallel embedded copy of those same shapes per series.
+- **Writers Room works** (`data/writers-room/works/{id}/manifest.json`) own yet another parallel copy.
+
+The story-bible sanitizer in `server/lib/storyBible.js` is shared, but the *records* are not — a character imagined for one universe cannot be reused in another without manual re-entry. Free-form creative material (one-line story sparks, scene snippets, rough drafts, lore notes) has no first-class home at all; it either gets pasted into a Writers Room draft (heavy), routed through the Importer (which expects screenplay shape), or lost.
+
+This plan adds a **Creative Ingredients Catalog** — a single Postgres-backed store of typed, tagged, embeddable "ingredients" (Characters, Places, Objects, Ideas, Scenes, Concepts) that:
+
+1. **Preserves raw input** — every paste is stored verbatim as a Scrap, never destructively edited by extraction.
+2. **Extracts structured ingredients** from each Scrap via LLM, using the existing `storyBible.js` shapes for char/place/object so backfilled and freshly-ingested records are identical on the wire.
+3. **Cross-references** with Universes / Series / Issues / Writers Room — both consumed (pick a character → attach to series) and produced (existing embedded canon is back-filled into the catalog, with bidirectional `ingredientId` linkage).
+4. **Federates** between peer installs using the same `sync_sequence BIGSERIAL` + LWW pattern as `server/services/memorySync.js`.
+5. **Searches** via Postgres tsvector FTS + pgvector cosine similarity (provider-agnostic embeddings via Ollama or LM Studio, configurable in PortOS settings).
+
+Postgres + pgvector is already live (the memory system uses it). The catalog reuses every primitive — `query`, `withTransaction`, `arrayToPgvector`/`pgvectorToArray`, HNSW indexing, the `sync_sequence` federation pattern, and the `PORTOS_SCHEMA_VERSIONS` wire contract.
+
+## Locked-in design decisions
+
+1. **Backfill existing embedded characters** into the catalog (migration writes new rows + stamps `ingredientId` back onto the embedded records).
+2. **Six ingredient types at launch**: `character`, `place`, `object` (reuse `storyBible.js` shapes verbatim) plus `idea`, `scene`, `concept` (new lightweight shapes).
+3. **New dedicated Catalog page** under Create section — not an extension of Brain inbox or Importer.
+4. **Provider-agnostic embeddings** auto-applied at ingest when configured. New `settings.embeddings = { provider: 'ollama' | 'lmstudio' | 'none', model }` in `data/settings.json`. Ollama is the user's default; `server/services/ollamaManager.js` needs a new `getEmbeddings()` mirroring the one already in `server/services/lmStudioManager.js:377`.
+5. **Vector dim pinned to 768** for v1 (matches the existing `memories.embedding vector(768)` column and Ollama's `nomic-embed-text`). The embedding service validates output dim and surfaces a clear error on mismatch.
+
+## Implementation phases
+
+Each phase is shippable as one or more PRs. Phases 1–3 are backend-only (no user-visible change). Phase 6 lights the feature up for the user. Phase 7 rolls picker integration into existing pages one at a time.
+
+### Phase 1 — Postgres schema + db.js helpers
+
+**Files**
+- `server/scripts/init-db.sql` — append catalog DDL (idempotent `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, `CREATE OR REPLACE FUNCTION`). Reuses the existing pgvector + pgcrypto extensions already loaded for `memories`.
+- `server/lib/db.js` — extend `ensureSchema()` with the same DDL so runtime upgrades work; extend `checkHealth()` to probe `catalog_ingredients`.
+
+**Tables**
+
+```sql
+CREATE TABLE IF NOT EXISTS catalog_scraps (
+ id TEXT PRIMARY KEY, -- 'cat-scrap-'
+ title TEXT,
+ raw_text TEXT NOT NULL,
+ source_kind VARCHAR(32) DEFAULT 'paste', -- paste|brain-bridge|importer-handoff
+ metadata JSONB DEFAULT '{}'::jsonb,
+ embedding vector(768),
+ embedding_model VARCHAR(100),
+ origin_instance_id VARCHAR(36),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ deleted BOOLEAN DEFAULT FALSE,
+ deleted_at TIMESTAMPTZ,
+ sync_sequence BIGSERIAL
+);
+CREATE INDEX IF NOT EXISTS idx_catalog_scraps_embedding
+ ON catalog_scraps USING hnsw (embedding vector_cosine_ops) WITH (m=16, ef_construction=64);
+CREATE INDEX IF NOT EXISTS idx_catalog_scraps_fts
+ ON catalog_scraps USING gin (to_tsvector('english', coalesce(title,'')||' '||raw_text));
+CREATE INDEX IF NOT EXISTS idx_catalog_scraps_sync_seq ON catalog_scraps (sync_sequence);
+
+CREATE TABLE IF NOT EXISTS catalog_ingredients (
+ id TEXT PRIMARY KEY, -- 'cat-chr-', 'cat-plc-', etc.
+ type VARCHAR(20) NOT NULL
+ CHECK (type IN ('character','place','object','idea','scene','concept')),
+ name TEXT NOT NULL,
+ payload JSONB NOT NULL DEFAULT '{}'::jsonb, -- storyBible shape for chr/plc/obj; lighter shape for idea/scene/concept
+ tags TEXT[] DEFAULT '{}',
+ embedding vector(768),
+ embedding_model VARCHAR(100),
+ search_tsv tsvector GENERATED ALWAYS AS (
+ setweight(to_tsvector('english', coalesce(name,'')), 'A') ||
+ setweight(to_tsvector('english',
+ coalesce(payload->>'description','') || ' ' ||
+ coalesce(payload->>'notes','') || ' ' ||
+ coalesce(payload->>'background','')
+ ), 'B')
+ ) STORED,
+ origin_instance_id VARCHAR(36),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ deleted BOOLEAN DEFAULT FALSE,
+ deleted_at TIMESTAMPTZ,
+ sync_sequence BIGSERIAL
+);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_embedding
+ ON catalog_ingredients USING hnsw (embedding vector_cosine_ops) WITH (m=16, ef_construction=64);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_fts ON catalog_ingredients USING gin (search_tsv);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_type ON catalog_ingredients (type);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_tags ON catalog_ingredients USING gin (tags);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_sync_seq ON catalog_ingredients (sync_sequence);
+
+CREATE TABLE IF NOT EXISTS catalog_ingredient_sources (
+ ingredient_id TEXT NOT NULL REFERENCES catalog_ingredients(id) ON DELETE CASCADE,
+ scrap_id TEXT NOT NULL REFERENCES catalog_scraps(id) ON DELETE CASCADE,
+ span JSONB, -- optional { start, end } in raw_text
+ extracted_at TIMESTAMPTZ DEFAULT NOW(),
+ sync_sequence BIGSERIAL,
+ PRIMARY KEY (ingredient_id, scrap_id)
+);
+
+CREATE TABLE IF NOT EXISTS catalog_ingredient_refs (
+ ingredient_id TEXT NOT NULL REFERENCES catalog_ingredients(id) ON DELETE CASCADE,
+ ref_kind VARCHAR(32) NOT NULL, -- 'universe'|'series'|'issue'|'work'|'creative-director'
+ ref_id TEXT NOT NULL,
+ role VARCHAR(64) NOT NULL, -- 'canon-character'|'canon-place'|'canon-object'|'cast'|'mentioned'
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ sync_sequence BIGSERIAL,
+ PRIMARY KEY (ingredient_id, ref_kind, ref_id, role)
+);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_refs_target ON catalog_ingredient_refs (ref_kind, ref_id);
+```
+
+A trigger function `update_catalog_ingredient_timestamp()` mirrors the existing `update_memory_timestamp()` in `init-db.sql:74` — skips the bump on no-content-change to keep sync quiet, respects explicit `updated_at` from the sync apply path.
+
+### Phase 2 — Embedding service (provider-agnostic)
+
+**Files**
+- `server/services/ollamaManager.js` — add `getEmbeddings(text, options = {})` that POSTs `{ model, input: text }` to `/api/embed` (Ollama 0.2+) with a fallback to `/api/embeddings` for older versions. Return `{ success, embedding, model, dimensions }` matching the `lmStudioManager.getEmbeddings` contract at line 377.
+- `server/services/embeddings.js` **NEW** — provider router:
+ - `getEmbeddingsConfig()` — reads `data/settings.json` `embeddings` slice
+ - `embedText(text)` — routes to `ollamaManager.getEmbeddings` / `lmStudioManager.getEmbeddings` / returns `{ skipped: true }` when provider is `'none'`. Validates dim === 768, errors on mismatch.
+ - `embedBatch(texts, { concurrency = 4 })` — used for backfill + admin re-embed.
+- `server/lib/validation.js` — add `settingsEmbeddingsSchema = z.object({ provider: z.enum(['ollama','lmstudio','none']), model: z.string().optional() })`. Wire into `PUT /api/settings` polymorphic-partial path in `server/routes/settings.js` per the convention.
+- `client/src/pages/Settings.jsx` — new "Embeddings" section. Provider radio/dropdown, model dropdown populated from `/api/ollama/models` or `/api/lm-studio/models` on provider selection. Persist via existing `PUT /api/settings`.
+
+### Phase 3 — Catalog DB + sync + routes (no UI)
+
+**Files**
+- `server/services/catalogDB.js` **NEW** — mirrors `server/services/memoryDB.js`. ID generation: `cat-${prefix}-${randomUUID()}` where prefix is `chr|plc|obj|idea|scn|cnc`.
+
+ Signatures:
+ ```
+ createScrap({ title, rawText, sourceKind?, metadata?, embedding? }): Promise
+ getScrap(id): Promise
+ listScraps({ since?, limit = 50 }): Promise<{ items, nextOffset }>
+ deleteScrap(id): Promise
+
+ createIngredient({ type, name, payload, tags?, embedding?, embeddingModel? }): Promise
+ updateIngredient(id, patch): Promise
+ listIngredients({ type?, tag?, query?, limit = 50, offset = 0, since? }): Promise<{ items, nextOffset }>
+ searchIngredientsByEmbedding(vector, { type?, limit = 20, threshold = 0.5 }): Promise>
+ searchIngredientsByText(query, { type?, limit = 20 }): Promise>
+ searchHybrid(query, opts): Promise<...> // blends cosine + ts_rank_cd 60/40 when embedding available
+
+ linkIngredientToSource(ingredientId, scrapId, span?): Promise
+ linkIngredientToRef(ingredientId, refKind, refId, role): Promise
+ unlinkIngredientFromRef(ingredientId, refKind, refId, role): Promise
+ listRefsForIngredient(ingredientId): Promise>
+ listIngredientsForRef(refKind, refId): Promise>
+
+ getNextSyncSequence(): Promise
+ getChangesSince(seq, limit = 100): Promise<{ scraps, ingredients, sources, refs, maxSequence, hasMore }>
+ applyRemoteChanges(envelope): Promise<{ applied, skipped }>
+ ```
+
+- `server/services/catalogSync.js` **NEW** — mirrors `server/services/memorySync.js`. LWW on `updated_at`. Reads `portosMeta.schemaVersions.catalog` from incoming envelope and rejects ahead-mismatches via `compareSchemaVersions` from `server/lib/schemaVersions.js`.
+
+- `server/lib/schemaVersions.js` — add `catalog: 1` to `PORTOS_SCHEMA_VERSIONS` (line 35). Add `'cat-ingredient': ['catalog']` and `'cat-scrap': ['catalog']` to `RECORD_KIND_SCHEMA_CATEGORIES` (line 81).
+
+- `server/routes/catalog.js` **NEW** — routes per spec:
+ ```
+ POST /api/catalog/scraps create + kick off extraction
+ GET /api/catalog/scraps
+ GET /api/catalog/scraps/:id
+ POST /api/catalog/scraps/:id/extract re-run
+ POST /api/catalog/scraps/:id/commit commit reviewed drafts
+ DELETE /api/catalog/scraps/:id
+ GET /api/catalog/ingredients ?type=&tag=&q=&limit=
+ GET /api/catalog/ingredients/:id
+ PATCH /api/catalog/ingredients/:id
+ DELETE /api/catalog/ingredients/:id
+ POST /api/catalog/ingredients/:id/link { refKind, refId, role }
+ DELETE /api/catalog/ingredients/:id/link
+ GET /api/catalog/sync?since=&limit=100
+ POST /api/catalog/sync/apply
+ POST /api/catalog/embeddings/backfill admin: re-embed rows where embedding IS NULL
+ ```
+
+- `server/lib/catalogValidation.js` **NEW** — Zod schemas: `catalogScrapCreateSchema`, `catalogIngredientCreateSchema`, `catalogIngredientPatchSchema`, `catalogIngredientLinkSchema`, `catalogIngredientQuerySchema`, `catalogSyncEnvelopeSchema`. Char/place/object payload schemas import limits from `storyBible.BIBLE_LIMITS`.
+
+- `server/lib/index.js` — barrel-export `catalogValidation` (the project's `server/lib/index.test.js` fails on missing barrel exports).
+
+- `server/index.js` — wire `app.use('/api/catalog', catalogRoutes)` near the existing `/api/memory` route registration.
+
+### Phase 4 — Backfill migration
+
+**Files**
+- `server/scripts/migrateBibleToCatalog.js` **NEW** — idempotent default export `migrateBibleToCatalog()`:
+ - Walk every universe via `universeBuilder.listUniverses()` → for each entry in `universe.canon.characters[] / places[] / objects[]`: if `entry.ingredientId` already set, skip; else create catalog ingredient with `payload = entry` (full storyBible shape), `tags = ['canon','from-universe', universe.id]`, link via `linkIngredientToRef(ingId, 'universe', universe.id, 'canon-')`, then mutate `entry.ingredientId = ingId` and write the universe back through `universeBuilder.saveUniverse` with a new `{ silent: true }` flag so peer-sync fan-out doesn't fire one record per ingredient during the migration window.
+ - Same walk for `series.characters[] / places[] / objects[]` and Writers Room work bibles.
+ - Records stats in `data/migrations.applied.json` under `bibleToCatalog: { version: 1, completedAt, stats: { ... } }`.
+- `server/lib/storyBible.js` — extend the sanitizer to preserve `ingredientId: string | null` (max 64 chars) through round-trips on character/place/object entries. Add `INGREDIENT_ID_MAX: 64` to `BIBLE_LIMITS`.
+- `server/services/universeBuilder.js` / `server/services/pipeline/series.js` / `server/services/writersRoom/local.js` — accept `{ silent: true }` save option that skips the post-save peer-sync trigger. Migration uses it; normal user edits leave it off.
+- `server/index.js` — after `ensureSchema()`, invoke `migrateBibleToCatalog()` once. Wrap in try/catch with single-line `console.error('🪄 bible→catalog migration failed: ${err.message}')` per project convention; never crash boot.
+
+### Phase 5 — Extraction service + ingest path
+
+**Files**
+- `server/services/catalogExtraction.js` **NEW** — `extractIngredients(rawText, { socketId?, hints?, signal? })`. Uses the AI toolkit LLM in JSON mode with a prompt that produces:
+ ```
+ { characters: [], places: [...], objects: [...],
+ ideas: [{ name, summary, tags? }],
+ scenes: [{ name, summary, povCharacter?, location?, fullText }],
+ concepts: [{ name, summary, kind?: 'lore'|'magic'|'tech'|'faction'|'rule', tags? }] }
+ ```
+ Streams `catalog:extract:progress` socket frames matching the `importer:progress` shape from `server/services/importer.js`. Output is a **draft**, not committed — the route returns it for user review.
+- `server/routes/catalog.js` (extension of phase 3) — `POST /api/catalog/scraps` persists the scrap immediately, returns `{ scrapId, draft }`. Extraction runs in the background and streams progress; the response payload includes the final draft once extraction is done (or the route returns immediately and the client polls/receives via socket — match `importer.js`'s pattern). `POST /api/catalog/scraps/:id/commit` accepts `{ accepted: [...drafts] }` and persists ingredients + calls `linkIngredientToSource(ingId, scrapId, span?)`.
+- `server/services/embeddings.js` (from phase 2) — invoked synchronously inside `createIngredient` and `createScrap` when provider is configured, batched when none-configured-but-later-backfilled.
+
+### Phase 6 — Catalog UI (feature goes live)
+
+**Files**
+- `client/src/pages/Catalog.jsx` **NEW** (`/catalog`) — list page modeled on `client/src/pages/Universes.jsx`:
+ - Header + "Ingest" button (links to `/catalog/ingest`)
+ - Type chip filter row (Character / Place / Object / Idea / Scene / Concept / All)
+ - Debounced search bar (calls `/api/catalog/ingredients?q=`)
+ - Card grid: name, type badge, tags, snippet, "appears in N records" back-reference count
+ - Scrolling layout (NOT in `isFullWidth` — it's a list page per CLAUDE.md convention)
+- `client/src/pages/CatalogIngest.jsx` **NEW** (`/catalog/ingest`) — modeled on `client/src/pages/Importer.jsx`:
+ - Large textarea + optional title field
+ - "Ingest" button → POST `/api/catalog/scraps`
+ - Subscribe to `catalog:extract:progress` socket frames during extraction
+ - Review screen: checkbox per drafted ingredient, inline-editable fields, "Commit Selected" button → POST `/api/catalog/scraps/:id/commit`
+ - Full-width route (goes in `isFullWidth` list in `Layout.jsx`)
+- `client/src/pages/CatalogIngredient.jsx` **NEW** (`/catalog/:type/:id`) — detail with:
+ - Type-specific edit form (character form reuses field shape from `client/src/pages/UniverseBuilder.jsx`'s character editor)
+ - Source scrap(s) panel — links back to the originating scraps
+ - "Appears in" panel — chips linking to universes/series/issues/works that reference this ingredient (driven by `catalog_ingredient_refs`)
+ - Full-width route
+- `client/src/components/IngredientPicker.jsx` **NEW** — reusable modal/popover. Props: `{ open, onClose, onSelect, type?, multi, excludeIds, refKind?, refId? }`. Calls `/api/catalog/ingredients?type=&q=`. Used by phase 7 picker integrations.
+- `server/lib/navManifest.js` — two new `NAV_COMMANDS` entries: `nav.create.catalog` (path `/catalog`, label "Catalog") and `nav.create.catalog-ingest` (path `/catalog/ingest`, label "Catalog Ingest"). Section `'Create'`. Aliases include `'catalog'`, `'ingredients'`, `'cast'`, `'ideas'`.
+- `client/src/components/Layout.jsx` — sidebar Create section: add "Catalog" entry, alphabetically first under Create (before Importer).
+- `client/src/App.jsx` — three new ``s. Add `/catalog/ingest` and `/catalog/:type/:id` to the `isFullWidth` list; `/catalog` stays scrolling.
+
+### Phase 7 — Picker integration into existing pages
+
+Each ships as its own PR.
+
+- `client/src/pages/UniverseBuilder.jsx` — in the Characters / Places / Objects panels, add "Pick from Catalog" button next to "Add new". On selection: PATCH the universe with the embedded entry copied from the ingredient's `payload` (carries `ingredientId`), AND POST `/api/catalog/ingredients/:id/link` with `{ refKind: 'universe', refId, role: 'canon-character'|'canon-place'|'canon-object' }`.
+- `client/src/pages/PipelineSeries.jsx` — same pattern, `refKind: 'series'`.
+- `client/src/pages/WritersRoom.jsx` — work bible panel, `refKind: 'work'`.
+
+The PATCH path on those three pages must also pass `ingredientId` through their respective sanitizers; this is already covered by the `storyBible.js` change in phase 4.
+
+### Phase 8 — Federation wire-up
+
+**Files**
+- `server/services/syncOrchestrator.js` (or `server/services/sharing/peerSync.js` — verify which orchestrates memory sync) — register catalog as a new sync category alongside memory. Pull via `catalogSync.getChangesSince`, apply via `catalogSync.applyRemoteChanges`.
+- Outbound envelopes include `portosMeta.schemaVersions.catalog = 1`.
+- `client/src/pages/SyncView.jsx` (if it exists, else the relevant sharing/sync UI) — surface a catalog row with last-sync timestamp + delta count.
+
+### Phase 9 — Story versioning around catalog refs (hybrid auto-scan + confirm)
+
+**Files**
+- `server/services/writersRoom/local.js` (or wherever draft versions are persisted) — when saving a draft version, accept `referencedIngredientIds: string[]`. Persist on the version record in the work manifest.
+- `server/services/catalogExtraction.js` (extend) — `scanProseForIngredientRefs(text, { universeId?, seriesId?, workId? })`: substring-match catalog ingredient names scoped to refs linked to the given universe/series/work for speed. Returns suggested ingredient ids; caller (UI) confirms before persisting.
+- `client/src/pages/WritersRoom.jsx` — on draft save, run scan + show suggestions; user confirms/adds/removes; persisted with the version.
+- Per-version display: a chip list of referenced ingredients on the version history panel.
+
+Defer to phase 9 explicitly — scope-out of v1 if extraction work runs long.
+
+### Phase 10 — Tests
+
+- `server/services/catalogDB.test.js` — CRUD + search round-trip. Bootstrap a test Postgres via the same pattern as `server/services/memoryDB.test.js` (verify the existing test setup at start of phase 10).
+- `server/services/catalogExtraction.test.js` — mock `aiToolkit` LLM call, assert extraction output conforms to `catalogIngredientCreateSchema`.
+- `server/lib/catalogValidation.test.js` — Zod boundary tests on each schema (max lengths, required fields, enum values).
+- `server/services/catalogSync.test.js` — envelope round-trip, LWW on `updated_at`, schema-version gate rejection.
+- `server/scripts/migrateBibleToCatalog.test.js` — idempotency (run twice → second is no-op), backfill correctness against fixture universes/series/works.
+- `server/services/embeddings.test.js` — provider routing, 768-dim validation, `'none'` skip path.
+- `client/src/pages/Catalog.test.jsx` — render + filter chips.
+- `client/src/components/IngredientPicker.test.jsx` — single + multi select behavior.
+
+## Critical files (reference map)
+
+**Reused as-is:**
+- `server/lib/db.js` — `query`, `withTransaction`, `arrayToPgvector`, `pgvectorToArray`
+- `server/lib/storyBible.js` — payload shape for char/place/object; backfill source
+- `server/services/memoryDB.js` — template for `catalogDB.js`
+- `server/services/memorySync.js` — template for `catalogSync.js`
+- `server/services/lmStudioManager.js:377` — template for `ollamaManager.getEmbeddings`
+- `server/services/importer.js` + `client/src/pages/Importer.jsx` — UX template for streaming paste-and-extract
+- `client/src/pages/Universes.jsx` + `client/src/pages/Pipeline.jsx` — list-page templates
+
+**New files:**
+- `server/services/catalogDB.js`
+- `server/services/catalogSync.js`
+- `server/services/catalogExtraction.js`
+- `server/services/embeddings.js`
+- `server/routes/catalog.js`
+- `server/lib/catalogValidation.js`
+- `server/scripts/migrateBibleToCatalog.js`
+- `client/src/pages/Catalog.jsx`
+- `client/src/pages/CatalogIngest.jsx`
+- `client/src/pages/CatalogIngredient.jsx`
+- `client/src/components/IngredientPicker.jsx`
+
+**Modified:**
+- `server/scripts/init-db.sql` (append DDL)
+- `server/lib/db.js` (extend `ensureSchema`, `checkHealth`)
+- `server/lib/schemaVersions.js` (add `catalog: 1`, `cat-ingredient` and `cat-scrap` record kinds)
+- `server/lib/storyBible.js` (preserve `ingredientId` through sanitizer)
+- `server/lib/validation.js` (`settingsEmbeddingsSchema`)
+- `server/lib/index.js` (barrel-export `catalogValidation`)
+- `server/services/ollamaManager.js` (add `getEmbeddings`)
+- `server/services/universeBuilder.js`, `server/services/pipeline/series.js`, `server/services/writersRoom/local.js` (accept `{ silent }` save flag)
+- `server/routes/settings.js` (accept embeddings slice)
+- `server/index.js` (wire `/api/catalog` route + boot-time `migrateBibleToCatalog`)
+- `server/lib/navManifest.js` (`nav.create.catalog`, `nav.create.catalog-ingest`)
+- `client/src/components/Layout.jsx` (sidebar entry)
+- `client/src/App.jsx` (routes + `isFullWidth` membership)
+- `client/src/pages/Settings.jsx` (Embeddings section)
+- `client/src/pages/UniverseBuilder.jsx`, `client/src/pages/PipelineSeries.jsx`, `client/src/pages/WritersRoom.jsx` (Pick from Catalog buttons — phase 7)
+
+## Open decisions to confirm during implementation
+
+1. **Backfill peer-sync behavior** — silent (bulk-mutate without per-record fan-out, sync flows on next normal cycle) vs. eager. **Recommend silent.** Implemented via the `{ silent: true }` save flag in phase 4.
+2. **Cross-instance ingredient ID collisions** — text-PK UUIDs make true collisions vanishingly rare, but two peers ingesting the same source text simultaneously create distinct ingredient rows. **Recommend** a post-sync dedupe pass that finds rows with embedding cosine ≥ 0.95 + same `type` + same `name` and surfaces a merge suggestion in the catalog UI. Not automatic. Out of v1 scope, list as deferred work in `PLAN.md`.
+3. **Scrap retention** — keep scraps forever as raw archive; user-initiated delete only. Documented in the Catalog page UI.
+
+## Verification
+
+1. **Boot smoke** — `npm start`. Watch logs for `✅ Database schema ensured`, `🪄 bible→catalog migration: ingredients created ( skipped)`, no boot crash.
+2. **DB shape** — `psql -h localhost -p 5561 -U portos -d portos -c '\dt catalog*'` — expect 4 tables. `\d catalog_ingredients` — confirm `embedding vector(768)`, `search_tsv` generated column, HNSW + GIN indexes present.
+3. **Embeddings settings round-trip** — Settings → Embeddings → pick Ollama + `nomic-embed-text` → save → reload → values persist.
+4. **Ingest happy path** — `/catalog/ingest` → paste a paragraph naming a character + a place → "Ingest" → progress streams → review shows extracted character + place → commit → both visible at `/catalog` filtered by type.
+5. **Cross-reference** — open `/universes/` → Characters panel → "Pick from Catalog" → select the catalog character → save → ingredient's "Appears in" panel now lists the universe.
+6. **Backfill correctness** — `psql ... -c "SELECT type, COUNT(*) FROM catalog_ingredients GROUP BY type"` — counts should roughly match sum of embedded chars/places/objects across the 15 existing universes + series + works.
+7. **Federation** — on a second instance, `GET /api/catalog/sync?since=0&limit=100` returns the envelope; `POST /api/catalog/sync/apply` on it inserts rows. Run twice — second is idempotent (LWW on `updated_at`).
+8. **Semantic search** — `/catalog?q=brooding%20detective` — returns characters whose descriptions match semantically even if "detective" isn't in the name.
+9. **Tests** — `cd server && npm test` (passes new catalog suites). `cd client && npm test` (passes new Catalog UI suites).
+10. **Mobile responsive** — verify `/catalog` list + ingest textarea + detail page all render usable on a 375px-wide viewport (per CLAUDE.md convention).
+
+## Approved-plan archival
+
+When this plan is approved (per CLAUDE.md), copy this file to `docs/plans/2026-05-29-creative-ingredients-catalog.md` as a design record before implementation begins.
diff --git a/server/index.js b/server/index.js
index 49d14b74fd..9c52d86a7e 100644
--- a/server/index.js
+++ b/server/index.js
@@ -42,6 +42,7 @@ import cosRoutes from './routes/cos.js';
import featureAgentsRoutes from './routes/featureAgents.js';
import feedsRoutes from './routes/feeds.js';
import gsdRoutes from './routes/gsd.js';
+import catalogRoutes from './routes/catalog.js';
import memoryRoutes from './routes/memory.js';
import notificationsRoutes from './routes/notifications.js';
import standardizeRoutes from './routes/standardize.js';
@@ -409,6 +410,7 @@ app.use('/api/cos/gsd', gsdRoutes);
app.use('/api/cos', cosRoutes);
app.use('/api/feature-agents', featureAgentsRoutes);
app.use('/api/feeds', feedsRoutes);
+app.use('/api/catalog', catalogRoutes);
app.use('/api/memory', memoryRoutes);
app.use('/api/notifications', notificationsRoutes);
app.use('/api/standardize', standardizeRoutes);
@@ -678,6 +680,22 @@ ensureSelf()
markRecoveryDone();
});
})
+ .then(async () => {
+ // Catalog backfill: promote universe canon (characters/places/objects)
+ // into the Postgres ingredients catalog. Idempotent — marker in
+ // data/catalog-backfill.applied.json gates the walk after the first run.
+ // Fire-and-forget so a DB hiccup doesn't block server boot — the route
+ // surface tolerates an empty catalog and the user can re-trigger via
+ // the admin endpoint.
+ try {
+ const { ensureSchema } = await import('./lib/db.js');
+ await ensureSchema();
+ const { migrateBibleToCatalog } = await import('./scripts/migrateBibleToCatalog.js');
+ await migrateBibleToCatalog();
+ } catch (err) {
+ console.error(`🪄 bible→catalog migration failed at boot: ${err.message}`);
+ }
+ })
.then(() => {
// Start server only after sync log + media job queue are initialized.
// initMediaJobQueue failure is fatal: the queue owns persistence + SSE
diff --git a/server/lib/README.md b/server/lib/README.md
index e939f95946..5e73359835 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -26,6 +26,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `validation.js` | Catch-all Zod schemas + the `validateRequest` middleware + shared helpers (`optionalBooleanMap`). Most route inputs validate through here. |
| `appleHealthValidation.js` | Apple Health import payloads. |
| `brainValidation.js` | Brain/memory route schemas (search, ingest, edit). |
+| `catalogValidation.js` | Creative ingredients catalog route schemas (scraps, ingredients, links, sync envelope). |
| `digitalTwinValidation.js` | Digital twin document/category schemas. |
| `genomeValidation.js` | Genome upload + search schemas. |
| `identityValidation.js` | Identity section + chronotype + scheduling schemas. |
diff --git a/server/lib/catalogValidation.js b/server/lib/catalogValidation.js
new file mode 100644
index 0000000000..5988fb99ef
--- /dev/null
+++ b/server/lib/catalogValidation.js
@@ -0,0 +1,202 @@
+/**
+ * Zod validation schemas for the Creative Ingredients Catalog routes.
+ *
+ * Domain-namespaced (imported as `catalogValidation.X` from the lib barrel)
+ * so cross-domain identifier collisions never bite. The route handler in
+ * server/routes/catalog.js uses `validateRequest(schema, body)` against these.
+ *
+ * Payload shapes for character/place/object intentionally accept the
+ * server/lib/storyBible.js field set verbatim — backfilled and freshly
+ * ingested records produce identical rows on the wire.
+ */
+
+import { z } from 'zod';
+import { BIBLE_LIMITS } from './storyBible.js';
+
+export const INGREDIENT_TYPES = Object.freeze([
+ 'character',
+ 'place',
+ 'object',
+ 'idea',
+ 'scene',
+ 'concept',
+]);
+
+export const REF_KINDS = Object.freeze([
+ 'universe',
+ 'series',
+ 'issue',
+ 'work',
+ 'creative-director',
+]);
+
+const tag = z.string().trim().min(1).max(BIBLE_LIMITS.TAG_MAX);
+const tags = z.array(tag).max(BIBLE_LIMITS.TAGS_PER_ENTRY_MAX).optional();
+
+// `payload` is a JSONB blob — the route accepts arbitrary content because the
+// six ingredient types have very different shapes. We cap the round-tripped
+// size at the JSON.stringify length to keep a single user accident from
+// landing a 50-MB blob in the catalog. The extraction service is responsible
+// for shape correctness; the schema only enforces the boundary.
+const payload = z.record(z.string(), z.unknown())
+ .refine((p) => JSON.stringify(p).length <= 200_000, {
+ message: 'payload exceeds 200KB JSON size cap',
+ })
+ .optional();
+
+export const catalogScrapCreateSchema = z.object({
+ title: z.string().trim().max(300).optional().nullable(),
+ rawText: z.string().min(1).max(2_000_000),
+ sourceKind: z.enum(['paste', 'brain-bridge', 'importer-handoff', 'manual']).optional(),
+ metadata: payload,
+}).strict();
+
+export const catalogScrapPatchSchema = catalogScrapCreateSchema.partial();
+
+export const catalogIngredientCreateSchema = z.object({
+ type: z.enum(INGREDIENT_TYPES),
+ name: z.string().trim().min(1).max(BIBLE_LIMITS.NAME_MAX),
+ payload,
+ tags,
+}).strict();
+
+export const catalogIngredientPatchSchema = z.object({
+ name: z.string().trim().min(1).max(BIBLE_LIMITS.NAME_MAX).optional(),
+ payload,
+ tags,
+}).strict();
+
+export const catalogIngredientQuerySchema = z.object({
+ type: z.enum(INGREDIENT_TYPES).optional(),
+ tag: tag.optional(),
+ q: z.string().trim().max(500).optional(),
+ limit: z.coerce.number().int().min(1).max(200).optional(),
+ offset: z.coerce.number().int().min(0).optional(),
+}).strict();
+
+export const catalogIngredientLinkSchema = z.object({
+ refKind: z.enum(REF_KINDS),
+ refId: z.string().trim().min(1).max(120),
+ role: z.string().trim().min(1).max(64),
+}).strict();
+
+export const catalogScrapCommitSchema = z.object({
+ accepted: z.array(catalogIngredientCreateSchema.extend({
+ // Optional source-span hint (server forwards as-is to linkIngredientToSource).
+ span: z.record(z.string(), z.unknown()).optional(),
+ })).min(0).max(200),
+}).strict();
+
+// /scraps/:id/extract — optional provider override (e.g., force a specific
+// LLM provider for this extraction). Empty body is valid.
+export const catalogExtractRequestSchema = z.object({
+ providerOverride: z.string().trim().min(1).max(64).optional(),
+}).strict();
+
+// /embeddings/backfill — re-embed up to `limit` rows. By default only fills
+// rows where embedding IS NULL; pass `includeStale: true` to also re-embed
+// rows whose stored `embedding_model` differs from the current settings
+// model (used after a provider/model switch to refresh the vector space).
+export const catalogEmbeddingsBackfillSchema = z.object({
+ limit: z.coerce.number().int().min(1).max(200).optional(),
+ includeStale: z.boolean().optional(),
+}).strict();
+
+// /migration/rerun — pass `force: true` to ignore the marker file.
+export const catalogMigrationRerunSchema = z.object({
+ force: z.boolean().optional(),
+}).strict();
+
+// Sync envelope shape — used by POST /api/catalog/sync/apply when a peer
+// forwards changes pulled from another instance. Each kind is optional so
+// callers can apply a partial envelope (e.g. ingredients-only).
+//
+// Read-path caps MIRROR the create-path caps above: rawText 2MB, payload
+// 200KB JSON-stringified, name ≤ NAME_MAX, tags count/length. The create
+// path can't be the only line of defense — a peer running an older / forked
+// PortOS that skipped its own validation could otherwise push an unbounded
+// blob through here. The receiver enforces its own size contract.
+const isoDate = z.string().min(1);
+const syncEmbedding = z.array(z.number()).max(4096).optional().nullable();
+const syncPayload = z.unknown().optional().refine(
+ (p) => p === undefined || p === null || (typeof p === 'object' && JSON.stringify(p).length <= 200_000),
+ { message: 'payload exceeds 200KB JSON size cap' },
+);
+const syncMetadata = syncPayload;
+const syncTags = z.array(z.string().max(BIBLE_LIMITS.TAG_MAX))
+ .max(BIBLE_LIMITS.TAGS_PER_ENTRY_MAX)
+ .optional();
+
+export const catalogSyncScrapSchema = z.object({
+ id: z.string().min(1).max(80),
+ title: z.string().max(300).nullable().optional(),
+ rawText: z.string().max(2_000_000),
+ sourceKind: z.string().max(32).optional(),
+ metadata: syncMetadata,
+ embedding: syncEmbedding,
+ embeddingModel: z.string().max(100).nullable().optional(),
+ originInstanceId: z.string().max(64).nullable().optional(),
+ createdAt: isoDate,
+ updatedAt: isoDate,
+ deleted: z.boolean().optional(),
+ deletedAt: z.string().nullable().optional(),
+ syncSequence: z.string().optional(),
+}).passthrough();
+
+export const catalogSyncIngredientSchema = z.object({
+ id: z.string().min(1).max(80),
+ type: z.enum(INGREDIENT_TYPES),
+ name: z.string().max(BIBLE_LIMITS.NAME_MAX),
+ payload: syncPayload,
+ tags: syncTags,
+ embedding: syncEmbedding,
+ embeddingModel: z.string().max(100).nullable().optional(),
+ originInstanceId: z.string().max(64).nullable().optional(),
+ createdAt: isoDate,
+ updatedAt: isoDate,
+ deleted: z.boolean().optional(),
+ deletedAt: z.string().nullable().optional(),
+ syncSequence: z.string().optional(),
+}).passthrough();
+
+export const catalogSyncSourceSchema = z.object({
+ ingredientId: z.string().max(80),
+ scrapId: z.string().max(80),
+ // span shape isn't strictly typed (`{ start, end }` today, may grow); cap
+ // its JSON size so a peer can't push a 50MB "span" blob.
+ span: z.unknown().optional().refine(
+ (p) => p === undefined || p === null || JSON.stringify(p).length <= 10_000,
+ { message: 'span exceeds 10KB JSON size cap' },
+ ),
+ extractedAt: isoDate,
+ syncSequence: z.string().optional(),
+}).passthrough();
+
+export const catalogSyncRefSchema = z.object({
+ ingredientId: z.string().max(80),
+ refKind: z.string().max(32),
+ refId: z.string().max(120),
+ role: z.string().max(64),
+ createdAt: isoDate,
+ syncSequence: z.string().optional(),
+}).passthrough();
+
+// Receiver may receive `portosMeta.schemaVersions.catalog` for the version
+// gate; the rest of portosMeta is informational. We accept arbitrary keys
+// inside portosMeta with passthrough but cap its size at 4KB to deny a peer
+// stuffing junk through the metadata escape hatch.
+const portosMeta = z.object({
+ portosVersion: z.string().max(64).optional(),
+ schemaVersions: z.record(z.string(), z.number().int()).optional(),
+}).passthrough().refine(
+ (m) => JSON.stringify(m).length <= 4_000,
+ { message: 'portosMeta exceeds 4KB size cap' },
+).optional();
+
+export const catalogSyncEnvelopeSchema = z.object({
+ scraps: z.array(catalogSyncScrapSchema).max(5_000).optional(),
+ ingredients: z.array(catalogSyncIngredientSchema).max(5_000).optional(),
+ sources: z.array(catalogSyncSourceSchema).max(20_000).optional(),
+ refs: z.array(catalogSyncRefSchema).max(20_000).optional(),
+ portosMeta,
+}).passthrough();
diff --git a/server/lib/db.js b/server/lib/db.js
index c582388e9e..62b4023a1e 100644
--- a/server/lib/db.js
+++ b/server/lib/db.js
@@ -71,13 +71,19 @@ export async function checkHealth() {
SELECT
EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'memories') AS has_memories,
EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'memory_links') AS has_links,
- EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name = 'memories' AND column_name = 'sync_sequence') AS has_sync
+ EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name = 'memories' AND column_name = 'sync_sequence') AS has_sync,
+ EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'catalog_ingredients') AS has_catalog,
+ EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'catalog_scraps') AS has_catalog_scraps
`);
- const { has_memories, has_links, has_sync } = result.rows?.[0] ?? {};
- return { connected: true, hasSchema: has_memories && has_links && has_sync };
+ const { has_memories, has_links, has_sync, has_catalog, has_catalog_scraps } = result.rows?.[0] ?? {};
+ return {
+ connected: true,
+ hasSchema: has_memories && has_links && has_sync,
+ hasCatalogSchema: has_catalog && has_catalog_scraps,
+ };
} catch (err) {
console.error(`🗄️ Database health check failed: ${err.message}`);
- return { connected: false, hasSchema: false, error: err.message };
+ return { connected: false, hasSchema: false, hasCatalogSchema: false, error: err.message };
}
}
@@ -96,6 +102,180 @@ export async function ensureSchema() {
for (const sql of upgrades) {
await pool.query(sql);
}
+
+ // Catalog block: every statement below is idempotent (CREATE IF NOT EXISTS
+ // / CREATE OR REPLACE FUNCTION / DROP TRIGGER IF EXISTS + CREATE TRIGGER),
+ // so we run the whole list on every boot rather than gating on table
+ // presence. A previous probe that early-returned on "all four tables exist"
+ // would skip the indexes / functions / triggers if the prior boot crashed
+ // between the table CREATEs and the artifact CREATEs — leaving the schema
+ // marked ready while update triggers and HNSW indexes were never installed.
+ // Cost on a fully-applied install is ~30 Postgres no-op parses (<10ms).
+
+ const catalogDDL = [
+ `CREATE TABLE IF NOT EXISTS catalog_scraps (
+ id TEXT PRIMARY KEY,
+ title TEXT,
+ raw_text TEXT NOT NULL,
+ source_kind VARCHAR(32) DEFAULT 'paste',
+ metadata JSONB DEFAULT '{}'::jsonb,
+ embedding vector(768),
+ embedding_model VARCHAR(100),
+ origin_instance_id VARCHAR(36),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ deleted BOOLEAN DEFAULT FALSE,
+ deleted_at TIMESTAMPTZ,
+ sync_sequence BIGSERIAL
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_scraps_embedding
+ ON catalog_scraps USING hnsw (embedding vector_cosine_ops)
+ WITH (m = 16, ef_construction = 64)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_scraps_fts
+ ON catalog_scraps USING gin (
+ to_tsvector('english', coalesce(title, '') || ' ' || coalesce(raw_text, ''))
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_scraps_sync_seq ON catalog_scraps (sync_sequence)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_scraps_created_at ON catalog_scraps (created_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_scraps_origin_instance ON catalog_scraps (origin_instance_id)`,
+
+ `CREATE TABLE IF NOT EXISTS catalog_ingredients (
+ id TEXT PRIMARY KEY,
+ type VARCHAR(20) NOT NULL
+ CHECK (type IN ('character', 'place', 'object', 'idea', 'scene', 'concept')),
+ name TEXT NOT NULL,
+ payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+ tags TEXT[] DEFAULT '{}',
+ embedding vector(768),
+ embedding_model VARCHAR(100),
+ search_tsv tsvector GENERATED ALWAYS AS (
+ setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
+ setweight(to_tsvector('english',
+ coalesce(payload->>'description', '') || ' ' ||
+ coalesce(payload->>'notes', '') || ' ' ||
+ coalesce(payload->>'background', '') || ' ' ||
+ coalesce(payload->>'summary', '')
+ ), 'B')
+ ) STORED,
+ origin_instance_id VARCHAR(36),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ deleted BOOLEAN DEFAULT FALSE,
+ deleted_at TIMESTAMPTZ,
+ sync_sequence BIGSERIAL
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_embedding
+ ON catalog_ingredients USING hnsw (embedding vector_cosine_ops)
+ WITH (m = 16, ef_construction = 64)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_fts ON catalog_ingredients USING gin (search_tsv)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_type ON catalog_ingredients (type)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_tags ON catalog_ingredients USING gin (tags)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_sync_seq ON catalog_ingredients (sync_sequence)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_created_at ON catalog_ingredients (created_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_origin_instance ON catalog_ingredients (origin_instance_id)`,
+
+ `CREATE TABLE IF NOT EXISTS catalog_ingredient_sources (
+ ingredient_id TEXT NOT NULL REFERENCES catalog_ingredients(id) ON DELETE CASCADE,
+ scrap_id TEXT NOT NULL REFERENCES catalog_scraps(id) ON DELETE CASCADE,
+ span JSONB,
+ extracted_at TIMESTAMPTZ DEFAULT NOW(),
+ sync_sequence BIGSERIAL,
+ PRIMARY KEY (ingredient_id, scrap_id)
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_sources_scrap ON catalog_ingredient_sources (scrap_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_sources_sync_seq ON catalog_ingredient_sources (sync_sequence)`,
+
+ `CREATE TABLE IF NOT EXISTS catalog_ingredient_refs (
+ ingredient_id TEXT NOT NULL REFERENCES catalog_ingredients(id) ON DELETE CASCADE,
+ ref_kind VARCHAR(32) NOT NULL,
+ ref_id TEXT NOT NULL,
+ role VARCHAR(64) NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ sync_sequence BIGSERIAL,
+ PRIMARY KEY (ingredient_id, ref_kind, ref_id, role)
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_refs_target ON catalog_ingredient_refs (ref_kind, ref_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_catalog_ing_refs_sync_seq ON catalog_ingredient_refs (sync_sequence)`,
+
+ `CREATE OR REPLACE FUNCTION update_catalog_ingredient_timestamp()
+ RETURNS TRIGGER AS $$
+ DECLARE
+ content_changed BOOLEAN;
+ BEGIN
+ content_changed := (
+ NEW.type IS DISTINCT FROM OLD.type OR
+ NEW.name IS DISTINCT FROM OLD.name OR
+ NEW.payload IS DISTINCT FROM OLD.payload OR
+ NEW.tags IS DISTINCT FROM OLD.tags OR
+ NEW.embedding IS DISTINCT FROM OLD.embedding OR
+ NEW.embedding_model IS DISTINCT FROM OLD.embedding_model OR
+ NEW.deleted IS DISTINCT FROM OLD.deleted OR
+ NEW.updated_at IS DISTINCT FROM OLD.updated_at
+ );
+ IF NOT content_changed THEN RETURN NEW; END IF;
+ IF NEW.updated_at IS NULL OR NEW.updated_at = OLD.updated_at THEN
+ NEW.updated_at := NOW();
+ END IF;
+ NEW.sync_sequence := nextval(pg_get_serial_sequence('catalog_ingredients', 'sync_sequence'));
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql`,
+ `DROP TRIGGER IF EXISTS trg_catalog_ingredient_updated_at ON catalog_ingredients`,
+ `CREATE TRIGGER trg_catalog_ingredient_updated_at
+ BEFORE UPDATE ON catalog_ingredients
+ FOR EACH ROW
+ EXECUTE FUNCTION update_catalog_ingredient_timestamp()`,
+
+ `CREATE OR REPLACE FUNCTION update_catalog_scrap_timestamp()
+ RETURNS TRIGGER AS $$
+ DECLARE
+ content_changed BOOLEAN;
+ BEGIN
+ content_changed := (
+ NEW.title IS DISTINCT FROM OLD.title OR
+ NEW.raw_text IS DISTINCT FROM OLD.raw_text OR
+ NEW.source_kind IS DISTINCT FROM OLD.source_kind OR
+ NEW.metadata IS DISTINCT FROM OLD.metadata OR
+ NEW.embedding IS DISTINCT FROM OLD.embedding OR
+ NEW.embedding_model IS DISTINCT FROM OLD.embedding_model OR
+ NEW.deleted IS DISTINCT FROM OLD.deleted OR
+ NEW.updated_at IS DISTINCT FROM OLD.updated_at
+ );
+ IF NOT content_changed THEN RETURN NEW; END IF;
+ IF NEW.updated_at IS NULL OR NEW.updated_at = OLD.updated_at THEN
+ NEW.updated_at := NOW();
+ END IF;
+ NEW.sync_sequence := nextval(pg_get_serial_sequence('catalog_scraps', 'sync_sequence'));
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql`,
+ `DROP TRIGGER IF EXISTS trg_catalog_scrap_updated_at ON catalog_scraps`,
+ `CREATE TRIGGER trg_catalog_scrap_updated_at
+ BEFORE UPDATE ON catalog_scraps
+ FOR EACH ROW
+ EXECUTE FUNCTION update_catalog_scrap_timestamp()`,
+
+ // Source-link UPDATE bumps sync_sequence so a span change (via
+ // `upsertSourceFromPeer` → ON CONFLICT DO UPDATE SET span = ...) doesn't
+ // stay invisible to peers (whose cursor would skip past the unchanged seq).
+ `CREATE OR REPLACE FUNCTION update_catalog_source_sync_seq()
+ RETURNS TRIGGER AS $$
+ BEGIN
+ IF NEW.span IS DISTINCT FROM OLD.span THEN
+ NEW.sync_sequence := nextval(pg_get_serial_sequence('catalog_ingredient_sources', 'sync_sequence'));
+ END IF;
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql`,
+ `DROP TRIGGER IF EXISTS trg_catalog_source_sync_seq ON catalog_ingredient_sources`,
+ `CREATE TRIGGER trg_catalog_source_sync_seq
+ BEFORE UPDATE ON catalog_ingredient_sources
+ FOR EACH ROW
+ EXECUTE FUNCTION update_catalog_source_sync_seq()`,
+ ];
+ for (const sql of catalogDDL) {
+ await pool.query(sql);
+ }
console.log('🗄️ Database schema upgrades applied');
}
diff --git a/server/lib/index.js b/server/lib/index.js
index 399f8d56da..b7b7ebe947 100644
--- a/server/lib/index.js
+++ b/server/lib/index.js
@@ -18,6 +18,7 @@
// names are the canonical PortOS-wide schemas.
export * as appleHealthValidation from './appleHealthValidation.js';
export * as brainValidation from './brainValidation.js';
+export * as catalogValidation from './catalogValidation.js';
export * as digitalTwinValidation from './digitalTwinValidation.js';
export * as genomeValidation from './genomeValidation.js';
export * as identityValidation from './identityValidation.js';
diff --git a/server/lib/navManifest.js b/server/lib/navManifest.js
index 23e649e411..e849e3d657 100644
--- a/server/lib/navManifest.js
+++ b/server/lib/navManifest.js
@@ -10,6 +10,8 @@ export const NAV_COMMANDS = [
{ id: 'nav.cybercity.settings', path: '/city/settings', label: 'CyberCity Settings', section: 'Main', aliases: ['city settings', 'cybercity settings', 'city-settings', 'cybercity-config'], keywords: ['cybercity', 'settings', '3d', 'configure'] },
{ id: 'nav.apps', path: '/apps', label: 'Apps', section: 'Main', aliases: ['apps'] },
+ { id: 'nav.catalog', path: '/catalog', label: 'Catalog', section: 'Create', aliases: ['catalog', 'ingredients', 'cast', 'creative-catalog'], keywords: ['character', 'place', 'object', 'idea', 'scene', 'concept', 'inventory', 'reference', 'creative'] },
+ { id: 'nav.catalog.ingest', path: '/catalog/ingest', label: 'Catalog Ingest', section: 'Create', aliases: ['catalog-ingest', 'ingest', 'paste-scrap', 'extract-ingredients'], keywords: ['paste', 'snippet', 'scene', 'idea', 'extract', 'scrap', 'import-catalog'] },
{ id: 'nav.media', path: '/media/image', label: 'Media Gen', section: 'Create', aliases: ['media', 'media-gen', 'mediagen', 'generate'], keywords: ['image', 'video', 'render', 'art', 'movie'] },
{ id: 'nav.media.image', path: '/media/image', label: 'Image', section: 'Create', aliases: ['image-gen', 'imagegen', 'generate-image', 'sd', 'stable-diffusion'], keywords: ['stable diffusion', 'render', 'art', 'picture', 'photo', 'draw', 'flux', 'mflux'] },
{ id: 'nav.media.video', path: '/media/video', label: 'Video', section: 'Create', aliases: ['video-gen', 'videogen', 'generate-video', 'ltx'], keywords: ['video', 'animate', 'movie', 'clip', 'ltx'] },
@@ -131,6 +133,7 @@ export const NAV_COMMANDS = [
{ id: 'nav.settings.autofixer', path: '/settings/autofixer', label: 'Autofixer', section: 'Settings', aliases: ['autofixer', 'settings-autofixer', 'auto-fixer'], keywords: ['crash', 'fix', 'pm2', 'repair', 'ai provider', 'restart'] },
{ id: 'nav.settings.backup', path: '/settings/backup', label: 'Backup', section: 'Settings', aliases: ['backup', 'settings-backup'] },
{ id: 'nav.settings.database', path: '/settings/database', label: 'Database', section: 'Settings', aliases: ['settings-database', 'database'] },
+ { id: 'nav.settings.embeddings', path: '/settings/embeddings', label: 'Embeddings', section: 'Settings', aliases: ['settings-embeddings', 'embeddings', 'embedding'], keywords: ['vector', 'pgvector', 'semantic search', 'nomic', 'ollama', 'lm studio'] },
{ id: 'nav.settings.general', path: '/settings/general', label: 'General', section: 'Settings', aliases: ['settings', 'settings-general', 'general'] },
{ id: 'nav.settings.local-llm', path: '/settings/local-llm', label: 'Local LLMs', section: 'Settings', aliases: ['local-llm', 'local-llms', 'ollama', 'lm-studio', 'lmstudio'], keywords: ['ollama', 'lm studio', 'local model', 'local llm', 'gguf', 'pull model', 'install model', 'migrate', 'switch backend'] },
{ id: 'nav.settings.mortalloom', path: '/settings/mortalloom', label: 'MortalLoom', section: 'Settings', aliases: ['settings-mortalloom', 'mortalloom'] },
diff --git a/server/lib/schemaVersions.js b/server/lib/schemaVersions.js
index 604491cf61..6f4c0bd1e4 100644
--- a/server/lib/schemaVersions.js
+++ b/server/lib/schemaVersions.js
@@ -47,6 +47,12 @@ export const PORTOS_SCHEMA_VERSIONS = Object.freeze({
// pauses with old peers; issues/universes keep flowing.
pipelineSeries: 2,
mediaCollections: 1,
+ // v1 = creative ingredients catalog (Postgres tables: catalog_scraps,
+ // catalog_ingredients, catalog_ingredient_sources, catalog_ingredient_refs).
+ // Per-category gate so a new peer can sync its catalog independently of
+ // whether other categories are version-locked. `cat-ingredient` and
+ // `cat-scrap` record kinds map back here via RECORD_KIND_SCHEMA_CATEGORIES.
+ catalog: 1,
// NOTE: `videoHistory` is intentionally NOT listed here. The version gate
// rejects the ENTIRE snapshot/push payload on ANY ahead-mismatch (the
// comparator walks the union of keys), so declaring a brand-new key would
@@ -83,6 +89,8 @@ export const RECORD_KIND_SCHEMA_CATEGORIES = Object.freeze({
series: Object.freeze(['pipelineSeries']),
issue: Object.freeze(['pipelineIssues']),
mediaCollection: Object.freeze(['mediaCollections']),
+ 'cat-ingredient': Object.freeze(['catalog']),
+ 'cat-scrap': Object.freeze(['catalog']),
});
/**
diff --git a/server/lib/storyBible.js b/server/lib/storyBible.js
index f0a481fdeb..9a472ea9a9 100644
--- a/server/lib/storyBible.js
+++ b/server/lib/storyBible.js
@@ -98,6 +98,11 @@ export const BIBLE_LIMITS = Object.freeze({
TAG_MAX: 60,
TAGS_PER_ENTRY_MAX: 12,
SOURCE_SERIES_ID_MAX: 64,
+ // Catalog backlink: when an embedded bible entry is promoted to the
+ // creative-ingredients catalog (server/services/catalogDB.js), this carries
+ // the catalog row id so edits stay synchronized. Cap matches the catalog's
+ // own id format ('cat--') — generous so future id schemes fit.
+ INGREDIENT_ID_MAX: 64,
// Voice id namespace: `engine:voiceName` (e.g. `kokoro:af_heart`,
// `piper:en_GB-northern_english_male`). Caps generously since 3rd-party
// providers (ElevenLabs) use uuid-shaped voice ids.
@@ -613,6 +618,11 @@ function applyCanonExtras(raw) {
tags: cleanStringArray(raw.tags, BIBLE_LIMITS.TAG_MAX, BIBLE_LIMITS.TAGS_PER_ENTRY_MAX),
source: ensureSource(raw.source),
sourceSeriesId: trimTo(raw.sourceSeriesId, BIBLE_LIMITS.SOURCE_SERIES_ID_MAX) || null,
+ // Catalog backlink — populated when this entry is promoted to or sourced
+ // from the creative ingredients catalog. `null` keeps the field present on
+ // every entry so the round-trip never strips it on a not-yet-promoted
+ // record. See migrateBibleToCatalog.js for the backfill path.
+ ingredientId: trimTo(raw.ingredientId, BIBLE_LIMITS.INGREDIENT_ID_MAX) || null,
};
if (raw.locked === true) out.locked = true;
else if (raw.locked === false) out.locked = false;
diff --git a/server/lib/storyBible.test.js b/server/lib/storyBible.test.js
index 2681499c55..4fc0a09700 100644
--- a/server/lib/storyBible.test.js
+++ b/server/lib/storyBible.test.js
@@ -132,6 +132,30 @@ describe('storyBible — sanitizeCharacter', () => {
expect(sanitizeCharacter({ name: 'A' }).locked).toBeUndefined();
});
+ it('round-trips ingredientId through applyCanonExtras (set / unset / over-cap / non-string)', () => {
+ // The catalog backfill stamps the catalog row id back onto the embedded
+ // canon entry; the sanitizer must preserve a valid string, trim to cap,
+ // and drop non-string / missing.
+ const set = sanitizeCharacter({ name: 'A', ingredientId: 'cat-chr-bible-abcd1234' });
+ expect(set.ingredientId).toBe('cat-chr-bible-abcd1234');
+
+ const unset = sanitizeCharacter({ name: 'A' });
+ expect(unset.ingredientId).toBeNull();
+
+ const long = 'cat-chr-bible-' + 'a'.repeat(BIBLE_LIMITS.INGREDIENT_ID_MAX + 32);
+ const trimmed = sanitizeCharacter({ name: 'A', ingredientId: long });
+ expect(trimmed.ingredientId.length).toBe(BIBLE_LIMITS.INGREDIENT_ID_MAX);
+
+ // Non-string falls back to null — the sanitizer treats anything outside
+ // the contract as "no value" rather than coercing.
+ expect(sanitizeCharacter({ name: 'A', ingredientId: 12345 }).ingredientId).toBeNull();
+ expect(sanitizeCharacter({ name: 'A', ingredientId: { id: 'x' } }).ingredientId).toBeNull();
+
+ // Symmetry: places + objects carry the same field through the same helper.
+ expect(sanitizePlace({ name: 'P', ingredientId: 'cat-plc-bible-feed' }).ingredientId)
+ .toBe('cat-plc-bible-feed');
+ });
+
it('caps tags + prompt + sourceSeriesId at their limits', () => {
const longPrompt = 'p'.repeat(BIBLE_LIMITS.PROMPT_MAX + 50);
const tooManyTags = Array.from({ length: BIBLE_LIMITS.TAGS_PER_ENTRY_MAX + 5 }, (_, i) => `tag-${i}`);
@@ -243,7 +267,6 @@ describe('storyBible — sanitizeCharacter', () => {
it('caps the list at BIBLE_LIMITS.WARDROBES_PER_CHARACTER_MAX', () => {
const tooMany = Array.from({ length: BIBLE_LIMITS.WARDROBES_PER_CHARACTER_MAX + 5 }, (_, i) => ({
-tryReadFile: vi.fn().mockResolvedValue(null),
name: `Outfit ${i}`,
}));
const out = sanitizeCharacter({ name: 'A', wardrobes: tooMany });
diff --git a/server/lib/validation.js b/server/lib/validation.js
index b36d8b18bc..78218e0355 100644
--- a/server/lib/validation.js
+++ b/server/lib/validation.js
@@ -1335,6 +1335,15 @@ export const locationSettingsSchema = z.object({
{ message: 'Provide both lat and lon, or neither.' },
);
+// Provider-agnostic embeddings settings. `provider: 'none'` is the default and
+// makes embedText() a no-op — rows persist without an embedding and a future
+// admin "Re-embed missing" action backfills. Model is optional so the user can
+// pick provider first and choose a model from the live list in the UI.
+export const settingsEmbeddingsSchema = z.object({
+ provider: z.enum(['ollama', 'lmstudio', 'none']),
+ model: z.string().trim().max(200).optional().nullable(),
+}).strict();
+
// Subscription creation: persistent (bucket, record) tuple. Series + universe
// are the subscribable kinds (records that change over time and benefit from
// auto-re-export). Media is one-shot via /buckets/:id/export.
diff --git a/server/routes/catalog.js b/server/routes/catalog.js
new file mode 100644
index 0000000000..da63d6a7ff
--- /dev/null
+++ b/server/routes/catalog.js
@@ -0,0 +1,303 @@
+// Creative Ingredients Catalog HTTP routes, mounted at /api/catalog. Backs
+// the Catalog page, Ingest workflow, picker integrations, and peer sync.
+
+import { Router } from 'express';
+import * as catalogDB from '../services/catalogDB.js';
+import * as catalogSync from '../services/catalogSync.js';
+import { asyncHandler, ServerError } from '../lib/errorHandler.js';
+import { validateRequest } from '../lib/validation.js';
+import {
+ catalogScrapCreateSchema,
+ catalogScrapPatchSchema,
+ catalogIngredientCreateSchema,
+ catalogIngredientPatchSchema,
+ catalogIngredientLinkSchema,
+ catalogIngredientQuerySchema,
+ catalogScrapCommitSchema,
+ catalogSyncEnvelopeSchema,
+ catalogExtractRequestSchema,
+ catalogEmbeddingsBackfillSchema,
+ catalogMigrationRerunSchema,
+} from '../lib/catalogValidation.js';
+import { embedText, embedIngredient, embedBatch, ingredientEmbedSeed } from '../services/embeddings.js';
+import { extractIngredients } from '../services/catalogExtraction.js';
+import { migrateBibleToCatalog } from '../scripts/migrateBibleToCatalog.js';
+import { PORTOS_SCHEMA_VERSIONS } from '../lib/schemaVersions.js';
+
+const router = Router();
+
+router.get('/stats', asyncHandler(async (req, res) => {
+ res.json(await catalogDB.getCatalogStats());
+}));
+
+router.get('/scraps', asyncHandler(async (req, res) => {
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
+ const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
+ res.json(await catalogDB.listScraps({ limit, offset }));
+}));
+
+router.get('/scraps/:id', asyncHandler(async (req, res) => {
+ const scrap = await catalogDB.getScrap(req.params.id);
+ if (!scrap) throw new ServerError('Scrap not found', { status: 404 });
+ const sources = await catalogDB.listSourcesForScrap(scrap.id);
+ res.json({ ...scrap, sources });
+}));
+
+router.post('/scraps', asyncHandler(async (req, res) => {
+ validateRequest(catalogScrapCreateSchema, req.body);
+ const embedded = await embedText(req.body.rawText).catch(() => null);
+ const scrap = await catalogDB.createScrap({
+ title: req.body.title,
+ rawText: req.body.rawText,
+ sourceKind: req.body.sourceKind,
+ metadata: req.body.metadata,
+ embedding: embedded?.success ? embedded.embedding : null,
+ embeddingModel: embedded?.success ? embedded.model : null,
+ });
+ res.status(201).json({ scrap });
+}));
+
+router.patch('/scraps/:id', asyncHandler(async (req, res) => {
+ validateRequest(catalogScrapPatchSchema, req.body);
+ const updated = await catalogDB.updateScrap(req.params.id, req.body);
+ if (!updated) throw new ServerError('Scrap not found', { status: 404 });
+ res.json(updated);
+}));
+
+router.delete('/scraps/:id', asyncHandler(async (req, res) => {
+ await catalogDB.deleteScrap(req.params.id);
+ res.status(204).end();
+}));
+
+router.post('/scraps/:id/extract', asyncHandler(async (req, res) => {
+ validateRequest(catalogExtractRequestSchema, req.body || {});
+ const scrap = await catalogDB.getScrap(req.params.id);
+ if (!scrap) throw new ServerError('Scrap not found', { status: 404 });
+ const draft = await extractIngredients({
+ rawText: scrap.rawText,
+ scrapId: scrap.id,
+ providerOverride: req.body?.providerOverride,
+ });
+ res.json({ scrap, draft });
+}));
+
+router.post('/scraps/:id/commit', asyncHandler(async (req, res) => {
+ validateRequest(catalogScrapCommitSchema, req.body);
+ const scrap = await catalogDB.getScrap(req.params.id);
+ if (!scrap) throw new ServerError('Scrap not found', { status: 404 });
+
+ // Embed all drafts in parallel (concurrency-4 inside embedBatch) before
+ // sequentially writing — LLM round-trips dominate, DB inserts don't.
+ const seeds = req.body.accepted.map((d) => ingredientEmbedSeed(d));
+ const embeds = await embedBatch(seeds);
+
+ const created = [];
+ for (let i = 0; i < req.body.accepted.length; i++) {
+ const draft = req.body.accepted[i];
+ const e = embeds[i];
+ const ing = await catalogDB.createIngredient({
+ type: draft.type,
+ name: draft.name,
+ payload: draft.payload || {},
+ tags: draft.tags || [],
+ embedding: e?.embedding ?? null,
+ embeddingModel: e?.model ?? null,
+ });
+ await catalogDB.linkIngredientToSource(ing.id, scrap.id, draft.span || null);
+ created.push(ing);
+ }
+
+ res.status(201).json({ scrap, ingredients: created });
+}));
+
+router.get('/ingredients', asyncHandler(async (req, res) => {
+ const params = validateRequest(catalogIngredientQuerySchema, req.query);
+ res.json(await catalogDB.listIngredients({
+ type: params.type,
+ tag: params.tag,
+ query: params.q,
+ limit: params.limit ?? 50,
+ offset: params.offset ?? 0,
+ }));
+}));
+
+router.get('/ingredients/:id', asyncHandler(async (req, res) => {
+ const ing = await catalogDB.getIngredient(req.params.id);
+ if (!ing) throw new ServerError('Ingredient not found', { status: 404 });
+ const [refs, sources] = await Promise.all([
+ catalogDB.listRefsForIngredient(req.params.id),
+ catalogDB.listSourcesForIngredient(req.params.id),
+ ]);
+ // Detail UI doesn't render the 768-float embedding (~6KB stringified).
+ // Strip unless `?includeEmbedding=true` — sync/export consumers can opt in.
+ const includeEmbedding = req.query.includeEmbedding === 'true';
+ const { embedding, ...rest } = ing;
+ res.json({
+ ...(includeEmbedding ? { ...rest, embedding } : rest),
+ refs,
+ sources,
+ });
+}));
+
+router.post('/ingredients', asyncHandler(async (req, res) => {
+ validateRequest(catalogIngredientCreateSchema, req.body);
+ const ing = await catalogDB.createIngredient({
+ type: req.body.type,
+ name: req.body.name,
+ payload: req.body.payload || {},
+ tags: req.body.tags || [],
+ ...(await embedIngredient(req.body)),
+ });
+ res.status(201).json(ing);
+}));
+
+router.patch('/ingredients/:id', asyncHandler(async (req, res) => {
+ validateRequest(catalogIngredientPatchSchema, req.body);
+ // Re-embed only when name or payload changes — tag-only edits skip embed.
+ let embeddingPatch = {};
+ if (req.body.name !== undefined || req.body.payload !== undefined) {
+ const current = await catalogDB.getIngredient(req.params.id);
+ if (!current) throw new ServerError('Ingredient not found', { status: 404 });
+ embeddingPatch = await embedIngredient({
+ name: req.body.name ?? current.name,
+ payload: req.body.payload ?? current.payload,
+ });
+ }
+ const updated = await catalogDB.updateIngredient(req.params.id, { ...req.body, ...embeddingPatch });
+ if (!updated) throw new ServerError('Ingredient not found', { status: 404 });
+ res.json(updated);
+}));
+
+router.delete('/ingredients/:id', asyncHandler(async (req, res) => {
+ await catalogDB.deleteIngredient(req.params.id);
+ res.status(204).end();
+}));
+
+router.post('/ingredients/:id/link', asyncHandler(async (req, res) => {
+ validateRequest(catalogIngredientLinkSchema, req.body);
+ await catalogDB.linkIngredientToRef(req.params.id, req.body.refKind, req.body.refId, req.body.role);
+ res.status(201).json({ success: true });
+}));
+
+router.delete('/ingredients/:id/link', asyncHandler(async (req, res) => {
+ validateRequest(catalogIngredientLinkSchema, req.body);
+ await catalogDB.unlinkIngredientFromRef(req.params.id, req.body.refKind, req.body.refId, req.body.role);
+ res.status(204).end();
+}));
+
+router.get('/ingredients/:id/refs', asyncHandler(async (req, res) => {
+ res.json(await catalogDB.listRefsForIngredient(req.params.id));
+}));
+
+router.get('/refs/:refKind/:refId/ingredients', asyncHandler(async (req, res) => {
+ res.json(await catalogDB.listIngredientsForRef(req.params.refKind, req.params.refId));
+}));
+
+router.get('/sync', asyncHandler(async (req, res) => {
+ // The four `sync_sequence` columns are independent — accept either
+ // `?since=N` (uniform; only meaningful on the first pull where everyone's
+ // at 0) or `?since[scraps]=A&since[ingredients]=B&...` for subsequent
+ // pulls.
+ //
+ // Express 5 defaults `query parser` to `simple` (Node's querystring),
+ // which leaves `since[scraps]=10` as a flat key `'since[scraps]': '10'`
+ // instead of nesting it. We reconstruct the per-kind object ourselves so
+ // the documented bracket protocol survives regardless of parser config —
+ // otherwise peers would silently keep pulling page 1 and loop on hasMore.
+ const sinceRaw = req.query.since;
+ let since;
+ if (sinceRaw && typeof sinceRaw === 'object' && !Array.isArray(sinceRaw)) {
+ since = sinceRaw;
+ } else {
+ const bracket = {};
+ for (const [k, v] of Object.entries(req.query)) {
+ const m = /^since\[([a-z]+)\]$/.exec(k);
+ if (!m) continue;
+ // Node's `simple` parser collapses repeated keys into an array; take
+ // the last value (HTTP convention) instead of dropping the cursor on
+ // the floor and resetting to '0'.
+ const value = Array.isArray(v) ? v[v.length - 1] : v;
+ if (typeof value === 'string') bracket[m[1]] = value;
+ }
+ if (Object.keys(bracket).length > 0) {
+ since = bracket;
+ } else {
+ // Reject arrays (`?since=1&since=2`) — coerce to '0' rather than letting
+ // them silently re-pull the whole sync log.
+ since = (typeof sinceRaw === 'string' && /^\d+$/.test(sinceRaw)) ? sinceRaw : '0';
+ }
+ }
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 1000);
+ const changes = await catalogSync.getChangesSince(since, limit);
+ res.json({
+ ...changes,
+ portosMeta: { schemaVersions: { catalog: PORTOS_SCHEMA_VERSIONS.catalog } },
+ });
+}));
+
+router.post('/sync/apply', asyncHandler(async (req, res) => {
+ validateRequest(catalogSyncEnvelopeSchema, req.body);
+ // applyRemoteChanges throws CatalogSyncVersionMismatchError (status 412)
+ // when the peer is ahead on the `catalog` schema; centralized error
+ // middleware translates `err.status` to the HTTP response.
+ const stats = await catalogSync.applyRemoteChanges(req.body);
+ res.json(stats);
+}));
+
+router.post('/embeddings/backfill', asyncHandler(async (req, res) => {
+ validateRequest(catalogEmbeddingsBackfillSchema, req.body || {});
+ const limit = Math.min(Math.max(parseInt(req.body?.limit, 10) || 50, 1), 200);
+
+ // When `includeStale` is true, also re-embed rows whose stored
+ // embedding_model differs from the current settings model — catches the
+ // "user switched provider/model and old vectors are in the wrong space"
+ // case. Resolved server-side so the client doesn't have to know the
+ // current settings.
+ let staleModel = null;
+ if (req.body?.includeStale === true) {
+ const { getEmbeddingsConfig } = await import('../services/embeddings.js');
+ const cfg = await getEmbeddingsConfig();
+ staleModel = cfg.model || null;
+ }
+
+ const { items: todo } = await catalogDB.listIngredients({
+ limit,
+ offset: 0,
+ embeddingMissing: !staleModel,
+ staleEmbeddingModel: staleModel,
+ });
+
+ const seeds = todo.map((i) => ingredientEmbedSeed(i));
+ const embeds = await embedBatch(seeds);
+
+ let embedded = 0;
+ let failed = 0;
+ for (let i = 0; i < todo.length; i++) {
+ const e = embeds[i];
+ if (e?.embedding) {
+ await catalogDB.updateIngredient(todo[i].id, {
+ embedding: e.embedding,
+ embeddingModel: e.model,
+ });
+ embedded++;
+ } else {
+ failed++;
+ }
+ }
+
+ res.json({ processed: todo.length, embedded, failed, staleModel });
+}));
+
+// Re-run the bible→catalog backfill. Idempotent by design (entries that have
+// already been promoted are skipped; embedded entries that carry a foreign
+// ingredient id are reconciled into the local catalog with that explicit id).
+// Pass `{ force: true }` to ignore the marker file when troubleshooting a
+// stuck install — without force the marker gates the walk and the endpoint
+// just reports the prior stats.
+router.post('/migration/rerun', asyncHandler(async (req, res) => {
+ validateRequest(catalogMigrationRerunSchema, req.body || {});
+ const result = await migrateBibleToCatalog({ force: req.body?.force === true });
+ res.json(result);
+}));
+
+export default router;
diff --git a/server/routes/settings.js b/server/routes/settings.js
index 207223795c..5a55ec722e 100644
--- a/server/routes/settings.js
+++ b/server/routes/settings.js
@@ -7,7 +7,7 @@ import {
CODEX_PARALLEL_DEFAULT,
} from '../services/mediaJobQueue/index.js';
import { asyncHandler } from '../lib/errorHandler.js';
-import { backupConfigSchema, sharingSettingsPatchSchema, featureProviderConfigSchema, codeReviewSettingsSchema, locationSettingsSchema, validateRequest } from '../lib/validation.js';
+import { backupConfigSchema, sharingSettingsPatchSchema, featureProviderConfigSchema, codeReviewSettingsSchema, locationSettingsSchema, settingsEmbeddingsSchema, validateRequest } from '../lib/validation.js';
const router = Router();
@@ -70,6 +70,9 @@ router.put('/', asyncHandler(async (req, res) => {
if (req.body?.location !== undefined) {
validateRequest(locationSettingsSchema, req.body.location);
}
+ if (req.body?.embeddings !== undefined) {
+ validateRequest(settingsEmbeddingsSchema.partial(), req.body.embeddings);
+ }
const merged = await updateSettings(req.body);
// The queue caches codex.parallelLimit in-process; sync it from the
// merged value so a save takes effect without a restart and without
diff --git a/server/scripts/init-db.sql b/server/scripts/init-db.sql
index 757abf66a2..c4dfcb78c3 100644
--- a/server/scripts/init-db.sql
+++ b/server/scripts/init-db.sql
@@ -112,3 +112,199 @@ CREATE TRIGGER trg_memory_updated_at
BEFORE UPDATE ON memories
FOR EACH ROW
EXECUTE FUNCTION update_memory_timestamp();
+
+-- ============================================================================
+-- Creative Ingredients Catalog
+-- ============================================================================
+-- Typed, tagged, embeddable store for creative "ingredients" (characters,
+-- places, objects, ideas, scenes, concepts) extracted from user-pasted scraps.
+-- Cross-references universes/series/issues/works via catalog_ingredient_refs.
+-- Federates via sync_sequence BIGSERIAL + LWW on updated_at (same pattern as
+-- the memories table above).
+
+-- Raw user input preserved verbatim. One scrap can spawn many ingredients.
+CREATE TABLE IF NOT EXISTS catalog_scraps (
+ id TEXT PRIMARY KEY, -- 'cat-scrap-'
+ title TEXT,
+ raw_text TEXT NOT NULL,
+ source_kind VARCHAR(32) DEFAULT 'paste', -- paste|brain-bridge|importer-handoff
+ metadata JSONB DEFAULT '{}'::jsonb,
+ embedding vector(768),
+ embedding_model VARCHAR(100),
+ origin_instance_id VARCHAR(36),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ deleted BOOLEAN DEFAULT FALSE,
+ deleted_at TIMESTAMPTZ,
+ sync_sequence BIGSERIAL
+);
+CREATE INDEX IF NOT EXISTS idx_catalog_scraps_embedding
+ ON catalog_scraps USING hnsw (embedding vector_cosine_ops)
+ WITH (m = 16, ef_construction = 64);
+CREATE INDEX IF NOT EXISTS idx_catalog_scraps_fts
+ ON catalog_scraps USING gin (
+ to_tsvector('english', coalesce(title, '') || ' ' || coalesce(raw_text, ''))
+ );
+CREATE INDEX IF NOT EXISTS idx_catalog_scraps_sync_seq ON catalog_scraps (sync_sequence);
+CREATE INDEX IF NOT EXISTS idx_catalog_scraps_created_at ON catalog_scraps (created_at);
+CREATE INDEX IF NOT EXISTS idx_catalog_scraps_origin_instance ON catalog_scraps (origin_instance_id);
+
+-- Extracted, structured ingredients. Char/place/object payloads follow the
+-- shape sanitized by server/lib/storyBible.js so backfill and fresh ingest
+-- produce identical records. Idea/scene/concept payloads are lighter shapes.
+CREATE TABLE IF NOT EXISTS catalog_ingredients (
+ id TEXT PRIMARY KEY, -- 'cat-chr-', 'cat-plc-', etc.
+ type VARCHAR(20) NOT NULL
+ CHECK (type IN ('character', 'place', 'object', 'idea', 'scene', 'concept')),
+ name TEXT NOT NULL,
+ payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+ tags TEXT[] DEFAULT '{}',
+ embedding vector(768),
+ embedding_model VARCHAR(100),
+ -- Weighted FTS column. Name carries the most weight (A); description/notes/
+ -- background fall under B. Generated/stored so the GIN index stays fresh
+ -- without trigger code.
+ search_tsv tsvector GENERATED ALWAYS AS (
+ setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
+ setweight(to_tsvector('english',
+ coalesce(payload->>'description', '') || ' ' ||
+ coalesce(payload->>'notes', '') || ' ' ||
+ coalesce(payload->>'background', '') || ' ' ||
+ coalesce(payload->>'summary', '')
+ ), 'B')
+ ) STORED,
+ origin_instance_id VARCHAR(36),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ deleted BOOLEAN DEFAULT FALSE,
+ deleted_at TIMESTAMPTZ,
+ sync_sequence BIGSERIAL
+);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_embedding
+ ON catalog_ingredients USING hnsw (embedding vector_cosine_ops)
+ WITH (m = 16, ef_construction = 64);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_fts ON catalog_ingredients USING gin (search_tsv);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_type ON catalog_ingredients (type);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_tags ON catalog_ingredients USING gin (tags);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_sync_seq ON catalog_ingredients (sync_sequence);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_created_at ON catalog_ingredients (created_at);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_origin_instance ON catalog_ingredients (origin_instance_id);
+
+-- Provenance: which scrap(s) an ingredient was extracted from.
+-- A single ingredient may be reinforced by multiple scraps over time.
+CREATE TABLE IF NOT EXISTS catalog_ingredient_sources (
+ ingredient_id TEXT NOT NULL REFERENCES catalog_ingredients(id) ON DELETE CASCADE,
+ scrap_id TEXT NOT NULL REFERENCES catalog_scraps(id) ON DELETE CASCADE,
+ span JSONB, -- optional { start, end } char range in raw_text
+ extracted_at TIMESTAMPTZ DEFAULT NOW(),
+ sync_sequence BIGSERIAL,
+ PRIMARY KEY (ingredient_id, scrap_id)
+);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_sources_scrap ON catalog_ingredient_sources (scrap_id);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_sources_sync_seq ON catalog_ingredient_sources (sync_sequence);
+
+-- Consumption: which universe/series/issue/work/etc references this ingredient.
+-- Drives the "Appears in" panel on the ingredient detail page and the
+-- back-reference count on the catalog list.
+CREATE TABLE IF NOT EXISTS catalog_ingredient_refs (
+ ingredient_id TEXT NOT NULL REFERENCES catalog_ingredients(id) ON DELETE CASCADE,
+ ref_kind VARCHAR(32) NOT NULL, -- 'universe'|'series'|'issue'|'work'|'creative-director'
+ ref_id TEXT NOT NULL,
+ role VARCHAR(64) NOT NULL, -- 'canon-character'|'canon-place'|'canon-object'|'cast'|'mentioned'
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ sync_sequence BIGSERIAL,
+ PRIMARY KEY (ingredient_id, ref_kind, ref_id, role)
+);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_refs_target ON catalog_ingredient_refs (ref_kind, ref_id);
+CREATE INDEX IF NOT EXISTS idx_catalog_ing_refs_sync_seq ON catalog_ingredient_refs (sync_sequence);
+
+-- Auto-update updated_at and bump sync_sequence on content/metadata changes.
+-- Mirrors update_memory_timestamp's pattern: skip the bump on no-content-change
+-- so cosmetic touches don't trigger sync. Respects explicit updated_at (used by
+-- the sync apply path to preserve the originating timestamp during LWW merges).
+CREATE OR REPLACE FUNCTION update_catalog_ingredient_timestamp()
+RETURNS TRIGGER AS $$
+DECLARE
+ content_changed BOOLEAN;
+BEGIN
+ content_changed := (
+ NEW.type IS DISTINCT FROM OLD.type OR
+ NEW.name IS DISTINCT FROM OLD.name OR
+ NEW.payload IS DISTINCT FROM OLD.payload OR
+ NEW.tags IS DISTINCT FROM OLD.tags OR
+ NEW.embedding IS DISTINCT FROM OLD.embedding OR
+ NEW.embedding_model IS DISTINCT FROM OLD.embedding_model OR
+ NEW.deleted IS DISTINCT FROM OLD.deleted OR
+ NEW.updated_at IS DISTINCT FROM OLD.updated_at
+ );
+
+ IF NOT content_changed THEN
+ RETURN NEW;
+ END IF;
+
+ IF NEW.updated_at IS NULL OR NEW.updated_at = OLD.updated_at THEN
+ NEW.updated_at := NOW();
+ END IF;
+ NEW.sync_sequence := nextval(pg_get_serial_sequence('catalog_ingredients', 'sync_sequence'));
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_catalog_ingredient_updated_at ON catalog_ingredients;
+CREATE TRIGGER trg_catalog_ingredient_updated_at
+ BEFORE UPDATE ON catalog_ingredients
+ FOR EACH ROW
+ EXECUTE FUNCTION update_catalog_ingredient_timestamp();
+
+CREATE OR REPLACE FUNCTION update_catalog_scrap_timestamp()
+RETURNS TRIGGER AS $$
+DECLARE
+ content_changed BOOLEAN;
+BEGIN
+ content_changed := (
+ NEW.title IS DISTINCT FROM OLD.title OR
+ NEW.raw_text IS DISTINCT FROM OLD.raw_text OR
+ NEW.source_kind IS DISTINCT FROM OLD.source_kind OR
+ NEW.metadata IS DISTINCT FROM OLD.metadata OR
+ NEW.embedding IS DISTINCT FROM OLD.embedding OR
+ NEW.embedding_model IS DISTINCT FROM OLD.embedding_model OR
+ NEW.deleted IS DISTINCT FROM OLD.deleted OR
+ NEW.updated_at IS DISTINCT FROM OLD.updated_at
+ );
+
+ IF NOT content_changed THEN
+ RETURN NEW;
+ END IF;
+
+ IF NEW.updated_at IS NULL OR NEW.updated_at = OLD.updated_at THEN
+ NEW.updated_at := NOW();
+ END IF;
+ NEW.sync_sequence := nextval(pg_get_serial_sequence('catalog_scraps', 'sync_sequence'));
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_catalog_scrap_updated_at ON catalog_scraps;
+CREATE TRIGGER trg_catalog_scrap_updated_at
+ BEFORE UPDATE ON catalog_scraps
+ FOR EACH ROW
+ EXECUTE FUNCTION update_catalog_scrap_timestamp();
+
+-- Source-link UPDATE bumps sync_sequence so a span change (via
+-- `upsertSourceFromPeer` → ON CONFLICT DO UPDATE SET span = ...) doesn't
+-- stay invisible to peers whose cursor would skip past the unchanged seq.
+CREATE OR REPLACE FUNCTION update_catalog_source_sync_seq()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.span IS DISTINCT FROM OLD.span THEN
+ NEW.sync_sequence := nextval(pg_get_serial_sequence('catalog_ingredient_sources', 'sync_sequence'));
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_catalog_source_sync_seq ON catalog_ingredient_sources;
+CREATE TRIGGER trg_catalog_source_sync_seq
+ BEFORE UPDATE ON catalog_ingredient_sources
+ FOR EACH ROW
+ EXECUTE FUNCTION update_catalog_source_sync_seq();
diff --git a/server/scripts/migrateBibleToCatalog.js b/server/scripts/migrateBibleToCatalog.js
new file mode 100644
index 0000000000..7543272ac3
--- /dev/null
+++ b/server/scripts/migrateBibleToCatalog.js
@@ -0,0 +1,248 @@
+/**
+ * Backfill embedded universe canon into the Creative Ingredients Catalog.
+ *
+ * Walks every universe, promotes each character/place/object into a catalog
+ * row, links it back to the universe via catalog_ingredient_refs, and stamps
+ * the new `ingredientId` onto the embedded entry so subsequent edits know
+ * which catalog row they own.
+ *
+ * Series + writers-room works do NOT carry embedded canon — only universes
+ * do — so this script touches universes only. (The exploration brief
+ * suggested otherwise; the actual on-disk shape is universe-only.)
+ *
+ * Idempotency: entries with an existing `ingredientId` are skipped. The
+ * migration marker lives in `data/catalog-backfill.applied.json` rather than
+ * piggy-backing on `data/migrations.applied.json`, which is a JSON array
+ * managed by the prompt-replace runner under `scripts/migrations/`.
+ *
+ * Invoked from server/index.js at boot, after `ensureSchema()`, gated by
+ * the marker file so the walk only runs once per install.
+ */
+
+import { readFile, writeFile } from 'fs/promises';
+import { join } from 'path';
+import { createHash } from 'crypto';
+import { PATHS } from '../lib/fileUtils.js';
+import { BIBLE_KINDS, BIBLE_FIELD } from '../lib/storyBible.js';
+import { listUniverses, updateUniverse } from '../services/universeBuilder.js';
+import * as catalogDB from '../services/catalogDB.js';
+
+const MARKER_VERSION = 1;
+const MARKER_FILENAME = 'catalog-backfill.applied.json';
+
+// Derived from BIBLE_KINDS so a future kind added there flows through here
+// without a manual update. `kind` is the catalog ingredient `type` (1:1 with
+// BIBLE_KIND values today: character/place/object).
+const KINDS = BIBLE_KINDS.map((kind) => ({
+ kind,
+ array: BIBLE_FIELD[kind],
+ role: `canon-${kind}`,
+}));
+
+const TYPE_PREFIX = { character: 'chr', place: 'plc', object: 'obj' };
+
+/**
+ * Deterministic ingredient id derived from (universeId, kind, entry.id).
+ *
+ * Two peers running this migration independently against the SAME universe
+ * (same `entry.id`) compute the SAME catalog id, so the cross-peer LWW merge
+ * on the universe payload doesn't orphan one side's catalog row. Random
+ * UUIDs here would mint divergent ids on each peer and leave whichever lost
+ * the merge with a dangling catalog row.
+ *
+ * The `bible:` prefix tags the seed so a future deterministic-id source
+ * (e.g. content-hash based) won't collide.
+ */
+function deterministicIngredientId(universeId, kind, entryId) {
+ const prefix = TYPE_PREFIX[kind];
+ if (!prefix) throw new Error(`deterministicIngredientId: unknown kind ${kind}`);
+ const seed = `bible:universe:${universeId}:${kind}:${entryId}`;
+ // 64-bit hex slice is plenty — collision odds at our scale are nil and the
+ // id stays short enough to read in URLs and logs.
+ const hash = createHash('sha256').update(seed).digest('hex').slice(0, 32);
+ return `cat-${prefix}-bible-${hash}`;
+}
+
+async function readMarker() {
+ const path = join(PATHS.data, MARKER_FILENAME);
+ const raw = await readFile(path, 'utf-8').catch(() => null);
+ if (!raw) return null;
+ try { return JSON.parse(raw); } catch { return null; }
+}
+
+async function writeMarker(payload) {
+ const path = join(PATHS.data, MARKER_FILENAME);
+ await writeFile(path, JSON.stringify(payload, null, 2), 'utf-8');
+}
+
+/**
+ * Promote a single embedded canon entry to a catalog ingredient.
+ *
+ * The target id is DETERMINISTIC (`deterministicIngredientId`) — every peer
+ * computes the same id for the same `(universeId, kind, entry.id)`, so peers
+ * that run this migration independently against the same universe converge
+ * on the same catalog row instead of minting divergent ids that would orphan
+ * one side on the next universe LWW merge.
+ *
+ * Three cases:
+ * - Entry has no `ingredientId` → insert the deterministic row, link, return
+ * the id so the caller stamps the embedded record.
+ * - Entry already has the deterministic `ingredientId` → already promoted;
+ * ensure the ref link exists, skip.
+ * - Entry has a foreign `ingredientId` (legacy random-UUID id from a peer on
+ * a pre-deterministic build) → recreate locally with the explicit foreign
+ * id so cross-peer identity holds, until the network re-converges.
+ */
+async function promoteEntry({ universeId, entry, kind, role }) {
+ if (!entry?.name || !entry?.id) return { skipped: true, reason: 'missing-id-or-name' };
+
+ const payload = { ...entry };
+ delete payload.id;
+ delete payload.ingredientId;
+ delete payload.createdAt;
+ delete payload.updatedAt;
+
+ // Audit tags first so a `.slice(-N)` preserves them on overflow rather than
+ // dropping them; user-supplied tags accept being trimmed before audit tags.
+ const auditTags = ['from-universe', `universe:${universeId}`];
+ const userTags = Array.isArray(entry.tags) ? entry.tags : [];
+ const tags = [...userTags, ...auditTags].slice(-12);
+
+ const targetId = entry.ingredientId || deterministicIngredientId(universeId, kind, entry.id);
+
+ const existing = await catalogDB.getIngredient(targetId);
+ if (existing) {
+ await catalogDB.linkIngredientToRef(targetId, 'universe', universeId, role);
+ // Stamp if the embedded entry hasn't recorded the id yet (first-time
+ // promotion via deterministic id; previously had a different/no id).
+ if (entry.ingredientId !== targetId) {
+ return { ingredientId: targetId, name: entry.name };
+ }
+ return { skipped: true, reason: 'already-promoted' };
+ }
+
+ // Soft-deleted recovery: if a row exists at this id but marked deleted,
+ // un-delete it rather than letting the next INSERT hit a PK conflict.
+ const undeleted = await catalogDB.reviveDeletedIngredient(targetId, {
+ type: kind, name: entry.name, payload, tags,
+ }).catch(() => null);
+ if (undeleted) {
+ await catalogDB.linkIngredientToRef(targetId, 'universe', universeId, role);
+ return entry.ingredientId === targetId
+ ? { skipped: true, reason: 'undeleted' }
+ : { ingredientId: targetId, name: entry.name };
+ }
+
+ const ing = await catalogDB.createIngredient({
+ id: targetId, type: kind, name: entry.name, payload, tags,
+ });
+ await catalogDB.linkIngredientToRef(ing.id, 'universe', universeId, role);
+ return { ingredientId: ing.id, name: entry.name };
+}
+
+/**
+ * Walk one universe — for each kind (character/place/object), promote every
+ * not-yet-promoted entry and stamp the new ingredientId back on the embedded
+ * record. Returns per-kind counts.
+ *
+ * Uses the universe write path's mutator overload so the queued write picks
+ * up the freshest in-memory snapshot — concurrent edits during boot won't
+ * race the migration.
+ */
+async function migrateUniverse(universe) {
+ const stats = { promoted: 0, skipped: 0, peerReconciled: 0, errors: 0 };
+ // Promote each entry, collecting new ingredient ids keyed by entry id.
+ // Done OUTSIDE the universe write queue so DB inserts don't block other
+ // universe edits during boot.
+ const newIds = {};
+ for (const { array, kind, role } of KINDS) {
+ const list = Array.isArray(universe[array]) ? universe[array] : [];
+ newIds[array] = {};
+ for (const entry of list) {
+ if (!entry?.id) continue;
+ try {
+ const result = await promoteEntry({ universeId: universe.id, entry, kind, role });
+ if (result.peerReconciled) {
+ stats.peerReconciled++;
+ continue;
+ }
+ if (result.skipped) {
+ stats.skipped++;
+ continue;
+ }
+ newIds[array][entry.id] = result.ingredientId;
+ stats.promoted++;
+ } catch (err) {
+ console.error(`🪄 promote failed (${universe.id}/${array}/${entry.name}): ${err.message}`);
+ stats.errors++;
+ }
+ }
+ }
+
+ // Phase 2: stamp ingredientId back onto the embedded entries. Skip if
+ // nothing was promoted on this universe so we don't bump updatedAt for no
+ // reason.
+ const hadAnyPromotion = KINDS.some(({ array }) => Object.keys(newIds[array]).length > 0);
+ if (!hadAnyPromotion) return stats;
+
+ // `silent: true` suppresses the per-universe peer-sync fan-out — without
+ // this, every install would emit N recordUpdated events at boot post-
+ // upgrade (one per universe with any new promotion), each fanning out to
+ // every peer. Peers pick up the ingredient-id stamps on the next normal
+ // sync cycle.
+ await updateUniverse(universe.id, (cur) => {
+ const patch = {};
+ for (const { array } of KINDS) {
+ const ids = newIds[array];
+ if (Object.keys(ids).length === 0) continue;
+ const list = Array.isArray(cur[array]) ? cur[array] : [];
+ patch[array] = list.map((entry) => {
+ const newId = ids[entry?.id];
+ return newId ? { ...entry, ingredientId: newId } : entry;
+ });
+ }
+ return Object.keys(patch).length > 0 ? patch : null;
+ }, { silent: true });
+
+ return stats;
+}
+
+/**
+ * Public entry point. Runs the migration once, then no-ops on every
+ * subsequent boot. Wired into server/index.js after ensureSchema().
+ */
+export async function migrateBibleToCatalog({ force = false } = {}) {
+ const marker = await readMarker();
+ if (marker?.version === MARKER_VERSION && !force) {
+ return { skipped: true, marker };
+ }
+
+ console.log('🪄 bible→catalog migration: starting');
+ const universes = await listUniverses({ includeDeleted: false });
+
+ const totals = { universesScanned: 0, promoted: 0, peerReconciled: 0, skipped: 0, errors: 0 };
+ for (const universe of universes) {
+ if (universe.deleted) continue;
+ const result = await migrateUniverse(universe);
+ totals.universesScanned++;
+ totals.promoted += result.promoted;
+ totals.peerReconciled += result.peerReconciled;
+ totals.skipped += result.skipped;
+ totals.errors += result.errors;
+ }
+
+ const payload = {
+ version: MARKER_VERSION,
+ completedAt: new Date().toISOString(),
+ stats: totals,
+ };
+ await writeMarker(payload);
+
+ console.log(
+ `🪄 bible→catalog migration: ${totals.universesScanned} universes scanned, ` +
+ `${totals.promoted} promoted, ${totals.peerReconciled} peer-reconciled, ` +
+ `${totals.skipped} skipped, ${totals.errors} errors`,
+ );
+
+ return { skipped: false, ...payload };
+}
diff --git a/server/services/catalogDB.js b/server/services/catalogDB.js
new file mode 100644
index 0000000000..c7e31b47a7
--- /dev/null
+++ b/server/services/catalogDB.js
@@ -0,0 +1,619 @@
+/**
+ * Creative Ingredients Catalog — Postgres data layer.
+ *
+ * Backs the typed catalog of creative "ingredients" (characters, places,
+ * objects, ideas, scenes, concepts). Mirrors the role memoryDB.js plays for
+ * memories: thin SQL wrappers + row→object translation, no business logic.
+ *
+ * Tables: catalog_scraps, catalog_ingredients, catalog_ingredient_sources,
+ * catalog_ingredient_refs. See server/scripts/init-db.sql for the schema.
+ */
+
+import { randomUUID } from 'crypto';
+import { query, withTransaction, pgvectorToArray, arrayToPgvector } from '../lib/db.js';
+import { getInstanceId } from './instances.js';
+
+const TYPE_PREFIX = {
+ character: 'chr',
+ place: 'plc',
+ object: 'obj',
+ idea: 'idea',
+ scene: 'scn',
+ concept: 'cnc',
+};
+
+function newIngredientId(type) {
+ const prefix = TYPE_PREFIX[type];
+ if (!prefix) throw new Error(`Unknown ingredient type: ${type}`);
+ return `cat-${prefix}-${randomUUID()}`;
+}
+
+function newScrapId() {
+ return `cat-scrap-${randomUUID()}`;
+}
+
+
+function rowToScrap(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ title: row.title,
+ rawText: row.raw_text,
+ sourceKind: row.source_kind,
+ metadata: row.metadata || {},
+ embedding: row.embedding ? pgvectorToArray(row.embedding) : null,
+ embeddingModel: row.embedding_model,
+ originInstanceId: row.origin_instance_id,
+ createdAt: row.created_at.toISOString(),
+ updatedAt: row.updated_at.toISOString(),
+ deleted: !!row.deleted,
+ deletedAt: row.deleted_at?.toISOString() ?? null,
+ syncSequence: String(row.sync_sequence),
+ };
+}
+
+function rowToIngredient(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ type: row.type,
+ name: row.name,
+ payload: row.payload || {},
+ tags: row.tags || [],
+ embedding: row.embedding ? pgvectorToArray(row.embedding) : null,
+ embeddingModel: row.embedding_model,
+ originInstanceId: row.origin_instance_id,
+ createdAt: row.created_at.toISOString(),
+ updatedAt: row.updated_at.toISOString(),
+ deleted: !!row.deleted,
+ deletedAt: row.deleted_at?.toISOString() ?? null,
+ syncSequence: String(row.sync_sequence),
+ };
+}
+
+function rowToRef(row) {
+ if (!row) return null;
+ return {
+ ingredientId: row.ingredient_id,
+ refKind: row.ref_kind,
+ refId: row.ref_id,
+ role: row.role,
+ createdAt: row.created_at.toISOString(),
+ syncSequence: String(row.sync_sequence),
+ };
+}
+
+function rowToSource(row) {
+ if (!row) return null;
+ return {
+ ingredientId: row.ingredient_id,
+ scrapId: row.scrap_id,
+ span: row.span,
+ extractedAt: row.extracted_at.toISOString(),
+ syncSequence: String(row.sync_sequence),
+ };
+}
+
+
+export async function createScrap({ title, rawText, sourceKind = 'paste', metadata = {}, embedding = null, embeddingModel = null } = {}) {
+ if (!rawText) throw new Error('rawText is required');
+ const id = newScrapId();
+ const originInstanceId = await getInstanceId();
+ const result = await query(
+ `INSERT INTO catalog_scraps
+ (id, title, raw_text, source_kind, metadata, embedding, embedding_model, origin_instance_id)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8)
+ RETURNING *`,
+ [
+ id,
+ title || null,
+ rawText,
+ sourceKind,
+ JSON.stringify(metadata || {}),
+ embedding ? arrayToPgvector(embedding) : null,
+ embeddingModel,
+ originInstanceId,
+ ],
+ );
+ return rowToScrap(result.rows[0]);
+}
+
+export async function getScrap(id) {
+ const result = await query(
+ `SELECT * FROM catalog_scraps WHERE id = $1 AND deleted = false`,
+ [id],
+ );
+ return rowToScrap(result.rows[0]);
+}
+
+export async function listScraps({ limit = 50, offset = 0 } = {}) {
+ const result = await query(
+ `SELECT * FROM catalog_scraps
+ WHERE deleted = false
+ ORDER BY created_at DESC
+ LIMIT $1 OFFSET $2`,
+ [limit, offset],
+ );
+ return { items: result.rows.map(rowToScrap), nextOffset: offset + result.rows.length };
+}
+
+export async function updateScrap(id, patch = {}) {
+ const fields = [];
+ const params = [];
+ let idx = 1;
+ const fieldMap = {
+ title: 'title',
+ rawText: 'raw_text',
+ sourceKind: 'source_kind',
+ metadata: 'metadata',
+ embedding: 'embedding',
+ embeddingModel: 'embedding_model',
+ };
+ for (const [jsField, dbField] of Object.entries(fieldMap)) {
+ if (patch[jsField] === undefined) continue;
+ if (jsField === 'metadata') {
+ fields.push(`${dbField} = $${idx++}::jsonb`);
+ params.push(JSON.stringify(patch.metadata || {}));
+ } else if (jsField === 'embedding') {
+ fields.push(`${dbField} = $${idx++}`);
+ params.push(patch.embedding ? arrayToPgvector(patch.embedding) : null);
+ } else {
+ fields.push(`${dbField} = $${idx++}`);
+ params.push(patch[jsField]);
+ }
+ }
+ if (fields.length === 0) return getScrap(id);
+ params.push(id);
+ // `AND deleted = false` keeps PATCH consistent with GET — a PATCH on a
+ // soft-deleted row returns zero rows so the route 404s, instead of silently
+ // mutating a row the next GET would refuse to return.
+ const result = await query(
+ `UPDATE catalog_scraps SET ${fields.join(', ')} WHERE id = $${idx} AND deleted = false RETURNING *`,
+ params,
+ );
+ return rowToScrap(result.rows[0]);
+}
+
+export async function deleteScrap(id, { hard = false } = {}) {
+ if (hard) {
+ await query(`DELETE FROM catalog_scraps WHERE id = $1`, [id]);
+ } else {
+ await query(
+ `UPDATE catalog_scraps SET deleted = true, deleted_at = NOW() WHERE id = $1`,
+ [id],
+ );
+ }
+ return { success: true, id };
+}
+
+
+export async function createIngredient({ id: explicitId, type, name, payload = {}, tags = [], embedding = null, embeddingModel = null } = {}) {
+ if (!type || !TYPE_PREFIX[type]) throw new Error(`Invalid ingredient type: ${type}`);
+ if (!name || !String(name).trim()) throw new Error('name is required');
+
+ // `explicitId` is used by the backfill when a universe arrives from a peer
+ // already carrying an ingredientId — preserves cross-peer identity so the
+ // same logical character has the same catalog id on every install. New
+ // user-initiated creates omit it and we mint a fresh prefix:uuid.
+ const id = explicitId || newIngredientId(type);
+ const originInstanceId = await getInstanceId();
+ const result = await query(
+ `INSERT INTO catalog_ingredients
+ (id, type, name, payload, tags, embedding, embedding_model, origin_instance_id)
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)
+ RETURNING *`,
+ [
+ id,
+ type,
+ String(name).trim(),
+ JSON.stringify(payload || {}),
+ tags || [],
+ embedding ? arrayToPgvector(embedding) : null,
+ embeddingModel,
+ originInstanceId,
+ ],
+ );
+ return rowToIngredient(result.rows[0]);
+}
+
+export async function getIngredient(id) {
+ const result = await query(
+ `SELECT * FROM catalog_ingredients WHERE id = $1 AND deleted = false`,
+ [id],
+ );
+ return rowToIngredient(result.rows[0]);
+}
+
+export async function updateIngredient(id, patch = {}) {
+ const fields = [];
+ const params = [];
+ let idx = 1;
+ const fieldMap = {
+ name: 'name',
+ payload: 'payload',
+ tags: 'tags',
+ embedding: 'embedding',
+ embeddingModel: 'embedding_model',
+ };
+ for (const [jsField, dbField] of Object.entries(fieldMap)) {
+ if (patch[jsField] === undefined) continue;
+ if (jsField === 'payload') {
+ fields.push(`${dbField} = $${idx++}::jsonb`);
+ params.push(JSON.stringify(patch.payload || {}));
+ } else if (jsField === 'embedding') {
+ fields.push(`${dbField} = $${idx++}`);
+ params.push(patch.embedding ? arrayToPgvector(patch.embedding) : null);
+ } else {
+ fields.push(`${dbField} = $${idx++}`);
+ params.push(patch[jsField]);
+ }
+ }
+ if (fields.length === 0) return getIngredient(id);
+ params.push(id);
+ // Mirrors updateScrap: PATCH on a soft-deleted row returns zero rows so the
+ // route 404s. Revival of soft-deleted rows is intentionally separate via
+ // `reviveDeletedIngredient`, so this filter doesn't conflict with that path.
+ const result = await query(
+ `UPDATE catalog_ingredients SET ${fields.join(', ')} WHERE id = $${idx} AND deleted = false RETURNING *`,
+ params,
+ );
+ return rowToIngredient(result.rows[0]);
+}
+
+export async function deleteIngredient(id, { hard = false } = {}) {
+ if (hard) {
+ await query(`DELETE FROM catalog_ingredients WHERE id = $1`, [id]);
+ } else {
+ await query(
+ `UPDATE catalog_ingredients SET deleted = true, deleted_at = NOW() WHERE id = $1`,
+ [id],
+ );
+ }
+ return { success: true, id };
+}
+
+/**
+ * Un-delete a soft-deleted ingredient row at a deterministic id and replace
+ * its `name`/`payload`/`tags`/`type` with the current values. Used only by
+ * the bible→catalog backfill — `getIngredient(id)` filters `deleted = false`,
+ * so without this an INSERT at the deterministic id collides on the PK and
+ * the migration silently re-fails on every boot. Returns the revived row, or
+ * `null` if no row exists at that id (caller falls through to plain INSERT).
+ */
+export async function reviveDeletedIngredient(id, { type, name, payload = {}, tags = [] } = {}) {
+ if (!type || !TYPE_PREFIX[type]) throw new Error(`reviveDeletedIngredient: invalid type ${type}`);
+ if (!name || !String(name).trim()) throw new Error('reviveDeletedIngredient: name required');
+ const result = await query(
+ `UPDATE catalog_ingredients
+ SET deleted = false, deleted_at = NULL,
+ type = $2, name = $3, payload = $4::jsonb, tags = $5,
+ updated_at = NOW()
+ WHERE id = $1 AND deleted = true
+ RETURNING *`,
+ [id, type, String(name).trim(), JSON.stringify(payload || {}), tags || []],
+ );
+ return result.rows.length > 0 ? rowToIngredient(result.rows[0]) : null;
+}
+
+// `includeEmbedding: false` (the default for list paths) strips the 768-float
+// vector column from the SELECT — each row's embedding is ~6KB stringified, so
+// a 200-row page would otherwise ship >1MB the UI never displays. The detail
+// endpoint sets includeEmbedding: true.
+// `embeddingMissing: true` is for the backfill admin path so SQL filters
+// directly instead of fetching-then-JS-filtering.
+const INGREDIENT_LIGHT_COLS = 'id, type, name, payload, tags, embedding_model, origin_instance_id, created_at, updated_at, deleted, deleted_at, sync_sequence';
+
+export async function listIngredients({ type, tag, query: q, limit = 50, offset = 0, includeEmbedding = false, embeddingMissing = false, staleEmbeddingModel = null } = {}) {
+ const conditions = ['deleted = false'];
+ const params = [];
+ let idx = 1;
+ if (type) {
+ conditions.push(`type = $${idx++}`);
+ params.push(type);
+ }
+ if (tag) {
+ conditions.push(`$${idx++} = ANY(tags)`);
+ params.push(tag);
+ }
+ let qIdx = null;
+ if (q) {
+ qIdx = idx++;
+ conditions.push(`search_tsv @@ websearch_to_tsquery('english', $${qIdx})`);
+ params.push(q);
+ }
+ if (embeddingMissing) {
+ conditions.push('embedding IS NULL');
+ }
+ // Re-embed admin path: catch rows that have an embedding but were created
+ // under a different provider/model. Without this, a settings change leaves
+ // every prior row in the wrong vector space, silently degrading search.
+ if (staleEmbeddingModel) {
+ conditions.push(`(embedding IS NULL OR embedding_model IS DISTINCT FROM $${idx++})`);
+ params.push(staleEmbeddingModel);
+ }
+ const where = `WHERE ${conditions.join(' AND ')}`;
+ // ORDER BY must reference q's actual param index — when type/tag is also
+ // present, q is not $1 and a hardcoded $1 would rank against the type literal.
+ const orderBy = qIdx
+ ? `ORDER BY ts_rank_cd(search_tsv, websearch_to_tsquery('english', $${qIdx})) DESC, created_at DESC`
+ : `ORDER BY created_at DESC`;
+ params.push(limit, offset);
+ const cols = includeEmbedding ? '*' : INGREDIENT_LIGHT_COLS;
+ const result = await query(
+ `SELECT ${cols} FROM catalog_ingredients ${where} ${orderBy} LIMIT $${idx++} OFFSET $${idx}`,
+ params,
+ );
+ return { items: result.rows.map(rowToIngredient), nextOffset: offset + result.rows.length };
+}
+
+
+/**
+ * Cosine-similarity search over the ingredient embedding column.
+ * `threshold` is a similarity floor (1 - cosine_distance), default 0.5.
+ */
+export async function searchIngredientsByEmbedding(vector, { type, limit = 20, threshold = 0.5 } = {}) {
+ if (!vector) return [];
+ const conditions = ['deleted = false', 'embedding IS NOT NULL'];
+ const params = [arrayToPgvector(vector), threshold, limit];
+ let idx = 4;
+ if (type) {
+ conditions.push(`type = $${idx++}`);
+ params.push(type);
+ }
+ const result = await query(
+ `SELECT *, 1 - (embedding <=> $1) AS score
+ FROM catalog_ingredients
+ WHERE ${conditions.join(' AND ')}
+ AND 1 - (embedding <=> $1) >= $2
+ ORDER BY embedding <=> $1
+ LIMIT $3`,
+ params,
+ );
+ return result.rows.map((row) => ({ ingredient: rowToIngredient(row), score: parseFloat(row.score) }));
+}
+
+export async function searchIngredientsByText(q, { type, limit = 20 } = {}) {
+ if (!q) return [];
+ const conditions = ['deleted = false', `search_tsv @@ websearch_to_tsquery('english', $1)`];
+ const params = [q, limit];
+ let idx = 3;
+ if (type) {
+ conditions.push(`type = $${idx++}`);
+ params.push(type);
+ }
+ const result = await query(
+ `SELECT *, ts_rank_cd(search_tsv, websearch_to_tsquery('english', $1)) AS rank
+ FROM catalog_ingredients
+ WHERE ${conditions.join(' AND ')}
+ ORDER BY rank DESC
+ LIMIT $2`,
+ params,
+ );
+ return result.rows.map((row) => ({ ingredient: rowToIngredient(row), rank: parseFloat(row.rank) }));
+}
+
+
+export async function linkIngredientToSource(ingredientId, scrapId, span = null) {
+ await query(
+ `INSERT INTO catalog_ingredient_sources (ingredient_id, scrap_id, span)
+ VALUES ($1, $2, $3::jsonb)
+ ON CONFLICT (ingredient_id, scrap_id) DO UPDATE SET span = EXCLUDED.span`,
+ [ingredientId, scrapId, span ? JSON.stringify(span) : null],
+ );
+}
+
+export async function listSourcesForIngredient(ingredientId) {
+ const result = await query(
+ `SELECT * FROM catalog_ingredient_sources WHERE ingredient_id = $1`,
+ [ingredientId],
+ );
+ return result.rows.map(rowToSource);
+}
+
+export async function listSourcesForScrap(scrapId) {
+ const result = await query(
+ `SELECT * FROM catalog_ingredient_sources WHERE scrap_id = $1`,
+ [scrapId],
+ );
+ return result.rows.map(rowToSource);
+}
+
+export async function linkIngredientToRef(ingredientId, refKind, refId, role) {
+ await query(
+ `INSERT INTO catalog_ingredient_refs (ingredient_id, ref_kind, ref_id, role)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (ingredient_id, ref_kind, ref_id, role) DO NOTHING`,
+ [ingredientId, refKind, refId, role],
+ );
+}
+
+export async function unlinkIngredientFromRef(ingredientId, refKind, refId, role) {
+ await query(
+ `DELETE FROM catalog_ingredient_refs
+ WHERE ingredient_id = $1 AND ref_kind = $2 AND ref_id = $3 AND role = $4`,
+ [ingredientId, refKind, refId, role],
+ );
+}
+
+export async function listRefsForIngredient(ingredientId) {
+ const result = await query(
+ `SELECT * FROM catalog_ingredient_refs WHERE ingredient_id = $1`,
+ [ingredientId],
+ );
+ return result.rows.map(rowToRef);
+}
+
+export async function listIngredientsForRef(refKind, refId) {
+ const result = await query(
+ `SELECT i.*, r.role, r.created_at AS ref_created_at
+ FROM catalog_ingredients i
+ JOIN catalog_ingredient_refs r ON r.ingredient_id = i.id
+ WHERE r.ref_kind = $1 AND r.ref_id = $2 AND i.deleted = false`,
+ [refKind, refId],
+ );
+ return result.rows.map((row) => ({ ingredient: rowToIngredient(row), role: row.role }));
+}
+
+
+export async function getMaxSequences() {
+ const result = await query(`
+ SELECT
+ COALESCE((SELECT MAX(sync_sequence) FROM catalog_ingredients), 0)::text AS ingredients,
+ COALESCE((SELECT MAX(sync_sequence) FROM catalog_scraps), 0)::text AS scraps,
+ COALESCE((SELECT MAX(sync_sequence) FROM catalog_ingredient_sources), 0)::text AS sources,
+ COALESCE((SELECT MAX(sync_sequence) FROM catalog_ingredient_refs), 0)::text AS refs
+ `);
+ return result.rows[0];
+}
+
+export async function getScrapChangesSince(since = '0', limit = 100) {
+ const result = await query(
+ `SELECT * FROM catalog_scraps WHERE sync_sequence > $1 ORDER BY sync_sequence ASC LIMIT $2`,
+ [since, limit + 1],
+ );
+ const hasMore = result.rows.length > limit;
+ const rows = hasMore ? result.rows.slice(0, limit) : result.rows;
+ return { items: rows.map(rowToScrap), hasMore };
+}
+
+export async function getIngredientChangesSince(since = '0', limit = 100) {
+ const result = await query(
+ `SELECT * FROM catalog_ingredients WHERE sync_sequence > $1 ORDER BY sync_sequence ASC LIMIT $2`,
+ [since, limit + 1],
+ );
+ const hasMore = result.rows.length > limit;
+ const rows = hasMore ? result.rows.slice(0, limit) : result.rows;
+ return { items: rows.map(rowToIngredient), hasMore };
+}
+
+export async function getSourceChangesSince(since = '0', limit = 100) {
+ const result = await query(
+ `SELECT * FROM catalog_ingredient_sources WHERE sync_sequence > $1 ORDER BY sync_sequence ASC LIMIT $2`,
+ [since, limit + 1],
+ );
+ const hasMore = result.rows.length > limit;
+ const rows = hasMore ? result.rows.slice(0, limit) : result.rows;
+ return { items: rows.map(rowToSource), hasMore };
+}
+
+export async function getRefChangesSince(since = '0', limit = 100) {
+ const result = await query(
+ `SELECT * FROM catalog_ingredient_refs WHERE sync_sequence > $1 ORDER BY sync_sequence ASC LIMIT $2`,
+ [since, limit + 1],
+ );
+ const hasMore = result.rows.length > limit;
+ const rows = hasMore ? result.rows.slice(0, limit) : result.rows;
+ return { items: rows.map(rowToRef), hasMore };
+}
+
+
+export async function upsertScrapFromPeer(scrap) {
+ const result = await query(
+ `INSERT INTO catalog_scraps
+ (id, title, raw_text, source_kind, metadata, embedding, embedding_model,
+ origin_instance_id, created_at, updated_at, deleted, deleted_at)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, $11, $12)
+ ON CONFLICT (id) DO UPDATE SET
+ title = EXCLUDED.title,
+ raw_text = EXCLUDED.raw_text,
+ source_kind = EXCLUDED.source_kind,
+ metadata = EXCLUDED.metadata,
+ embedding = EXCLUDED.embedding,
+ embedding_model = EXCLUDED.embedding_model,
+ updated_at = EXCLUDED.updated_at,
+ deleted = EXCLUDED.deleted,
+ deleted_at = EXCLUDED.deleted_at
+ WHERE EXCLUDED.updated_at > catalog_scraps.updated_at
+ RETURNING (xmax = 0) AS is_insert`,
+ [
+ scrap.id,
+ scrap.title || null,
+ scrap.rawText,
+ scrap.sourceKind || 'paste',
+ JSON.stringify(scrap.metadata || {}),
+ scrap.embedding ? arrayToPgvector(scrap.embedding) : null,
+ scrap.embeddingModel || null,
+ scrap.originInstanceId || null,
+ scrap.createdAt,
+ scrap.updatedAt,
+ !!scrap.deleted,
+ scrap.deletedAt || null,
+ ],
+ );
+ return { applied: result.rows.length > 0, isInsert: result.rows[0]?.is_insert ?? false };
+}
+
+export async function upsertIngredientFromPeer(ing) {
+ const result = await query(
+ `INSERT INTO catalog_ingredients
+ (id, type, name, payload, tags, embedding, embedding_model,
+ origin_instance_id, created_at, updated_at, deleted, deleted_at)
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8, $9, $10, $11, $12)
+ ON CONFLICT (id) DO UPDATE SET
+ type = EXCLUDED.type,
+ name = EXCLUDED.name,
+ payload = EXCLUDED.payload,
+ tags = EXCLUDED.tags,
+ embedding = EXCLUDED.embedding,
+ embedding_model = EXCLUDED.embedding_model,
+ updated_at = EXCLUDED.updated_at,
+ deleted = EXCLUDED.deleted,
+ deleted_at = EXCLUDED.deleted_at
+ WHERE EXCLUDED.updated_at > catalog_ingredients.updated_at
+ RETURNING (xmax = 0) AS is_insert`,
+ [
+ ing.id,
+ ing.type,
+ ing.name,
+ JSON.stringify(ing.payload || {}),
+ ing.tags || [],
+ ing.embedding ? arrayToPgvector(ing.embedding) : null,
+ ing.embeddingModel || null,
+ ing.originInstanceId || null,
+ ing.createdAt,
+ ing.updatedAt,
+ !!ing.deleted,
+ ing.deletedAt || null,
+ ],
+ );
+ return { applied: result.rows.length > 0, isInsert: result.rows[0]?.is_insert ?? false };
+}
+
+export async function upsertSourceFromPeer(src) {
+ await query(
+ `INSERT INTO catalog_ingredient_sources (ingredient_id, scrap_id, span, extracted_at)
+ VALUES ($1, $2, $3::jsonb, $4)
+ ON CONFLICT (ingredient_id, scrap_id) DO UPDATE SET span = EXCLUDED.span`,
+ [src.ingredientId, src.scrapId, src.span ? JSON.stringify(src.span) : null, src.extractedAt],
+ );
+}
+
+export async function upsertRefFromPeer(ref) {
+ await query(
+ `INSERT INTO catalog_ingredient_refs (ingredient_id, ref_kind, ref_id, role, created_at)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (ingredient_id, ref_kind, ref_id, role) DO NOTHING`,
+ [ref.ingredientId, ref.refKind, ref.refId, ref.role, ref.createdAt],
+ );
+}
+
+
+export async function getCatalogStats() {
+ const [byTypeResult, scrapResult, withEmb] = await Promise.all([
+ query(`SELECT type, COUNT(*) AS count FROM catalog_ingredients WHERE deleted = false GROUP BY type`),
+ query(`SELECT COUNT(*) AS count FROM catalog_scraps WHERE deleted = false`),
+ query(`SELECT COUNT(*) AS count FROM catalog_ingredients WHERE deleted = false AND embedding IS NOT NULL`),
+ ]);
+ const byType = {};
+ let total = 0;
+ for (const r of byTypeResult.rows) {
+ byType[r.type] = parseInt(r.count, 10);
+ total += parseInt(r.count, 10);
+ }
+ return {
+ total,
+ byType,
+ scraps: parseInt(scrapResult.rows[0].count, 10),
+ withEmbeddings: parseInt(withEmb.rows[0].count, 10),
+ };
+}
diff --git a/server/services/catalogEvents.js b/server/services/catalogEvents.js
new file mode 100644
index 0000000000..1324e31216
--- /dev/null
+++ b/server/services/catalogEvents.js
@@ -0,0 +1,13 @@
+import { EventEmitter } from 'events';
+
+// Stage-progress bus for the catalog's LLM extraction passes (character,
+// place, object). Mirrors importerEvents.js — the extraction route runs each
+// kind in parallel, can take 30+ seconds, and the client has no other way to
+// see which kind is in flight. socket.js bridges `progress` frames here to
+// `catalog:extract:progress` on Socket.IO; the Catalog Ingest page renders
+// the live stage checklist.
+//
+// Single-user trust model: at most one extraction runs at a time, but each
+// frame carries a `runId` so the client can ignore stragglers from a prior
+// run if the user re-fired quickly.
+export const catalogEvents = new EventEmitter();
diff --git a/server/services/catalogExtraction.js b/server/services/catalogExtraction.js
new file mode 100644
index 0000000000..3aa90a2bbf
--- /dev/null
+++ b/server/services/catalogExtraction.js
@@ -0,0 +1,124 @@
+/**
+ * Catalog Extraction Service
+ *
+ * Runs LLM passes over a raw user-pasted scrap and returns a typed draft of
+ * candidate ingredients (characters, places, objects) the user can review
+ * and selectively commit via POST /api/catalog/scraps/:id/commit.
+ *
+ * Reuses server/lib/bibleExtractor.js for the three storyBible-shaped types
+ * — characters, places, objects — so the catalog payload is identical to the
+ * shape stored in universe canon. Idea/scene/concept LLM extraction is a
+ * follow-up (Phase 5b); for now they're created manually via POST
+ * /api/catalog/ingredients.
+ *
+ * Streams progress as `catalog:extract:progress` socket frames so the Ingest
+ * UI can render a live stage checklist while waiting on the parallel passes.
+ */
+
+import { randomUUID } from 'crypto';
+import { extractBible } from '../lib/bibleExtractor.js';
+import { BIBLE_KINDS, BIBLE_FIELD } from '../lib/storyBible.js';
+import { catalogEvents } from './catalogEvents.js';
+
+// One stage per bible kind; runs in parallel under the same runId. Derived
+// from BIBLE_KINDS so a future kind picked up by extractBible flows through
+// here automatically.
+const titleCase = (s) => s.charAt(0).toUpperCase() + s.slice(1) + 's';
+export const EXTRACTION_STAGES = Object.freeze(
+ BIBLE_KINDS.map((kind) => ({
+ id: BIBLE_FIELD[kind], // 'characters' | 'places' | 'objects'
+ label: titleCase(kind),
+ kind,
+ })),
+);
+
+/**
+ * Extract candidate ingredients from a raw scrap.
+ *
+ * @param {object} args
+ * @param {string} args.rawText The scrap body to extract from.
+ * @param {string} [args.scrapId] Scrap id to attach to progress frames.
+ * @param {string} [args.providerOverride] Override the staged-llm provider.
+ * @returns {Promise<{
+ * runId: string,
+ * characters: Array,
+ * places: Array,
+ * objects: Array,
+ * stages: Array<{ id, label, status, error? }>
+ * }>}
+ */
+/**
+ * Neutralize markdown fence delimiters in user-pasted text before it lands
+ * inside the extractor's triple-backtick `{{draftBody}}` fence. Without this
+ * a paste containing ``` would prematurely close the prompt's fenced block
+ * and corrupt the structured prompt the LLM sees. The writers-room callers
+ * of `extractBible` pass server-curated content so they don't need this; the
+ * catalog caller passes ARBITRARY USER PASTE and does.
+ *
+ * Replacement uses zero-width joiner between the backticks so the visual
+ * content is preserved for any model that wants to comment on it.
+ */
+function neutralizeFenceDelimiters(text) {
+ // U+200D zero-width joiner; safe inside JSON strings, invisible in prose.
+ return text.replace(/```/g, '```');
+}
+
+export async function extractIngredients({ rawText, scrapId = null, providerOverride } = {}) {
+ if (typeof rawText !== 'string' || !rawText.trim()) {
+ throw new Error('extractIngredients: rawText is required');
+ }
+
+ const corpus = neutralizeFenceDelimiters(rawText);
+ const runId = randomUUID();
+ const emit = (frame) => {
+ try {
+ catalogEvents.emit('progress', { runId, scrapId, ...frame });
+ } catch (err) {
+ console.error(`❌ catalog progress emit failed: ${err.message}`);
+ }
+ };
+
+ emit({ type: 'start', stages: EXTRACTION_STAGES.map(({ id, label }) => ({ id, label })) });
+
+ // Run all three bible-extract passes in parallel — they're independent and
+ // the corpus is identical. Each settles to a per-stage status frame so the
+ // UI flips the corresponding row to ✓ / ✗ as work completes.
+ const promises = EXTRACTION_STAGES.map(async ({ id, kind }) => {
+ emit({ type: 'stage', id, status: 'running' });
+ try {
+ const result = await extractBible({
+ kind,
+ corpus,
+ existing: [],
+ providerOverride,
+ source: `catalog-extract-${id}`,
+ });
+ emit({ type: 'stage', id, status: 'completed', count: result.extracted.length });
+ return { id, extracted: result.extracted, error: null };
+ } catch (err) {
+ console.error(`❌ catalog extract ${id} failed: ${err.message}`);
+ emit({ type: 'stage', id, status: 'failed', error: err.message });
+ return { id, extracted: [], error: err.message };
+ }
+ });
+
+ const settled = await Promise.all(promises);
+
+ const draft = Object.fromEntries(EXTRACTION_STAGES.map((s) => [s.id, []]));
+ const stages = settled.map(({ id, extracted, error }) => {
+ draft[id] = extracted;
+ return {
+ id,
+ label: EXTRACTION_STAGES.find((s) => s.id === id).label,
+ status: error ? 'failed' : 'completed',
+ count: extracted.length,
+ error: error || undefined,
+ };
+ });
+
+ // (No terminal `done` socket frame — the client transitions to the review
+ // phase off the HTTP response, not a socket event. Emitting an unhandled
+ // frame is just noise.)
+
+ return { runId, ...draft, stages };
+}
diff --git a/server/services/catalogSync.js b/server/services/catalogSync.js
new file mode 100644
index 0000000000..66d1c0c044
--- /dev/null
+++ b/server/services/catalogSync.js
@@ -0,0 +1,178 @@
+/**
+ * Catalog Federation Sync Service
+ *
+ * Peer-to-peer sync for the Creative Ingredients Catalog. Mirrors
+ * server/services/memorySync.js but the envelope carries four kinds
+ * (scraps, ingredients, sources, refs) because the catalog is a
+ * three-table relational store, not a single flat row set.
+ *
+ * Pull protocol:
+ * GET /api/catalog/sync?since[scraps]=A&since[ingredients]=B&since[sources]=C&since[refs]=D&limit=100
+ * → { scraps[], ingredients[], sources[], refs[], maxSequences, hasMore }
+ *
+ * The four BIGSERIAL `sync_sequence` columns are INDEPENDENT — a row at
+ * sources.sync_sequence=50 isn't comparable to ingredients.sync_sequence=50.
+ * The receiver therefore tracks four cursors and `since` is `{ scraps,
+ * ingredients, sources, refs }`. A scalar `?since=N` is still accepted for
+ * back-compat / one-shot pulls and is applied uniformly to all four kinds.
+ * `hasMore` is true when ANY of the four tables had more than `limit` rows
+ * past its respective cursor — drain by re-pulling with the maxSequences from
+ * the previous response.
+ *
+ * Apply: receiver POSTs the envelope to /api/catalog/sync/apply. Conflict
+ * resolution is LWW on `updated_at` for ingredient/scrap rows; source/ref
+ * rows are tuple-unique and idempotent on conflict. Each row is wrapped in
+ * its own try/catch so one malformed row can't poison the whole batch.
+ */
+
+import {
+ getScrapChangesSince,
+ getIngredientChangesSince,
+ getSourceChangesSince,
+ getRefChangesSince,
+ getMaxSequences,
+ upsertScrapFromPeer,
+ upsertIngredientFromPeer,
+ upsertSourceFromPeer,
+ upsertRefFromPeer,
+} from './catalogDB.js';
+import { compareSchemaVersions, PORTOS_SCHEMA_VERSIONS } from '../lib/schemaVersions.js';
+
+const CURSOR_KEYS = ['scraps', 'ingredients', 'sources', 'refs'];
+
+// Normalize `since` (scalar string OR per-kind object) into a `{ scraps,
+// ingredients, sources, refs }` cursor map. Scalar form is uniform across
+// kinds — fine for the first pull (everyone starts at '0'); subsequent pulls
+// MUST use the per-kind form returned in `maxSequences` or rows on a less-
+// active table get silently filtered out forever.
+function normalizeCursors(since) {
+ if (since && typeof since === 'object' && !Array.isArray(since)) {
+ return Object.fromEntries(CURSOR_KEYS.map((k) => {
+ const v = since[k];
+ return [k, typeof v === 'string' && /^\d+$/.test(v) ? v : '0'];
+ }));
+ }
+ const scalar = typeof since === 'string' && /^\d+$/.test(since) ? since : '0';
+ return Object.fromEntries(CURSOR_KEYS.map((k) => [k, scalar]));
+}
+
+export async function getChangesSince(since = '0', limit = 100) {
+ const cursors = normalizeCursors(since);
+ const [scraps, ingredients, sources, refs] = await Promise.all([
+ getScrapChangesSince(cursors.scraps, limit),
+ getIngredientChangesSince(cursors.ingredients, limit),
+ getSourceChangesSince(cursors.sources, limit),
+ getRefChangesSince(cursors.refs, limit),
+ ]);
+
+ const hasMore =
+ scraps.hasMore || ingredients.hasMore || sources.hasMore || refs.hasMore;
+
+ // Per-kind cursor advance — fall back to the inbound cursor so the next pull
+ // doesn't move backward on a quiet kind.
+ const maxOf = (items, fallback) =>
+ items.length > 0 ? items[items.length - 1].syncSequence : fallback;
+
+ return {
+ scraps: scraps.items,
+ ingredients: ingredients.items,
+ sources: sources.items,
+ refs: refs.items,
+ maxSequences: {
+ scraps: maxOf(scraps.items, cursors.scraps),
+ ingredients: maxOf(ingredients.items, cursors.ingredients),
+ sources: maxOf(sources.items, cursors.sources),
+ refs: maxOf(refs.items, cursors.refs),
+ },
+ hasMore,
+ };
+}
+
+export class CatalogSyncVersionMismatchError extends Error {
+ constructor(diff) {
+ super(`catalog sync rejected: sender ahead on ${diff.ahead.map((g) => `${g.category} (v${g.senderV} vs v${g.receiverV})`).join(', ')}`);
+ this.name = 'CatalogSyncVersionMismatchError';
+ this.code = 'CATALOG_SCHEMA_VERSION_AHEAD';
+ this.status = 412;
+ this.diff = diff;
+ }
+}
+
+export async function applyRemoteChanges(envelope = {}) {
+ // Schema-version gate: a peer running a newer `catalog` schema would push
+ // forward-shaped data this install can't safely interpret. Match the
+ // memorySync pattern — reject ahead-mismatches with a 412.
+ const senderVersions = envelope?.portosMeta?.schemaVersions || {};
+ const diff = compareSchemaVersions(senderVersions, PORTOS_SCHEMA_VERSIONS);
+ const aheadOnCatalog = diff.ahead.filter((g) => g.category === 'catalog');
+ if (aheadOnCatalog.length > 0) {
+ throw new CatalogSyncVersionMismatchError({ ahead: aheadOnCatalog, behind: [] });
+ }
+
+ const stats = {
+ scraps: { inserted: 0, updated: 0, skipped: 0, failed: 0 },
+ ingredients: { inserted: 0, updated: 0, skipped: 0, failed: 0 },
+ sources: { applied: 0, failed: 0 },
+ refs: { applied: 0, failed: 0 },
+ errors: [],
+ };
+
+ const recordFailure = (kind, id, err) => {
+ stats.errors.push({ kind, id: id || null, message: err?.message || String(err) });
+ console.error(`❌ catalog sync ${kind} ${id || '?'} failed: ${err?.message || err}`);
+ };
+
+ // Scraps first (sources FK to BOTH so we want the parents present before
+ // the join rows land). Each row in its own try/catch — one malformed row
+ // must NOT abort the rest of the envelope.
+ for (const scrap of envelope.scraps || []) {
+ try {
+ const res = await upsertScrapFromPeer(scrap);
+ if (!res.applied) stats.scraps.skipped++;
+ else if (res.isInsert) stats.scraps.inserted++;
+ else stats.scraps.updated++;
+ } catch (err) {
+ stats.scraps.failed++;
+ recordFailure('scrap', scrap?.id, err);
+ }
+ }
+
+ for (const ing of envelope.ingredients || []) {
+ try {
+ const res = await upsertIngredientFromPeer(ing);
+ if (!res.applied) stats.ingredients.skipped++;
+ else if (res.isInsert) stats.ingredients.inserted++;
+ else stats.ingredients.updated++;
+ } catch (err) {
+ stats.ingredients.failed++;
+ recordFailure('ingredient', ing?.id, err);
+ }
+ }
+
+ for (const src of envelope.sources || []) {
+ try {
+ await upsertSourceFromPeer(src);
+ stats.sources.applied++;
+ } catch (err) {
+ stats.sources.failed++;
+ recordFailure('source', `${src?.ingredientId}↔${src?.scrapId}`, err);
+ }
+ }
+
+ for (const ref of envelope.refs || []) {
+ try {
+ await upsertRefFromPeer(ref);
+ stats.refs.applied++;
+ } catch (err) {
+ stats.refs.failed++;
+ recordFailure('ref', `${ref?.ingredientId}/${ref?.refKind}/${ref?.refId}`, err);
+ }
+ }
+
+ return stats;
+}
+
+// Per-kind cursor view for the federation orchestrator. The previous scalar
+// `getMaxSequence` collapsed the four BIGSERIALs into one max — that lied
+// about the protocol (one cursor can't represent four independent sequences).
+export { getMaxSequences };
diff --git a/server/services/dataSync.pipelineUniverse.test.js b/server/services/dataSync.pipelineUniverse.test.js
index bccdc8bd68..010c9d6240 100644
--- a/server/services/dataSync.pipelineUniverse.test.js
+++ b/server/services/dataSync.pipelineUniverse.test.js
@@ -905,8 +905,20 @@ describe('dataSync — per-category schema gate (cross-key isolation)', () => {
it('every versioned PORTOS_SCHEMA_VERSIONS key is reachable from some snapshot category', () => {
// A newly-versioned category can't ship without being wired into the
// per-category snapshot gate — otherwise its snapshot transfer is ungated.
+ //
+ // EXCEPTION: a few keys move via dedicated out-of-band sync endpoints
+ // (Postgres-backed federation), not the file-snapshot transfer. Those
+ // are gated at their own apply path with `compareSchemaVersions` and
+ // need not appear in the snapshot map. List them here so the coverage
+ // assertion stays honest about what the snapshot map IS and ISN'T
+ // responsible for.
+ const OUT_OF_BAND_SYNC_KEYS = new Set([
+ // catalog → `POST /api/catalog/sync/apply` (server/services/catalogSync.js)
+ 'catalog',
+ ]);
const covered = new Set(Object.values(dataSync.getSnapshotCategorySchemaKeys()).flat());
for (const key of Object.keys(PORTOS_SCHEMA_VERSIONS)) {
+ if (OUT_OF_BAND_SYNC_KEYS.has(key)) continue;
expect(covered.has(key)).toBe(true);
}
});
diff --git a/server/services/embeddings.js b/server/services/embeddings.js
new file mode 100644
index 0000000000..3f4536db01
--- /dev/null
+++ b/server/services/embeddings.js
@@ -0,0 +1,156 @@
+/**
+ * Provider-agnostic embedding service.
+ *
+ * Reads `settings.embeddings = { provider, model }` and routes embedText() to
+ * the configured backend (Ollama or LM Studio). When `provider === 'none'` or
+ * unset, returns `{ skipped: true }` — callers persist the row without an
+ * embedding and a future re-embed admin action backfills.
+ *
+ * Vector dim is pinned to 768 (matches the `vector(768)` column on memories +
+ * catalog_ingredients). Output dim is validated; a mismatch surfaces clearly
+ * rather than corrupting the index by silently inserting the wrong-shape vec.
+ */
+
+import { getSettings } from './settings.js';
+import * as ollama from './ollamaManager.js';
+import * as lmstudio from './lmStudioManager.js';
+
+export const EMBEDDING_DIM = 768;
+
+/**
+ * Read the configured provider + model.
+ * Defaults to `{ provider: 'none' }` so a missing settings slice cleanly
+ * degrades to no-embedding mode (rows persist; semantic search returns empty).
+ */
+export async function getEmbeddingsConfig() {
+ const settings = await getSettings();
+ const cfg = settings?.embeddings || {};
+ return {
+ provider: cfg.provider || 'none',
+ model: cfg.model || null,
+ };
+}
+
+/**
+ * Embed a single text. Returns:
+ * - `{ skipped: true, reason }` when provider is 'none'/unset
+ * - `{ success: true, embedding, model, provider, dimensions }` on success
+ * - `{ success: false, error, provider, model }` on failure
+ */
+export async function embedText(text, options = {}) {
+ if (!text || typeof text !== 'string' || text.trim().length === 0) {
+ return { skipped: true, reason: 'empty-text' };
+ }
+
+ const cfg = await getEmbeddingsConfig();
+ const provider = options.provider || cfg.provider;
+ const model = options.model || cfg.model || undefined;
+
+ if (provider === 'none' || !provider) {
+ return { skipped: true, reason: 'provider-disabled' };
+ }
+
+ let raw;
+ if (provider === 'ollama') {
+ raw = await ollama.getEmbeddings(text, { model, timeout: options.timeout });
+ } else if (provider === 'lmstudio') {
+ raw = await lmstudio.getEmbeddings(text, { model, timeout: options.timeout });
+ } else {
+ return { success: false, error: `Unknown embeddings provider: ${provider}`, provider, model };
+ }
+
+ if (!raw?.success) {
+ return { success: false, error: raw?.error || 'Embedding request failed', provider, model: raw?.model || model };
+ }
+
+ const embedding = raw.embedding || [];
+ if (embedding.length !== EMBEDDING_DIM) {
+ return {
+ success: false,
+ error: `Embedding model returned dim=${embedding.length}, expected ${EMBEDDING_DIM}. Pick a 768-dim model (e.g. nomic-embed-text).`,
+ provider,
+ model: raw.model || model,
+ };
+ }
+
+ return {
+ success: true,
+ embedding,
+ model: raw.model || model,
+ provider,
+ dimensions: embedding.length,
+ };
+}
+
+/**
+ * Build the embedding seed text for an ingredient — name + the few payload
+ * fields that carry narrative weight. Used by every catalog write path so
+ * the seed is consistent across commit, manual create, manual edit, and
+ * the admin backfill.
+ *
+ * Returns `''` when there's nothing worth embedding so callers can skip.
+ */
+export function ingredientEmbedSeed({ name, payload } = {}) {
+ const p = payload || {};
+ return [name, p.description, p.summary, p.notes, p.background]
+ .filter(Boolean)
+ .join(' ')
+ .slice(0, 4000);
+}
+
+/**
+ * Convenience wrapper: build the seed, embed it, and return the
+ * `{ embedding, embeddingModel }` slice ready to spread into createIngredient
+ * / updateIngredient. Returns `{}` when there's nothing to embed, the
+ * provider is unavailable, or the embed failed — the spread becomes a no-op.
+ *
+ * Logs non-skipped failures (provider down, dim mismatch, unknown error) so
+ * a quiet "search is broken" symptom has a paper trail. The batch path in
+ * embedBatch already logs; this single-text path was silent before.
+ */
+export async function embedIngredient(ingredient) {
+ const seed = ingredientEmbedSeed(ingredient);
+ if (!seed) return {};
+ const out = await embedText(seed).catch((err) => {
+ console.error(`🧬 embedIngredient threw: ${err?.message || err}`);
+ return null;
+ });
+ if (!out) return {};
+ if (out.skipped) return {};
+ if (!out.success) {
+ console.error(`🧬 embedIngredient failed: ${out.error || 'unknown'} (provider=${out.provider}, model=${out.model || 'unset'})`);
+ return {};
+ }
+ return { embedding: out.embedding, embeddingModel: out.model };
+}
+
+/**
+ * Embed an array of texts in parallel with a concurrency cap.
+ *
+ * Used by the catalog backfill and the `/api/catalog/embeddings/backfill`
+ * admin action. Per-text failures don't abort the batch — the corresponding
+ * slot in the result is `null`, the caller decides whether to skip or retry.
+ */
+export async function embedBatch(texts, options = {}) {
+ const concurrency = Math.max(1, options.concurrency || 4);
+ const results = new Array(texts.length).fill(null);
+
+ let next = 0;
+ const worker = async () => {
+ while (next < texts.length) {
+ const i = next++;
+ const out = await embedText(texts[i], options);
+ if (out.success) {
+ results[i] = { embedding: out.embedding, model: out.model, provider: out.provider };
+ } else if (out.skipped) {
+ results[i] = null;
+ } else {
+ console.error(`🧬 embedBatch[${i}] failed: ${out.error}`);
+ results[i] = null;
+ }
+ }
+ };
+
+ await Promise.all(Array.from({ length: concurrency }, worker));
+ return results;
+}
diff --git a/server/services/ollamaManager.js b/server/services/ollamaManager.js
index d7079e6f09..f1c72394c9 100644
--- a/server/services/ollamaManager.js
+++ b/server/services/ollamaManager.js
@@ -411,6 +411,72 @@ async function getVersion() {
return data?.version || null
}
+/**
+ * Get embeddings for `text` from a loaded Ollama model.
+ *
+ * Mirrors lmStudioManager.getEmbeddings shape — returns
+ * `{ success, embedding, model, dimensions }` so server/services/embeddings.js
+ * can route either backend through one interface.
+ *
+ * Ollama 0.2+ exposes `POST /api/embed` with `{ model, input }` → `{ embeddings: [[...]] }`.
+ * Older daemons only have `POST /api/embeddings` with `{ model, prompt }` → `{ embedding: [...] }`.
+ * We try the modern endpoint first, fall back on a 404/400.
+ *
+ * Auto-discovery: when `options.model` is omitted, scan installed models
+ * for a name matching a known embedding-model heuristic (embed/bge/nomic/mxbai)
+ * since Ollama tags don't carry a "type=embedding" flag.
+ */
+async function getEmbeddings(text, options = {}) {
+ const available = await checkOllamaAvailable()
+ if (!available) {
+ return { success: false, error: 'Ollama not available' }
+ }
+
+ let model = options.model
+ if (!model) {
+ const models = await getInstalledModels()
+ const guess = models.find((m) => /embed|bge|nomic|mxbai|gte|e5/i.test(m.id || m.name || ''))
+ if (!guess) {
+ return { success: false, error: 'No embedding model installed in Ollama' }
+ }
+ model = guess.id || guess.name
+ }
+
+ const tryEndpoint = async (endpoint, body) => {
+ const response = await fetchWithTimeout(`${config.baseUrl}${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ }, options.timeout ?? 30_000).catch((err) => ({ _err: err.message }))
+ if (response._err) return { ok: false, error: response._err }
+ if (!response.ok) {
+ const errBody = await response.text().catch(() => '')
+ return { ok: false, status: response.status, error: errBody.slice(0, 200) }
+ }
+ return { ok: true, data: await response.json() }
+ }
+
+ // Modern endpoint: `/api/embed` returns `{ embeddings: [[...]] }`
+ let result = await tryEndpoint('/api/embed', { model, input: text })
+ let embedding = result.ok ? (result.data?.embeddings?.[0] || []) : null
+
+ // Fallback for older Ollama: `/api/embeddings` returns `{ embedding: [...] }`
+ if (!result.ok || !embedding?.length) {
+ const fallback = await tryEndpoint('/api/embeddings', { model, prompt: text })
+ if (!fallback.ok) {
+ return { success: false, error: result.error || fallback.error, model }
+ }
+ embedding = fallback.data?.embedding || []
+ }
+
+ return {
+ success: true,
+ embedding,
+ model,
+ dimensions: embedding.length
+ }
+}
+
/**
* List models currently loaded into VRAM/unified memory (Ollama's `/api/ps`).
* Distinct from getInstalledModels(): a model on disk doesn't occupy memory
@@ -804,5 +870,6 @@ export {
ensureRunning,
ensureProviderReady,
isOllamaProvider,
- getServiceStatus
+ getServiceStatus,
+ getEmbeddings
}
diff --git a/server/services/socket.js b/server/services/socket.js
index 6522fbfca3..9055a33192 100644
--- a/server/services/socket.js
+++ b/server/services/socket.js
@@ -20,6 +20,7 @@ import { reviewEvents } from './review.js';
import { loopEvents } from './loops.js';
import { imageGenEvents } from './imageGenEvents.js';
import { importerEvents } from './importerEvents.js';
+import { catalogEvents } from './catalogEvents.js';
import { videoGenEvents } from './videoGen/events.js';
import { aiStatusEvents } from './aiStatusEvents.js';
import { wireProactiveTriggers } from './voice/proactiveTriggers.js';
@@ -565,6 +566,9 @@ export function initSocket(io) {
// Set up importer stage-progress forwarding (broadcast to all clients)
setupImporterEventForwarding();
+ // Set up catalog extraction-progress forwarding (broadcast to all clients)
+ setupCatalogEventForwarding();
+
// Wire proactive voice (CoS speaks first on high-severity errors, new tasks,
// and high-priority notifications — rate-limited per source).
setupProactiveSpeechForwarding();
@@ -583,6 +587,15 @@ function setupImporterEventForwarding() {
});
}
+let catalogForwardingSetup = false;
+function setupCatalogEventForwarding() {
+ if (catalogForwardingSetup) return;
+ catalogForwardingSetup = true;
+ catalogEvents.on('progress', (data) => {
+ if (ioInstance) ioInstance.emit('catalog:extract:progress', data);
+ });
+}
+
let aiStatusForwardingSetup = false;
function setupAIStatusEventForwarding() {
if (aiStatusForwardingSetup) return;
diff --git a/server/services/universeBuilder.js b/server/services/universeBuilder.js
index 626bf884ce..31edecb8c2 100644
--- a/server/services/universeBuilder.js
+++ b/server/services/universeBuilder.js
@@ -1011,7 +1011,7 @@ export async function insertUniverseWithId(input = {}) {
return next;
}
-export async function updateUniverse(id, patchOrMutator = {}) {
+export async function updateUniverse(id, patchOrMutator = {}, options = {}) {
// The queued section covers only the universe-builder read/modify/write
// cycle. The cross-file media-collection rename runs *after* the queue
// releases so a slow/stuck collection write can't block unrelated universe
@@ -1025,7 +1025,14 @@ export async function updateUniverse(id, patchOrMutator = {}) {
// `null`/`undefined` short-circuits the write and resolves with the
// unchanged record (no `updatedAt` bump, no rename cascade, no
// `recordUpdated` emit).
+ //
+ // `options.silent: true` suppresses the post-write `emitRecordUpdated`
+ // peer-sync fan-out — used by the bible→catalog backfill which would
+ // otherwise emit one event per universe at boot on every install. The
+ // universe is still persisted; peers learn about the change on the next
+ // normal sync cycle.
const isMutator = typeof patchOrMutator === 'function';
+ const { silent = false } = options;
const s = store();
const { merged, nameChanged, skipped, removedCharacterIds, prevEphemeral, nextEphemeral } = await s.queueRecordWrite(id, async () => {
const cur = await s.loadOne(id);
@@ -1260,7 +1267,9 @@ export async function updateUniverse(id, patchOrMutator = {}) {
console.log(`⚠️ universe: unsubscribe after ephemeralizing failed: ${err.message}`);
});
}
- emitRecordUpdated('universe', merged.id);
+ if (!silent) {
+ emitRecordUpdated('universe', merged.id);
+ }
return merged;
}