import { computed, equals, generateText, handler, NAME, pattern, safeDateNow, UI, Writable, } from "commonfabric"; import FavoritesManager from "./favorites-manager.tsx"; import Journal from "./journal.tsx"; // Types from favorites-manager.tsx and journal.tsx type Favorite = { cell: { [NAME]?: string }; // Discovery tags snapshotted from the piece's schema when favorited. tags: string[]; userTags: string[]; spaceName?: string; }; type JournalSnapshot = { name?: string; schemaTag?: string; valueExcerpt?: string; }; type JournalEntry = { timestamp?: number; eventType?: string; subject?: Writable; snapshot?: JournalSnapshot; narrative?: string; narrativePending?: boolean; tags?: string[]; space?: string; }; type SpaceEntry = { name: string; did?: string; }; /** * Capture a snapshot of a cell's current state for journaling. * Extracts name, schema tag, and a value excerpt. * Uses schema to properly resolve nested cell references. */ function captureSnapshot( cell: Writable<{ [NAME]?: string }>, schemaTag?: string, ): JournalSnapshot { let name = ""; let valueExcerpt = ""; try { const value = cell.get(); if (value && typeof value === "object" && NAME in value) { name = value[NAME] || ""; } } catch { // Ignore errors - name is optional } try { // Try to get the schema from the cell to properly resolve nested data let schemaCell = cell as any; try { const { schema } = schemaCell.asSchemaFromLinks?.()?.getAsNormalizedFullLink?.() || {}; if (schema) { schemaCell = schemaCell.asSchema(schema); } } catch { // Ignore schema errors, fall back to direct get } const value = schemaCell.get(); if (value !== undefined) { const str = JSON.stringify(value); // Capture up to 2000 chars to give LLM more context about content valueExcerpt = str.length > 2000 ? str.slice(0, 2000) + "..." : str; } } catch { // Ignore errors - excerpt is optional } return { name, schemaTag: schemaTag || "", valueExcerpt }; } // Handler to add a favorite const addFavorite = handler< { piece: Writable<{ [NAME]?: string }>; tags?: string[]; spaceName?: string }, { favorites: Writable; journal: Writable } >(({ piece, tags, spaceName }, { favorites, journal }) => { const current = favorites.get(); if (!current.some((f) => f && equals(f.cell, piece))) { // Discovery tags are derived by the client and passed in; the handler // just stores them. const finalTags = tags ?? []; const hashTags = finalTags.map((t) => `#${t}`); favorites.push({ cell: piece, tags: finalTags, userTags: [], spaceName, }); // Add journal entry for the favorite action const snapshot = captureSnapshot(piece, hashTags.join(" ")); journal.push({ timestamp: safeDateNow(), eventType: "piece:favorited", subject: piece, snapshot, narrative: "", narrativePending: true, tags: hashTags, space: spaceName || "", }); } }); // Handler to remove a favorite const removeFavorite = handler< { piece: Writable }, { favorites: Writable; journal: Writable } >(({ piece }, { favorites, journal }) => { const favorite = favorites.get().find((f) => f && equals(f.cell, piece)); if (favorite) { const hashTags = (favorite.tags ?? []).map((t) => `#${t}`); // Capture snapshot before removing const snapshot = captureSnapshot( piece as Writable<{ [NAME]?: string }>, hashTags.join(" "), ); favorites.remove(favorite); // Add journal entry for the unfavorite action journal.push({ timestamp: safeDateNow(), eventType: "piece:unfavorited", subject: piece as any, snapshot, narrative: "", narrativePending: true, tags: hashTags, space: favorite.spaceName || "", }); } }); // Handler to add a journal entry const addJournalEntry = handler< { entry: JournalEntry }, { journal: Writable } >(({ entry }, { journal }) => { journal.push(entry); }); // Handler to add a space to the managed list const addSpaceHandler = handler< { detail: { message: string } }, { spaces: Writable } >(({ detail }, { spaces }) => { const name = detail?.message?.trim(); if (!name) return; const current = spaces.get(); if (!current.some((s) => s.name === name)) { spaces.push({ name }); } }); // Handler to remove a space from the managed list const removeSpaceHandler = handler< Record, { name: string; spaces: Writable } >((_, { name, spaces }) => { const current = spaces.get(); const filtered = current.filter((s) => s.name !== name); spaces.set(filtered); }); export default pattern((_) => { // OWN the data cells (.for for id stability) const favorites = new Writable([]).for("favorites"); const journal = new Writable([]).for("journal"); const spaces = new Writable([]).for("spaces"); const defaultAppUrl = new Writable("").for("defaultAppUrl"); // Child components use wish() to access favorites/journal through defaultPattern const favoritesComponent = FavoritesManager({}); const journalComponent = Journal({}); const activeTab = new Writable("spaces").for("activeTab"); // === REACTIVE NARRATIVE ENRICHMENT === // LLM Error Handling: generateText returns { pending, result, error }. // On LLM failure, `error` is set and `result` remains undefined. The writeback // computation checks for errors and marks entries as failed to prevent retry loops. // Find the first pending entry that needs a narrative const pendingEntry = computed(() => journal.get().find((e) => e.narrativePending && !e.narrative) ); // Event type descriptions for narrative generation const eventDescriptions: Record = { "piece:favorited": "favorited", "piece:unfavorited": "unfavorited", "piece:created": "created", "piece:modified": "modified", "space:entered": "entered a space", }; // Generate narrative for pending entry // Uses context parameter to properly serialize cell content with schema const narrativeGen = generateText({ prompt: computed(() => { const entry = pendingEntry; if (!entry) return ""; // No-op when nothing pending const eventDesc = eventDescriptions[entry.eventType || ""] || entry.eventType; return `Generate a brief journal entry (2-3 sentences) describing this user action. Event: User ${eventDesc} a piece Piece name: ${entry.snapshot?.name || "unnamed"} The full content of the piece is available in the context below. IMPORTANT: Read and analyze the CONTENT, not just the title. If it's a note, what is it about? If it has data, what kind? Extract meaningful insights about their interests, work, or life from the actual content. Write in past tense, personal style. Focus on: 1. What the content reveals about the user's interests/goals 2. Any specific topics, projects, or themes in the data 3. What this might indicate about what they care about`; }), system: "You analyze user activity and content to understand their interests. The piece content is provided in the context. Look at the actual data/content, not just titles. Extract meaningful insights about what they care about, work on, or are interested in.", model: "anthropic:claude-sonnet-4-5", // Pass the subject cell as context - system will serialize it properly context: computed(() => { const entry = pendingEntry; if (!entry?.subject) return {}; return { favoritedPiece: entry.subject }; }), }); // Idempotent writeback - update entry when narrative is ready (or on error) const writeNarrative = computed(() => { const result = narrativeGen.result; const pending = narrativeGen.pending; const error = (narrativeGen as any).error; const entry = pendingEntry; // Guard: only proceed when not pending and we have an entry if (pending || !entry) return null; // Idempotent check: already written? if (entry.narrative !== "") return null; // Find the entry in the array const entries = journal.get(); const idx = entries.findIndex((e) => e.timestamp === entry.timestamp); if (idx === -1) return null; // Handle error: mark as processed to prevent retry loop if (error && !result) { const updatedEntry = { ...entries[idx], narrative: "[Failed to generate narrative]", narrativePending: false, }; const newEntries = [...entries]; newEntries[idx] = updatedEntry; journal.set(newEntries); return null; } // Guard: need a result to proceed if (!result) return null; // Create updated entry const updatedEntry = { ...entries[idx], narrative: result, narrativePending: false, }; // Replace in array and set const newEntries = [...entries]; newEntries[idx] = updatedEntry; journal.set(newEntries); return result; }); // Reference writeNarrative to ensure it's evaluated (required for reactive side effects) void writeNarrative; return { [NAME]: `Home`, [UI]: (

homespace

Spaces Journal Favorites {journalComponent} {favoritesComponent}

My Spaces

{spaces.map((space) => (
))} {computed(() => spaces.get().length === 0) ? (

No spaces yet. Add one below.

) : null}

Add or Create Space

Type a name and press enter. Click the link to navigate.

Settings

Pattern URL for new spaces. Leave empty for system default.
), // Exported data favorites, journal, spaces, defaultAppUrl, // Exported handlers (bound to state cells for external callers) addFavorite: addFavorite({ favorites, journal }), removeFavorite: removeFavorite({ favorites, journal }), addJournalEntry: addJournalEntry({ journal }), }; });