Skip to content

Commit 465424f

Browse files
authored
Introduce "space cell" managed by charm manager (#1947)
* Introduce 'space cell' with pointers to other well known cells Also, remove trash concept * `/` points to space cell, `#` points to default-pattern * Update wish paths * Expose clearChat tool * Clean up code * Remove DEFAULT_PATTERN_ID * Track recent charms * Fix typecheck * Define `recentCharms` as a link * Sync cell before use * Comment use of space DID * Added wish keys and clarified semantics * Update paths in patterns * Clean up code * Don't call resolveAsKey() before cell is ready * Fix #mentionable resolution
1 parent 260e93a commit 465424f

File tree

14 files changed

+518
-178
lines changed

14 files changed

+518
-178
lines changed

packages/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,8 @@ export type StripCell<T> = T extends Cell<infer U> ? StripCell<U>
619619
: T extends object ? { [K in keyof T]: StripCell<T[K]> }
620620
: T;
621621

622+
export type WishKey = `/${string}` | `#${string}`;
623+
622624
export type Schema<
623625
T extends JSONSchema,
624626
Root extends JSONSchema = T,

packages/charm/src/manager.ts

Lines changed: 110 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
UI,
1919
URI,
2020
} from "@commontools/runner";
21+
import { ALL_CHARMS_ID } from "../../runner/src/builtins/well-known.ts";
2122
import { vdomSchema } from "@commontools/html";
2223
import { type Session } from "@commontools/identity";
2324
import { isObject, isRecord } from "@commontools/utils/types";
@@ -55,6 +56,18 @@ export const charmListSchema = {
5556
items: { asCell: true },
5657
} as const satisfies JSONSchema;
5758

