import { type Cell, Classification, EntityId, getEntityId, type IExtendedStorageTransaction, isCell, isLink, JSONSchema, type MemorySpace, Module, parseLink, Recipe, Runtime, type Schema, type SpaceCellContents, TYPE, URI, } from "@commontools/runner"; import * as favorites from "./favorites.ts"; import { ALL_CHARMS_ID } from "../../runner/src/builtins/well-known.ts"; import { type Session } from "@commontools/identity"; import { isRecord } from "@commontools/utils/types"; import { charmListSchema, charmSourceCellSchema, NameSchema, nameSchema, processSchema, } from "@commontools/runner/schemas"; import { type FavoriteList } from "@commontools/home-schemas"; /** * Extracts the ID from a charm. * @param charm - The charm to extract ID from * @returns The charm ID string, or undefined if no ID is found */ export function charmId(charm: Cell): string | undefined { const id = charm.entityId; if (!id) return undefined; const idValue = id["/"]; return typeof idValue === "string" ? idValue : undefined; } /** * Filters an array of charms by removing any that match the target cell */ function filterOutCell( list: Cell[]>, target: Cell, ): Cell[] { const resolvedTarget = target.resolveAsCell(); return list.get().filter((charm) => !charm.resolveAsCell().equals(resolvedTarget) ); } export class CharmManager { private space: MemorySpace; private charms: Cell[]>; private pinnedCharms: Cell[]>; private recentCharms: Cell[]>; private spaceCell: Cell; /** * Promise resolved when the charm manager gets the charm list. */ ready: Promise; constructor( private session: Session, public runtime: Runtime, ) { this.space = this.session.space; // Use the well-known ALL_CHARMS_ID entity for the charms cell this.charms = this.runtime.getCellFromEntityId( this.space, { "/": ALL_CHARMS_ID }, [], charmListSchema, ); this.pinnedCharms = this.runtime.getCell( this.space, "pinned-charms", charmListSchema, ); // Use the space DID as the cause - it's derived from the space name // and consistently available everywhere // For home space (where space DID = user identity DID), getHomeSpaceCellContents() // uses homeSpaceCellContentsSchema which includes favorites for proper sync/query behavior. const isHomeSpace = this.space === this.runtime.userIdentityDID; this.spaceCell = isHomeSpace ? this.runtime.getHomeSpaceCell() : this.runtime.getSpaceCell(this.space); const syncSpaceCellContents = Promise.resolve(this.spaceCell.sync()); // Initialize the space cell structure by linking to existing cells const linkSpaceCellContents = syncSpaceCellContents.then(() => this.runtime.editWithRetry((tx) => { const spaceCellWithTx = this.spaceCell.withTx(tx); let existingSpace: Partial | undefined; try { existingSpace = spaceCellWithTx.get() ?? undefined; } catch { existingSpace = undefined; } const recentCharmsField = spaceCellWithTx .key("recentCharms") .asSchema(charmListSchema); let recentCharmsValue: unknown; try { recentCharmsValue = recentCharmsField.get(); } catch { recentCharmsValue = undefined; } if (!Array.isArray(recentCharmsValue)) { recentCharmsField.set([]); } const nextSpaceValue: Partial = { ...(existingSpace ?? {}), // Set cells directly (not .get()) to create reactive links. // The Cell system automatically converts cells to links via convertCellsToLinks(). // This ensures wish("/").allCharms stays in sync when charms are added/removed. allCharms: this.charms.withTx(tx) as Cell, recentCharms: recentCharmsField.withTx(tx) as Cell, }; spaceCellWithTx.set(nextSpaceValue as SpaceCellContents); // defaultPattern will be linked later when the default pattern is found }) ); this.recentCharms = this.spaceCell .key("recentCharms") .asSchema(charmListSchema); // Initialize all cells in parallel. linkSpaceCellContents already // depends on syncSpaceCellContents internally, so ordering is preserved. this.ready = Promise.all([ this.syncCharms(this.charms), this.syncCharms(this.pinnedCharms), this.syncCharms(this.recentCharms), linkSpaceCellContents, ]).then(() => {}); } getSpace(): MemorySpace { return this.space; } getSpaceName(): string | undefined { return this.session.spaceName; } async synced(): Promise { await this.ready; return await this.runtime.storageManager.synced(); } async pin(charm: Cell) { await this.syncCharms(this.pinnedCharms); // Check if already pinned const resolvedCharm = charm.resolveAsCell(); const alreadyPinned = this.pinnedCharms.get().some((c) => c.resolveAsCell().equals(resolvedCharm) ); if (!alreadyPinned) { this.pinnedCharms.push(charm); await this.runtime.idle(); } } async unpin(charm: Cell) { await this.syncCharms(this.pinnedCharms); const { ok } = await this.runtime.editWithRetry((tx) => { const pinnedCharms = this.pinnedCharms.withTx(tx); const newPinnedCharms = filterOutCell(pinnedCharms, charm); if (newPinnedCharms.length !== pinnedCharms.get().length) { this.pinnedCharms.withTx(tx).set(newPinnedCharms); return true; } else { return false; } }); return !!ok; } getPinned(): Cell[]> { this.syncCharms(this.pinnedCharms); return this.pinnedCharms; } getSpaceCellContents(): Cell { return this.spaceCell; } /** * Link the default pattern cell to the space cell. * This should be called after the default pattern is created. * @param defaultPatternCell - The cell representing the default pattern */ async linkDefaultPattern( defaultPatternCell: Cell, ): Promise { await this.runtime.editWithRetry((tx) => { const spaceCellWithTx = this.spaceCell.withTx(tx); spaceCellWithTx.key("defaultPattern").set(defaultPatternCell.withTx(tx)); }); await this.runtime.idle(); } /** * Clears the defaultPattern link from the space cell. * Used when the default pattern is being deleted. */ async unlinkDefaultPattern(): Promise { await this.runtime.editWithRetry((tx) => { const spaceCellWithTx = this.spaceCell.withTx(tx); spaceCellWithTx.key("defaultPattern").set(undefined); }); await this.runtime.idle(); } /** * Get the default pattern cell from the space cell. * @returns The default pattern cell, or undefined if not set */ async getDefaultPattern(): Promise | undefined> { const cell = await this.spaceCell.key("defaultPattern").sync(); if (!cell.get().get()) { return undefined; } return this.get( cell.get(), true, nameSchema, ); } /** * Track a charm as recently viewed/interacted with. * Maintains a list of up to 10 most recent charms. * @param charm - The charm to track */ async trackRecentCharm(charm: Cell): Promise { const resolvedCharm = charm.resolveAsCell(); await this.runtime.editWithRetry((tx) => { const recentCharmsWithTx = this.recentCharms.withTx(tx); const recentCharms = recentCharmsWithTx.get() || []; // Remove any existing instance of this charm to avoid duplicates const filtered = recentCharms.filter((c) => !c.resolveAsCell().equals(resolvedCharm) ); // Add charm to the beginning of the list const updated = [charm, ...filtered]; // Trim to max 10 items const trimmed = updated.slice(0, 10); recentCharmsWithTx.set(trimmed); }); await this.runtime.idle(); } // FIXME(ja): this says it returns a list of charm, but it isn't! you will // have to call .get() to get the actual charm (this is missing the schema) // how can we fix the type here? getCharms(): Cell[]> { // Start syncing if not already syncing. Will trigger a change to the list // once loaded. this.syncCharms(this.charms); return this.charms; } private addCharms( newCharms: Cell[], tx: IExtendedStorageTransaction, ) { const charms = this.charms.withTx(tx); newCharms.forEach((charm) => { if (!charms.get().some((otherCharm) => otherCharm.equals(charm))) { charms.push(charm); } }); } async add(newCharms: Cell[], tx?: IExtendedStorageTransaction) { await this.syncCharms(this.charms); await this.runtime.idle(); if (tx) { this.addCharms(newCharms, tx); } else { await this.runtime.editWithRetry((tx) => { this.addCharms(newCharms, tx); }); } } syncCharms(cell: Cell[]>) { // TODO(@ubik2) We use elevated permissions here temporarily. // Our request for the charm list will walk the schema tree, and that will // take us into classified data of charms. If that happens, we still want // this bit to work, so we elevate this request. const privilegedSchema = { ...charmListSchema, ifc: { classification: [Classification.Secret] }, } as const satisfies JSONSchema; return cell.asSchema(privilegedSchema).sync(); } async get( id: string | Cell, runIt: boolean, asSchema: S, ): Promise>>; async get( id: string | Cell, runIt?: boolean, asSchema?: JSONSchema, ): Promise>; async get( id: string | Cell, runIt: boolean = false, asSchema?: JSONSchema, ): Promise> { // Get the charm cell const charm: Cell = isCell(id) ? id : this.runtime.getCellFromEntityId(this.space, { "/": id }); if (runIt) { // start() handles sync, recipe loading, and running // It's idempotent - no effect if already running await this.runtime.start(charm); } else { // Just sync the cell if not running await charm.sync(); } // If caller provided a schema, use it if (asSchema) { return charm.asSchema(asSchema); } // Otherwise, get result cell with schema from processCell.resultRef // The resultRef was created with includeSchema: true during setup const processCell = charm.getSourceCell(); if (processCell) { const resultWithSchema = processCell.key("resultRef").resolveAsCell(); if (resultWithSchema) { return resultWithSchema as Cell; } } // Fallback: return charm without schema return charm as Cell; } getLineage(charm: Cell) { return charm.getSourceCell(charmSourceCellSchema)?.key("lineage").get() ?? []; } getLLMTrace(charm: Cell): string | undefined { return charm.getSourceCell(charmSourceCellSchema)?.key("llmRequestId") .get() ?? undefined; } /** * Find all charms that the given charm reads data from via aliases or links. * This identifies dependencies that the charm has on other charms. * @param charm The charm to check * @returns Array of charms that are read from */ async getReadingFrom(charm: Cell): Promise[]> { // Get all charms that might be referenced const allCharms = this.getCharms().get(); const result: Cell[] = []; const seenEntityIds = new Set(); // Track entities we've already processed const maxDepth = 10; // Prevent infinite recursion const maxResults = 50; // Prevent too many results from overwhelming the UI const resolvedCharm = charm.resolveAsCell(); if (!charm) return result; try { // Get the argument data - this is where references to other charms are stored const argumentCell = await this.getArgument(charm); if (!argumentCell) return result; // Get the raw argument value let argumentValue; try { argumentValue = argumentCell.getRaw(); } catch (err) { console.debug("Error getting argument value:", err); return result; } // Helper function to add a matching charm to the result const addMatchingCharm = (docId: EntityId) => { if (!docId || !docId["/"]) return; const entityIdStr = typeof docId["/"] === "string" ? docId["/"] : JSON.stringify(docId["/"]); // Skip if we've already processed this entity if (seenEntityIds.has(entityIdStr)) return; seenEntityIds.add(entityIdStr); // Find matching charm by entity ID const matchingCharm = allCharms.find((c) => { const cId = getEntityId(c); return cId && docId["/"] === cId["/"]; }); if (matchingCharm) { const resolvedMatching = matchingCharm.resolveAsCell(); const isNotSelf = !resolvedMatching.equals(resolvedCharm); const notAlreadyInResult = !result.some((c) => c.resolveAsCell().equals(resolvedMatching) ); if (isNotSelf && notAlreadyInResult && result.length < maxResults) { result.push(matchingCharm); } } }; // Helper function to follow alias chain to its source const followSourceToResultRef = ( cell: Cell, visited = new Set(), depth = 0, ): EntityId | undefined => { if (depth > maxDepth) return undefined; // Prevent infinite recursion try { const docId = cell.entityId; if (!docId || !docId["/"]) return undefined; const docIdStr = typeof docId["/"] === "string" ? docId["/"] : JSON.stringify(docId["/"]); // Prevent cycles if (visited.has(docIdStr)) return undefined; visited.add(docIdStr); try { // If document has a sourceCell, follow it const value = cell.getRaw(); const sourceCell = cell.getSourceCell(); if (sourceCell) { return followSourceToResultRef(sourceCell, visited, depth + 1); } else if (isRecord(value) && value.resultRef) { // If we've reached the end and have a resultRef, return it const { id: source } = parseLink(value.resultRef, cell)!; if (source) return getEntityId(source); } } catch (err) { // Ignore errors getting doc value console.debug("Error getting doc value:", err); } return docId; // Return the current document's ID if no further references } catch (err) { console.debug("Error in followSourceToResultRef:", err); return undefined; } }; // Find references in the argument structure const processValue = ( value: unknown, parent: Cell, visited = new Set(), // Track objects directly, not string representations depth = 0, ) => { if (!isRecord(value) || depth > maxDepth) return; // Prevent cycles in our traversal by tracking object references directly if (visited.has(value)) return; visited.add(value); try { // Handle values that are themselves cells, docs, or cell links if (isLink(value)) { const link = parseLink(value, parent); if (link.id) { addMatchingCharm(getEntityId(link.id)!); } const sourceRefId = followSourceToResultRef( this.runtime.getCellFromLink(link), new Set(), 0, ); if (sourceRefId) addMatchingCharm(sourceRefId); } else if (Array.isArray(value)) { // Safe recursive processing of arrays for (let i = 0; i < value.length; i++) { try { processValue( value[i], parent, new Set([...visited]), depth + 1, ); } catch (err) { console.debug( `Error processing array item at index ${i}:`, err, ); } } } else if (typeof value === "object") { // Process regular object properties const keys = Object.keys(value); for (let i = 0; i < keys.length; i++) { const key = keys[i]; try { processValue( value[key], parent, new Set([...visited]), depth + 1, ); } catch (err) { console.debug( `Error processing object property '${key}':`, err, ); } } } } catch (err) { console.debug("Error in processValue:", err); } }; // Start processing from the argument value if (argumentValue && typeof argumentValue === "object") { processValue( argumentValue, argumentCell, new Set(), 0, ); } } catch (error) { console.debug("Error finding references in charm arguments:", error); // Don't throw the error - return an empty result instead } return result; } /** * Find all charms that read data from the given charm via aliases or links. * This identifies which charms depend on this charm. * @param charm The charm to check * @returns Array of charms that read from this charm */ async getReadByCharms(charm: Cell): Promise[]> { // Get all charms to check const allCharms = this.getCharms().get(); const result: Cell[] = []; const seenEntityIds = new Set(); // Track entities we've already processed const maxDepth = 10; // Prevent infinite recursion const maxResults = 50; // Prevent too many results from overwhelming the UI if (!charm) return result; const charmId = getEntityId(charm); if (!charmId) return result; const resolvedCharm = charm.resolveAsCell(); // Helper function to add a matching charm to the result const addReadingCharm = (otherCharm: Cell) => { const otherCharmId = getEntityId(otherCharm); if (!otherCharmId || !otherCharmId["/"]) return; const entityIdStr = typeof otherCharmId["/"] === "string" ? otherCharmId["/"] : JSON.stringify(otherCharmId["/"]); // Skip if we've already processed this entity if (seenEntityIds.has(entityIdStr)) return; seenEntityIds.add(entityIdStr); const resolvedOther = otherCharm.resolveAsCell(); const notAlreadyInResult = !result.some((c) => c.resolveAsCell().equals(resolvedOther) ); if (notAlreadyInResult && result.length < maxResults) { result.push(otherCharm); } }; // Helper function to follow alias chain to its source const followSourceToResultRef = ( cell: Cell, visited = new Set(), depth = 0, ): URI | undefined => { if (depth > maxDepth) return undefined; // Prevent infinite recursion const cellURI = cell.sourceURI; // Prevent cycles if (visited.has(cellURI)) return undefined; visited.add(cellURI); // If document has a sourceCell, follow it const value = cell.getRaw(); const sourceCell = cell.getSourceCell(); if (sourceCell) { return followSourceToResultRef(sourceCell, visited, depth + 1); } // If we've reached the end and have a resultRef, return it if (isRecord(value) && value.resultRef) { return parseLink(value.resultRef, cell)?.id; } return cellURI; // Return the current document's ID if no further references }; // Helper to check if a document refers to our target charm const checkRefersToTarget = ( value: unknown, parent: Cell, visited = new Set(), // Track objects directly, not string representations depth = 0, ): boolean => { if (!isRecord(value) || depth > maxDepth) return false; // Prevent cycles in our traversal by tracking object references directly if (visited.has(value)) return false; visited.add(value); try { if (isLink(value)) { try { const link = parseLink(value, parent); // Check if the cell link's doc is our target if (link.id === charm.sourceURI) return true; // Check if cell link's source chain leads to our target const sourceResultRefURI = followSourceToResultRef( this.runtime.getCellFromLink(link), new Set(), 0, ); if (sourceResultRefURI === charm.sourceURI) return true; } catch (err) { console.debug( "Error handling cell link in checkRefersToTarget:", err, ); } return false; // Don't process cell link contents } // Safe recursive processing of arrays if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { try { if ( checkRefersToTarget( value[i], parent, new Set([...visited]), depth + 1, ) ) { return true; } } catch (err) { console.debug(`Error checking array item at index ${i}:`, err); } } } else if (isRecord(value)) { // Process regular object properties const keys = Object.keys(value); for (let i = 0; i < keys.length; i++) { const key = keys[i]; try { if ( checkRefersToTarget( value[key], parent, new Set([...visited]), depth + 1, ) ) { return true; } } catch (err) { console.debug(`Error checking object property '${key}':`, err); } } } } catch (err) { console.debug("Error in checkRefersToTarget:", err); } return false; }; // Check each charm to see if it references this charm for (const otherCharm of allCharms) { if (otherCharm.resolveAsCell().equals(resolvedCharm)) continue; // Skip self if (checkRefersToTarget(otherCharm, otherCharm, new Set(), 0)) { addReadingCharm(otherCharm); continue; // Skip additional checks for this charm } // Also specifically check the argument data where references are commonly found try { const argumentCell = await this.getArgument(otherCharm); if (argumentCell) { const argumentValue = argumentCell.getRaw(); // Check if the argument references our target if (argumentValue && typeof argumentValue === "object") { if ( checkRefersToTarget( argumentValue, argumentCell, new Set(), 0, ) ) { addReadingCharm(otherCharm); } } } } catch (_) { // Error checking argument references for charm } } return result; } async getCellById( id: EntityId | string, path: string[] = [], schema?: JSONSchema, ): Promise> { const cell = this.runtime.getCellFromEntityId( this.space, id, path, schema, ); await cell.sync(); return cell; } // Return Cell with argument content, loading the recipe if needed. async getArgument( charm: Cell, ): Promise> { const source = charm.getSourceCell(processSchema); const recipeId = source?.get()?.[TYPE]!; if (!recipeId) throw new Error("charm missing recipe ID"); const recipe = await this.runtime.recipeManager.loadRecipe( recipeId, this.space, ); return source.key("argument").asSchema(recipe.argumentSchema); } getResult( charm: Cell, ): Cell { // Get result cell with schema from processCell.resultRef const processCell = charm.getSourceCell(); if (processCell) { return processCell.key("resultRef").resolveAsCell(); } // Fallback: return charm without schema return charm; } // note: removing a charm doesn't clean up the charm's cells async remove(charm: Cell) { await Promise.all([ this.syncCharms(this.charms), this.syncCharms(this.pinnedCharms), ]); await this.unpin(charm); // Check if this is the default pattern and clear the link const defaultPattern = await this.getDefaultPattern(); if ( defaultPattern && charm.resolveAsCell().equals(defaultPattern.resolveAsCell()) ) { await this.unlinkDefaultPattern(); } const { ok } = await this.runtime.editWithRetry((tx) => { const charms = this.charms.withTx(tx); // Remove from main list const newCharms = filterOutCell(charms, charm); if (newCharms.length !== charms.get().length) { charms.set(newCharms); return true; } else { return false; } }); return !!ok; } async runPersistent( recipe: Recipe | Module, inputs?: unknown, cause?: unknown, llmRequestId?: string, options?: { start?: boolean }, ): Promise> { const start = options?.start ?? true; const charm = await this.setupPersistent( recipe, inputs, cause, llmRequestId, ); if (start) { await this.startCharm(charm); } return charm; } // Consistently return the `Cell` of charm with // id `charmId`, applies the provided `recipe` (which may be // its current recipe -- useful when we are only updating inputs), // and optionally applies `inputs` if provided. async runWithRecipe( recipe: Recipe | Module, charmId: string, inputs?: object, options?: { start?: boolean }, ): Promise> { const charm = this.runtime.getCellFromEntityId(this.space, { "/": charmId, }); await charm.sync(); const start = options?.start ?? true; if (start) { await this.runtime.runSynced(charm, recipe, inputs); } else { this.runtime.setup(undefined, recipe, inputs ?? {}, charm); } await this.syncRecipe(charm); await this.add([charm]); return charm; } /** * Prepare a new charm by setting up its process/result cells and recipe * metadata without scheduling the recipe's nodes. */ async setupPersistent( recipe: Recipe | Module, inputs?: unknown, cause?: unknown, llmRequestId?: string, ): Promise> { await this.runtime.idle(); const charm = this.runtime.getCell( this.space, cause, recipe.resultSchema, ); this.runtime.setup(undefined, recipe, inputs ?? {}, charm); await this.syncRecipe(charm); await this.add([charm]); if (llmRequestId) { this.runtime.editWithRetry((tx) => { charm.getSourceCell(charmSourceCellSchema)?.key("llmRequestId") .withTx(tx) .set(llmRequestId); }); } return charm; } /** Start scheduling and running a prepared charm. */ async startCharm(charmOrId: string | Cell): Promise { const charm = typeof charmOrId === "string" ? await this.get(charmOrId) : charmOrId; if (!charm) throw new Error("Charm not found"); this.runtime.start(charm); await this.runtime.idle(); await this.synced(); } /** Stop a running charm (no-op if not running). */ async stopCharm(charmOrId: string | Cell): Promise { const charm = typeof charmOrId === "string" ? await this.get(charmOrId) : charmOrId; if (!charm) throw new Error("Charm not found"); this.runtime.runner.stop(charm); await this.runtime.idle(); } // FIXME(JA): this really really really needs to be revisited async syncRecipe(charm: Cell) { await charm.sync(); // When we subscribe to a doc, our subscription includes the doc's source, // so get that. const sourceCell = charm.getSourceCell(); if (!sourceCell) throw new Error("charm missing source cell"); await sourceCell.sync(); const recipeId = sourceCell.get()?.[TYPE]; if (!recipeId) throw new Error("charm missing recipe ID"); return await this.syncRecipeById(recipeId); } async syncRecipeById(recipeId: string) { if (!recipeId) throw new Error("recipeId is required"); const recipe = await this.runtime.recipeManager.loadRecipe( recipeId, this.space, ); return recipe; } async sync(entity: Cell, _waitForStorage: boolean = false) { await entity.sync(); } // Returns the charm from one of our active charm lists if it is present, // or undefined if it is not getActiveCharm(charmCell: Cell) { const resolved = charmCell.resolveAsCell(); return this.charms.get().find((charm) => charm.resolveAsCell().equals(resolved) ) ?? this.pinnedCharms.get().find((charm) => charm.resolveAsCell().equals(resolved) ); } async link( sourceCharmId: string, sourcePath: (string | number)[], targetCharmId: string, targetPath: (string | number)[], ): Promise { // Get source cell (charm or arbitrary cell) const { cell: sourceCell, isCharm: _ } = await getCellByIdOrCharm( this, sourceCharmId, "Source", ); // Get target cell (charm or arbitrary cell) const { cell: targetCell, isCharm: targetIsCharm } = await getCellByIdOrCharm( this, targetCharmId, "Target", ); await this.runtime.editWithRetry((tx) => { // Navigate to the source path // Cannot navigate `Cell` // FIXME: types // deno-lint-ignore no-explicit-any let sourceResultCell = sourceCell.withTx(tx) as Cell; // For charms, manager.get() already returns the result cell, so no need to add "result" for (const segment of sourcePath) { sourceResultCell = sourceResultCell.key(segment); } // Navigate to the target path const targetKey = targetPath.pop(); if (targetKey === undefined) { throw new Error("Target path cannot be empty"); } // Cannot navigate `Cell` // FIXME: types // deno-lint-ignore no-explicit-any let targetInputCell = targetCell.withTx(tx) as Cell; if (targetIsCharm) { // For charms, target fields are in the source cell's argument const sourceCell = targetCell.getSourceCell(processSchema); if (!sourceCell) { throw new Error("Target charm has no source cell"); } targetInputCell = sourceCell.key("argument").withTx(tx); } for (const segment of targetPath) { targetInputCell = targetInputCell.key(segment); } targetInputCell.key(targetKey).set(sourceResultCell); }); await this.runtime.idle(); await this.synced(); } /** * Add a charm to the user's favorites (in home space) * @param charm - The charm to add to favorites */ addFavorite(charm: Cell): Promise { return favorites.addFavorite(this.runtime, charm); } /** * Remove a charm from the user's favorites (in home space) * @param charm - The charm to remove from favorites * @returns true if the charm was removed, false if it wasn't in favorites */ removeFavorite(charm: Cell): Promise { return favorites.removeFavorite(this.runtime, charm); } /** * Check if a charm is in the user's favorites (in home space) * @param charm - The charm to check * @returns true if the charm is favorited, false otherwise */ isFavorite(charm: Cell): boolean { return favorites.isFavorite(this.runtime, charm); } /** * Get the favorites cell from the home space * @returns Cell containing the array of favorite entries with cell and tag */ getFavorites(): Cell { return favorites.getHomeFavorites(this.runtime); } } export const getRecipeIdFromCharm = (charm: Cell): string => { const sourceCell = charm.getSourceCell(processSchema); if (!sourceCell) throw new Error("charm missing source cell"); return sourceCell.get()?.[TYPE]!; }; async function getCellByIdOrCharm( manager: CharmManager, cellId: string, label: string, ): Promise<{ cell: Cell; isCharm: boolean }> { try { // Try to get as a charm first const charm = await manager.get(cellId, true); if (!charm) { throw new Error(`Charm ${cellId} not found`); } return { cell: charm, isCharm: true }; } catch (_) { // If manager.get() fails (e.g., "recipeId is required"), try as arbitrary cell ID try { const cell = await manager.getCellById({ "/": cellId }); // Check if this cell is actually a charm by looking at the charms list const charms = manager.getCharms().get(); const isActuallyCharm = charms.some((charm) => { const id = charmId(charm); // If we can't get the charm ID, it's not a valid charm if (!id) return false; return id === cellId; }); return { cell, isCharm: isActuallyCharm }; } catch (_) { throw new Error(`${label} "${cellId}" not found as charm or cell`); } } }