59+
export const spaceCellSchema = {
60+
type: "object",
61+
properties: {
62+
// Each field is a Cell<> reference to another cell
63+
allCharms: { ...charmListSchema, asCell: true },
64+
defaultPattern: { type: "object", asCell: true },
65+
recentCharms: { ...charmListSchema, asCell: true },
66+
},
67+
} as const satisfies JSONSchema;
68+
69+
export type SpaceCell = Schema<typeof spaceCellSchema>;
70+
5871
export const charmLineageSchema = {
5972
type: "object",
6073
properties: {
@@ -120,7 +133,8 @@ export class CharmManager {
120133

121134
private charms: Cell<Cell<unknown>[]>;
122135
private pinnedCharms: Cell<Cell<unknown>[]>;
123-
private trashedCharms: Cell<Cell<unknown>[]>;
136+
private recentCharms: Cell<Cell<unknown>[]>;
137+
private spaceCell: Cell<SpaceCell>;
124138

125139
/**
126140
* Promise resolved when the charm manager gets the charm list.
@@ -133,26 +147,77 @@ export class CharmManager {
133147
) {
134148
this.space = this.session.space;
135149

136-
this.charms = this.runtime.getCell(
150+
// Use the well-known ALL_CHARMS_ID entity for the charms cell
151+
this.charms = this.runtime.getCellFromEntityId(
137152
this.space,
138-
"charms",
153+
{ "/": ALL_CHARMS_ID },
154+
[],
139155
charmListSchema,
140156
);
141157
this.pinnedCharms = this.runtime.getCell(
142158
this.space,
143159
"pinned-charms",
144160
charmListSchema,
145161
);
146-
this.trashedCharms = this.runtime.getCell(
162+
// Use the space DID as the cause - it's derived from the space name
163+
// and consistently available everywhere
164+
this.spaceCell = this.runtime.getCell(
147165
this.space,
148-
"trash",
149-
charmListSchema,
166+
this.space, // Space DID is stable per space and available to all clients
167+
spaceCellSchema,
168+
);
169+
170+
const syncSpaceCell = Promise.resolve(this.spaceCell.sync());
171+
172+
// Initialize the space cell structure by linking to existing cells
173+
const linkSpaceCell = syncSpaceCell.then(() =>
174+
this.runtime.editWithRetry((tx) => {
175+
const spaceCellWithTx = this.spaceCell.withTx(tx);
176+
177+
let existingSpace: Partial<SpaceCell> | undefined;
178+
try {
179+
existingSpace = spaceCellWithTx.get() ?? undefined;
180+
} catch {
181+
existingSpace = undefined;
182+
}
183+
184+
const recentCharmsField = spaceCellWithTx
185+
.key("recentCharms")
186+
.asSchema(charmListSchema);
187+
188+
let recentCharmsValue: unknown;
189+
try {
190+
recentCharmsValue = recentCharmsField.get();
191+
} catch {
192+
recentCharmsValue = undefined;
193+
}
194+
195+
if (!Array.isArray(recentCharmsValue)) {
196+
recentCharmsField.set([]);
197+
}
198+
199+
const nextSpaceValue: Partial<SpaceCell> = {
200+
...(existingSpace ?? {}),
201+
allCharms: this.charms.withTx(tx),
202+
recentCharms: recentCharmsField.withTx(tx),
203+
};
204+
205+
spaceCellWithTx.set(nextSpaceValue);
206+
207+
// defaultPattern will be linked later when the default pattern is found
208+
})
150209
);
151210

211+
this.recentCharms = this.spaceCell
212+
.key("recentCharms")
213+
.asSchema(charmListSchema);
214+
152215
this.ready = Promise.all([
153216
this.syncCharms(this.charms),
154217
this.syncCharms(this.pinnedCharms),
155-
this.syncCharms(this.trashedCharms),
218+
this.syncCharms(this.recentCharms),
219+
syncSpaceCell,
220+
linkSpaceCell,
156221
]).then(() => {});
157222
}
158223

@@ -210,47 +275,51 @@ export class CharmManager {
210275
return this.pinnedCharms;
211276
}
212277

213-
getTrash(): Cell<Cell<unknown>[]> {
214-
this.syncCharms(this.trashedCharms);
215-
return this.trashedCharms;
278+
getSpaceCell(): Cell<SpaceCell> {
279+
return this.spaceCell;
216280
}
217281

218-
async restoreFromTrash(idOrCharm: string | EntityId | Cell<unknown>) {
219-
await this.syncCharms(this.trashedCharms);
220-
await this.syncCharms(this.charms);
282+
/**
283+
* Link the default pattern cell to the space cell.
284+
* This should be called after the default pattern is created.
285+
* @param defaultPatternCell - The cell representing the default pattern
286+
*/
287+
async linkDefaultPattern(
288+
defaultPatternCell: Cell<any>,
289+
): Promise<void> {
290+
await this.runtime.editWithRetry((tx) => {
291+
const spaceCellWithTx = this.spaceCell.withTx(tx);
292+
spaceCellWithTx.key("defaultPattern").set(defaultPatternCell.withTx(tx));
293+
});
294+
await this.runtime.idle();
295+
}
221296

222-
const error = await this.runtime.editWithRetry((tx) => {
223-
const trashedCharms = this.trashedCharms.withTx(tx);
297+
/**
298+
* Track a charm as recently viewed/interacted with.
299+
* Maintains a list of up to 10 most recent charms.
300+
* @param charm - The charm to track
301+
*/
302+
async trackRecentCharm(charm: Cell<unknown>): Promise<void> {
303+
const id = getEntityId(charm);
304+
if (!id) return;
224305

225-
const id = getEntityId(idOrCharm);
226-
if (!id) return false;
306+
await this.runtime.editWithRetry((tx) => {
307+
const recentCharmsWithTx = this.recentCharms.withTx(tx);
308+
const recentCharms = recentCharmsWithTx.get() || [];
227309

228-
// Find the charm in trash
229-
const trashedCharm = trashedCharms.get().find((charm) =>
230-
isSameEntity(charm, id)
231-
);
310+
// Remove any existing instance of this charm to avoid duplicates
311+
const filtered = recentCharms.filter((c) => !isSameEntity(c, id));
232312

233-
if (!trashedCharm) return false;
313+
// Add charm to the beginning of the list
314+
const updated = [charm, ...filtered];
234315

235-
// Remove from trash
236-
const newTrashedCharms = filterOutEntity(trashedCharms, id);
237-
trashedCharms.set(newTrashedCharms);
316+
// Trim to max 10 items
317+
const trimmed = updated.slice(0, 10);
238318

239-
// Add back to charms
240-
this.addCharms([trashedCharm], tx);
319+
recentCharmsWithTx.set(trimmed);
241320
});
242321

243322
await this.runtime.idle();
244-
245-
return !error;
246-
}
247-
248-
async emptyTrash() {
249-
await this.syncCharms(this.trashedCharms);
250-
await this.runtime.editWithRetry((tx) => {
251-
const trashedCharms = this.trashedCharms.withTx(tx);
252-
trashedCharms.set([]);
253-
});
254323
}
255324

256325
// FIXME(ja): this says it returns a list of charm, but it isn't! you will
@@ -821,14 +890,12 @@ export class CharmManager {
821890
}
822891

823892
// note: removing a charm doesn't clean up the charm's cells
824-
// Now moves the charm to trash instead of just removing it
825893
async remove(idOrCharm: string | EntityId | Cell<unknown>) {
826894
let success = false;
827895

828896
await Promise.all([
829897
this.syncCharms(this.charms),
830898
this.syncCharms(this.pinnedCharms),
831-
this.syncCharms(this.trashedCharms),
832899
]);
833900

834901
const id = getEntityId(idOrCharm);
@@ -838,44 +905,11 @@ export class CharmManager {
838905

839906
return (!await this.runtime.editWithRetry((tx) => {
840907
const charms = this.charms.withTx(tx);
841-
const trashedCharms = this.trashedCharms.withTx(tx);
842-
843-
// Find the charm in the main list
844-
const charm = charms.get().find((c) => isSameEntity(c, id));
845-
if (!charm) {
846-
success = false;
847-
} else {
848-
// Move to trash if not already there
849-
if (!trashedCharms.get().some((c) => isSameEntity(c, id))) {
850-
trashedCharms.push(charm);
851-
}
852-
853-
// Remove from main list
854-
const newCharms = filterOutEntity(charms, id);
855-
if (newCharms.length !== charms.get().length) {
856-
charms.set(newCharms);
857-
}
858908

859-
success = true;
860-
}
861-
})) && success;
862-
}
863-
864-
// Permanently delete a charm (from trash or directly)
865-
async permanentlyDelete(idOrCharm: string | EntityId | Cell<unknown>) {
866-
let success;
867-
868-
await this.syncCharms(this.trashedCharms);
869-
870-
const id = getEntityId(idOrCharm);
871-
if (!id) return false;
872-
873-
return (!await this.runtime.editWithRetry((tx) => {
874-
// Remove from trash if present
875-
const trashedCharms = this.trashedCharms.withTx(tx);
876-
const newTrashedCharms = filterOutEntity(trashedCharms, id);
877-
if (newTrashedCharms.length !== trashedCharms.get().length) {
878-
trashedCharms.set(newTrashedCharms);
909+
// Remove from main list
910+
const newCharms = filterOutEntity(charms, id);
911+
if (newCharms.length !== charms.get().length) {
912+
charms.set(newCharms);
879913
success = true;
880914
} else {
881915
success = false;

packages/charm/src/ops/charms-controller.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,8 @@ export class CharmsController<T = unknown> {
7373
async remove(charmId: string): Promise<boolean> {
7474
this.disposeCheck();
7575
const removed = await this.#manager.remove(charmId);
76-
// Empty trash and ensure full synchronization
76+
// Ensure full synchronization
7777
if (removed) {
78-
await this.#manager.emptyTrash();
7978
await this.#manager.runtime.idle();
8079
await this.#manager.synced();
8180
}

packages/cli/lib/charm.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ export async function callCharmHandler<T = any>(
506506
}
507507

508508
/**
509-
* Removes a charm from the space (moves it to trash).
509+
* Removes a charm from the space.
510510
*/
511511
export async function removeCharm(
512512
config: CharmConfig,

packages/patterns/chatbot-note-composed.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,11 @@ export default recipe<ChatbotNoteInput, ChatbotNoteResult>(
173173
"Chatbot + Note",
174174
({ title, messages }) => {
175175
const allCharms = schemaifyWish<MentionableCharm[]>("#allCharms", []);
176-
const index = schemaifyWish<BacklinksIndex>("/backlinksIndex", {
176+
const index = schemaifyWish<BacklinksIndex>("#default/backlinksIndex", {
177177
mentionable: [],
178178
});
179179
const mentionable = schemaifyWish<MentionableCharm[]>(
180-
"/backlinksIndex/mentionable",
180+
"#mentionable",
181181
[],
182182
);
183183

packages/patterns/chatbot-outliner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const handleCharmLinkClick = handler<
5555
function getMentionable() {
5656
return derive<MentionableCharm[], MentionableCharm[]>(
5757
wish<MentionableCharm[]>(
58-
"/backlinksIndex/mentionable",
58+
"#mentionable",
5959
[],
6060
),
6161
(i) => i,

packages/patterns/chatbot.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ const sendMessage = handler<
106106
});
107107
});
108108

109-
const _clearChat = handler(
109+
const clearChat = handler(
110110
(
111111
_: never,
112112
{ messages, pending }: {
@@ -137,6 +137,7 @@ type ChatOutput = {
137137
messages: Array<BuiltInLLMMessage>;
138138
pending: boolean | undefined;
139139
addMessage: Stream<BuiltInLLMMessage>;
140+
clearChat: Stream<void>;
140141
cancelGeneration: Stream<void>;
141142
title?: string;
142143
attachments: Array<PromptAttachment>;
@@ -274,7 +275,7 @@ export default recipe<ChatInput, ChatOutput>(
274275
const model = cell<string>("anthropic:claude-sonnet-4-5");
275276
const allAttachments = cell<Array<PromptAttachment>>([]);
276277
const mentionable = schemaifyWish<MentionableCharm[]>(
277-
"/backlinksIndex/mentionable",
278+
"#mentionable",
278279
[],
279280
);
280281

@@ -393,6 +394,16 @@ export default recipe<ChatInput, ChatOutput>(
393394
onct-remove={removeAttachment({ allAttachments })}
394395
/>
395396
<ct-tools-chip tools={flattenedTools} />
397+
<button
398+
type="button"
399+
title="Clear chat"
400+
onClick={clearChat({
401+
messages,
402+
pending,
403+
})}
404+
>
405+
Clear
406+
</button>
396407
</ct-hstack>
397408
);
398409

@@ -413,6 +424,10 @@ export default recipe<ChatInput, ChatOutput>(
413424
messages,
414425
pending,
415426
addMessage,
427+
clearChat: clearChat({
428+
messages,
429+
pending,
430+
}),
416431
cancelGeneration,
417432
title,
418433
attachments: allAttachments,

packages/patterns/note.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const Note = recipe<Input, Output>(
103103
"Note",
104104
({ title, content }) => {
105105
const mentionable = schemaifyWish<MentionableCharm[]>(
106-
"/backlinksIndex/mentionable",
106+
"#mentionable",
107107
[],
108108
);
109109
const mentioned = cell<MentionableCharm[]>([]);

packages/runner/src/builtins/well-known.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,3 @@
77
*/
88
export const ALL_CHARMS_ID =
99
"baedreiahv63wxwgaem4hzjkizl4qncfgvca7pj5cvdon7cukumfon3ioye";
10-
export const DEFAULT_PATTERN_ID =
11-
"baedreicw57gfkzm77cu2paqxnbyyjzyfviddbswnjixqxnfm6anx43khgi";

0 commit comments

Comments
 (0)