6b66c590d2683ec07ca549bc6a53d0e708ed026053cf85808401dc03800bac01afterNavigateafterSessionEndbeforeLLMCallbreath:exhale1import express from "express";
2import { sendError, ERR } from "../../seed/protocol.js";
3import urlAuth from "../html-rendering/urlAuth.js";
4import { htmlOnly, buildQS } from "../html-rendering/htmlHelpers.js";
5import Node from "../../seed/models/node.js";
6import { getNotes } from "../../seed/tree/notes.js";
7import { getLandRootId } from "../../seed/landRoot.js";
8import { renderMemoryPage } from "./pages/memoryPage.js";
9
10export default function buildHtmlRoutes() {
11 const router = express.Router();
12
13 router.get("/user/:userId/home-memory", urlAuth, htmlOnly, async (req, res) => {
14 try {
15 const userId = req.params.userId;
16 if (!req.userId || String(req.userId) !== String(userId)) {
17 return sendError(res, 403, ERR.FORBIDDEN, "Can only view your own home memory");
18 }
19
20 const qs = buildQS(req);
21 const landRootId = getLandRootId();
22 const homeName = `.home-${userId.slice(0, 8)}`;
23
24 const homeTree = await Node.findOne({ parent: landRootId, name: homeName })
25 .select("_id").lean();
26
27 let memories = [];
28 let reminders = [];
29
30 if (homeTree) {
31 const memoriesNode = await Node.findOne({ parent: String(homeTree._id), name: "memories" })
32 .select("_id").lean();
33 if (memoriesNode) {
34 const result = await getNotes({ nodeId: String(memoriesNode._id), limit: 50 });
35 memories = result?.notes || [];
36 }
37
38 const remindersNode = await Node.findOne({ parent: String(homeTree._id), name: "reminders" })
39 .select("_id").lean();
40 if (remindersNode) {
41 const result = await getNotes({ nodeId: String(remindersNode._id), limit: 20 });
42 reminders = result?.notes || [];
43 }
44 }
45
46 res.send(renderMemoryPage({
47 username: req.username || "unknown",
48 memories,
49 reminders,
50 qs,
51 }));
52 } catch (err) {
53 sendError(res, 500, ERR.INTERNAL, "Home memory page failed");
54 }
55 });
56
57 return router;
58}
591import { v4 as uuidv4 } from "uuid";
2import log from "../../seed/log.js";
3import Node from "../../seed/models/node.js";
4import Note from "../../seed/models/note.js";
5import Chat from "../../seed/models/chat.js";
6import User from "../../seed/models/user.js";
7import { createNote } from "../../seed/tree/notes.js";
8import { getNotes } from "../../seed/tree/notes.js";
9import { getLandRootId } from "../../seed/landRoot.js";
10
11const MAX_MEMORIES = 200;
12const MAX_REMINDERS = 50;
13const SUMMARY_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes between summaries
14
15// Track last summary time per user so we don't summarize every session end
16const _lastSummary = new Map();
17
18// Track in-flight summary runs per user so we don't stack overlapping calls
19// (multiple session ends in quick succession all firing summarizeSession).
20const _inFlight = new Set();
21
22// Track navigation patterns per user (in-memory, compressed on breath)
23const _navTracking = new Map(); // userId -> { trees: Map<rootId, { name, count, lastVisit }> }
24
25export async function init(core) {
26 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
27
28 // Direct import for background LLM calls (bypasses userHasLlm guard)
29 const { runChat: _runChatDirect } = await import("../../seed/llm/conversation.js");
30 const runChat = async (opts) => _runChatDirect({ ...opts, llmPriority: BG });
31
32 // ── afterNavigate: track which trees the user visits ──────────────
33 core.hooks.register("afterNavigate", async ({ userId, rootId }) => {
34 if (!userId || !rootId) return;
35
36 try {
37 let tracking = _navTracking.get(userId);
38 if (!tracking) {
39 tracking = { trees: new Map() };
40 _navTracking.set(userId, tracking);
41 }
42
43 const root = await Node.findById(rootId).select("name").lean();
44 const entry = tracking.trees.get(rootId) || { name: root?.name || "?", count: 0, lastVisit: null };
45 entry.count++;
46 entry.lastVisit = Date.now();
47 if (root?.name) entry.name = root.name;
48 tracking.trees.set(rootId, entry);
49 } catch {}
50 }, "home-memory");
51
52 // ── afterSessionEnd: summarize home conversations ─────────────────
53 core.hooks.register("afterSessionEnd", async ({ sessionId, userId, type, meta }) => {
54 // Only care about home sessions. The visitorId (stored in session meta
55 // by runOrchestration / websocket.js syncRegistrySession) follows the
56 // pattern "home:{userId}".
57 const visitorId = meta?.visitorId || "";
58 if (!visitorId.startsWith("home:")) return;
59 if (!userId) return;
60
61 // Single in-flight run per user. Prevents stacking when multiple home
62 // chats end in quick succession (each session-end fires this hook).
63 if (_inFlight.has(userId)) {
64 log.verbose("HomeMemory", ` skipping: summary already in flight for user ${userId.slice(0, 8)}`);
65 return;
66 }
67
68 // Cooldown: don't summarize too frequently. Set on success only (see end
69 // of summarizeSession), so failed runs can be retried on the next session.
70 const lastTime = _lastSummary.get(userId) || 0;
71 const elapsed = Date.now() - lastTime;
72 if (elapsed < SUMMARY_COOLDOWN_MS) {
73 log.verbose("HomeMemory", ` skipping: cooldown active (${Math.round(elapsed / 1000)}s of ${SUMMARY_COOLDOWN_MS / 1000}s)`);
74 return;
75 }
76
77 log.info("HomeMemory", `Summarizing home session for user ${userId.slice(0, 8)}`);
78 _inFlight.add(userId);
79 // Fire and forget. Always release the in-flight flag.
80 summarizeSession(userId, sessionId, runChat)
81 .catch(err => log.warn("HomeMemory", `Summary failed: ${err.message}`))
82 .finally(() => _inFlight.delete(userId));
83 }, "home-memory");
84
85 // ── beforeLLMCall: inject memories into home-zone system prompt ─────
86 // enrichContext doesn't fire in home zone (no tree context).
87 // beforeLLMCall fires on every LLM call. We prepend memories to the
88 // system message, same pattern as persona extension.
89 core.hooks.register("beforeLLMCall", async (hookData) => {
90 const { messages, mode, userId } = hookData;
91 if (!messages || !messages[0] || messages[0].role !== "system") return;
92 if (!mode || !mode.startsWith("home:")) return;
93 if (!userId) return;
94
95 try {
96 const homeTree = await getHomeTree(userId);
97 if (!homeTree) return;
98
99 // Read recent memories
100 const memoriesNode = await Node.findOne({ parent: String(homeTree._id), name: "memories" })
101 .select("_id").lean();
102 if (!memoriesNode) return;
103
104 const result = await getNotes({ nodeId: String(memoriesNode._id), limit: 15 });
105 const memories = result?.notes || [];
106
107 // Read reminders
108 let reminders = [];
109 const remindersNode = await Node.findOne({ parent: String(homeTree._id), name: "reminders" })
110 .select("_id").lean();
111 if (remindersNode) {
112 const rResult = await getNotes({ nodeId: String(remindersNode._id), limit: 10 });
113 reminders = rResult?.notes || [];
114 }
115
116 // Build navigation summary from tracking
117 const tracking = _navTracking.get(userId);
118 let navSummary = null;
119 if (tracking && tracking.trees.size > 0) {
120 const sorted = [...tracking.trees.entries()]
121 .sort((a, b) => b[1].lastVisit - a[1].lastVisit)
122 .slice(0, 5);
123 navSummary = sorted.map(([, t]) => {
124 const ago = timeSince(t.lastVisit);
125 return `${t.name} (visited ${t.count}x, last ${ago})`;
126 }).join(", ");
127 }
128
129 if (memories.length === 0 && reminders.length === 0 && !navSummary) return;
130
131 // Build memory block and prepend to system message
132 const sections = [];
133
134 if (memories.length > 0) {
135 sections.push(`[Memories from past conversations]\n${memories.map(m => `- ${m.content}`).join("\n")}`);
136 }
137 if (reminders.length > 0) {
138 sections.push(`[Things the user asked you to remember]\n${reminders.map(r => `- ${r.content}`).join("\n")}`);
139 }
140 if (navSummary) {
141 sections.push(`[Recent tree activity]\n${navSummary}`);
142 }
143
144 const block = sections.join("\n\n") +
145 "\n\nUse these memories naturally. Do not list them. Do not mention that you have a memory system. " +
146 "Just be someone who remembers.\n\n";
147
148 messages[0].content = block + messages[0].content;
149 } catch (err) {
150 log.debug("HomeMemory", `beforeLLMCall failed: ${err.message}`);
151 }
152 }, "home-memory");
153
154 // ── breath:exhale: compress old memories periodically ─────────────
155 // Runs on any tree's breath. We just use it as a clock tick.
156 // Only runs once per user per day max.
157 const _lastCompress = new Map();
158 core.hooks.register("breath:exhale", async ({ rootId }) => {
159 // Use rootId to find the owner, compress their home memories
160 try {
161 const root = await Node.findById(rootId).select("rootOwner").lean();
162 if (!root?.rootOwner || String(root.rootOwner) === "SYSTEM") return;
163 const userId = String(root.rootOwner);
164
165 const lastTime = _lastCompress.get(userId) || 0;
166 if (Date.now() - lastTime < 24 * 60 * 60 * 1000) return;
167 _lastCompress.set(userId, Date.now());
168
169 await capMemories(userId);
170 } catch {}
171 }, "home-memory");
172
173 // HTML page + user quick link
174 try {
175 const { getExtension } = await import("../loader.js");
176 const htmlExt = getExtension("html-rendering");
177 const base = getExtension("treeos-base");
178 if (htmlExt) {
179 const { default: buildHtmlRoutes } = await import("./htmlRoutes.js");
180 htmlExt.router.use("/", buildHtmlRoutes());
181 }
182 base?.exports?.registerSlot?.("user-quick-links", "home-memory", ({ userId, queryString }) =>
183 `<a href="/api/v1/user/${userId}/home-memory${queryString}" class="back-link">Home Memory</a>`,
184 { priority: 45 }
185 );
186 } catch {}
187
188 log.info("HomeMemory", "Loaded. The lobby remembers.");
189 return {};
190}
191
192// ─────────────────────────────────────────────────────────────────────────
193// HELPERS
194// ─────────────────────────────────────────────────────────────────────────
195
196/**
197 * Get or create the .home tree for a user.
198 * .home is a child of the land root, hidden (dot-prefix), owned by the user.
199 */
200async function getHomeTree(userId) {
201 const landRootId = getLandRootId();
202 if (!landRootId) return null;
203
204 // Look for existing .home node owned by this user
205 let home = await Node.findOne({
206 parent: landRootId,
207 name: `.home-${userId.slice(0, 8)}`,
208 }).select("_id name").lean();
209
210 if (home) return home;
211 return null; // Don't create until first summary
212}
213
214/**
215 * Create the .home tree for a user. Called on first session summary.
216 */
217async function createHomeTree(userId) {
218 const landRootId = getLandRootId();
219 if (!landRootId) return null;
220
221 const homeName = `.home-${userId.slice(0, 8)}`;
222
223 const home = await Node.findOneAndUpdate(
224 { parent: landRootId, name: homeName },
225 {
226 $setOnInsert: {
227 _id: uuidv4(),
228 name: homeName,
229 parent: landRootId,
230 rootOwner: userId,
231 status: "active",
232 children: [],
233 contributors: [],
234 metadata: {},
235 },
236 },
237 { upsert: true, new: true, lean: true },
238 );
239
240 await Node.updateOne(
241 { _id: landRootId },
242 { $addToSet: { children: home._id } },
243 );
244
245 // Create memories and reminders child nodes
246 for (const childName of ["memories", "reminders"]) {
247 const child = await Node.findOneAndUpdate(
248 { parent: String(home._id), name: childName },
249 {
250 $setOnInsert: {
251 _id: uuidv4(),
252 name: childName,
253 parent: String(home._id),
254 rootOwner: userId,
255 status: "active",
256 children: [],
257 contributors: [],
258 metadata: {},
259 },
260 },
261 { upsert: true, new: true, lean: true },
262 );
263 await Node.updateOne(
264 { _id: String(home._id) },
265 { $addToSet: { children: child._id } },
266 );
267 }
268
269 log.info("HomeMemory", `Created .home tree for user ${userId.slice(0, 8)}`);
270 return home;
271}
272
273// How far back to look for chats to summarize. A session ending after a long
274// gap should only summarize what's actually fresh, not chats from days ago.
275const SUMMARY_WINDOW_MS = 12 * 60 * 60 * 1000; // 12 hours
276
277/**
278 * Summarize a home session into a one-sentence memory.
279 * Reads home-zone chats since the last summary (capped at the window) and
280 * compresses them into a single observation. Old chats are ignored. Already
281 * summarized chats are ignored.
282 */
283async function summarizeSession(userId, sessionId, runChat) {
284 // Build the time floor: chats must be newer than the most recent summary
285 // AND newer than the rolling window. Whichever is later wins.
286 const lastTime = _lastSummary.get(userId) || 0;
287 const windowFloor = Date.now() - SUMMARY_WINDOW_MS;
288 const sinceTime = Math.max(lastTime, windowFloor);
289
290 const chats = await Chat.find({
291 userId,
292 "aiContext.zone": "home",
293 "startMessage.time": { $gt: new Date(sinceTime) },
294 })
295 .sort({ "startMessage.time": -1 })
296 .limit(20)
297 .select("startMessage.content endMessage.content")
298 .lean();
299
300 log.verbose("HomeMemory", ` found ${chats.length} fresh home chats since ${new Date(sinceTime).toISOString()}`);
301 if (chats.length === 0) return;
302
303 // Build conversation excerpt
304 const excerpt = chats.reverse().map(c => {
305 const user = c.startMessage?.content || "";
306 const ai = c.endMessage?.content || "";
307 return `User: ${user.slice(0, 200)}\nAI: ${ai.slice(0, 200)}`;
308 }).join("\n\n");
309
310 if (excerpt.length < 20) return;
311
312 // Get user info
313 const user = await User.findById(userId).select("username").lean();
314
315 // Get or create .home tree
316 let homeTree = await getHomeTree(userId);
317 if (!homeTree) homeTree = await createHomeTree(userId);
318 if (!homeTree) return;
319
320 const memoriesNode = await Node.findOne({ parent: String(homeTree._id), name: "memories" })
321 .select("_id").lean();
322 if (!memoriesNode) return;
323
324 // Check for explicit reminders in the conversation
325 const reminderPatterns = /\b(remember|remind me|don't forget|keep in mind|note that)\b/i;
326 const hasReminder = chats.some(c => reminderPatterns.test(c.startMessage?.content || ""));
327
328 log.verbose("HomeMemory", ` calling LLM to summarize ${chats.length} chats (${excerpt.length} chars)`);
329
330 // One LLM call: summarize into a memory
331 // Use a land-level LLM since there's no tree context
332 let answer;
333 try {
334 const result = await runChat({
335 userId,
336 username: user?.username || "user",
337 message:
338 `You are summarizing a home-zone conversation for future reference. ` +
339 `This is NOT a response to the user. This is a private memory note.\n\n` +
340 `Conversation:\n${excerpt}\n\n` +
341 `Write ONE sentence capturing what this conversation was about and anything ` +
342 `worth remembering (what the user cared about, their mood, what they asked for, ` +
343 `any personal details they shared). Be specific, not generic. ` +
344 `If nothing interesting happened, write "routine check-in."` +
345 (hasReminder
346 ? `\n\nThe user also explicitly asked to remember something. After your summary sentence, ` +
347 `write a second line starting with "REMINDER:" containing exactly what they asked to remember.`
348 : ""),
349 mode: "tree:respond",
350 rootId: String(homeTree._id),
351 slot: "homeMemory",
352 });
353 answer = result?.answer;
354 } catch (err) {
355 log.warn("HomeMemory", `runChat failed: ${err.message}`);
356 return;
357 }
358
359 log.verbose("HomeMemory", ` LLM returned ${answer ? answer.length + " chars" : "null"}: "${(answer || "").slice(0, 100)}"`);
360
361 if (!answer || answer.length < 5) {
362 log.verbose("HomeMemory", ` skipping: answer too short or empty`);
363 return;
364 }
365
366 // Parse reminder if present
367 const lines = answer.trim().split("\n").filter(l => l.trim());
368 const memoryText = lines[0];
369 const reminderLine = lines.find(l => l.startsWith("REMINDER:"));
370
371 // Write memory
372 if (memoryText && memoryText !== "routine check-in.") {
373 try {
374 await createNote({
375 contentType: "text",
376 content: memoryText.trim(),
377 userId,
378 nodeId: String(memoriesNode._id),
379 wasAi: true,
380 });
381 log.verbose("HomeMemory", ` wrote memory note to ${memoriesNode._id}`);
382 } catch (err) {
383 log.warn("HomeMemory", `createNote failed: ${err.message}`);
384 return;
385 }
386 } else {
387 log.verbose("HomeMemory", ` skipping: routine check-in (no memory worth saving)`);
388 }
389
390 // Write reminder if found
391 if (reminderLine) {
392 const remindersNode = await Node.findOne({ parent: String(homeTree._id), name: "reminders" })
393 .select("_id").lean();
394 if (remindersNode) {
395 const reminderText = reminderLine.replace(/^REMINDER:\s*/, "").trim();
396 if (reminderText.length > 3) {
397 await createNote({
398 contentType: "text",
399 content: reminderText,
400 userId,
401 nodeId: String(remindersNode._id),
402 wasAi: true,
403 });
404 }
405 }
406 }
407
408 // Only set cooldown after successful write so failed runs can be retried
409 // immediately on the next session end instead of locking the user out for 4 hours.
410 _lastSummary.set(userId, Date.now());
411 log.info("HomeMemory", `Saved memory for ${user?.username || userId.slice(0, 8)}: "${memoryText?.slice(0, 60)}"`);
412}
413
414/**
415 * Cap memories at MAX_MEMORIES by deleting oldest.
416 */
417async function capMemories(userId) {
418 const homeTree = await getHomeTree(userId);
419 if (!homeTree) return;
420
421 for (const childName of ["memories", "reminders"]) {
422 const node = await Node.findOne({ parent: String(homeTree._id), name: childName })
423 .select("_id").lean();
424 if (!node) continue;
425
426 const max = childName === "memories" ? MAX_MEMORIES : MAX_REMINDERS;
427 const count = await Note.countDocuments({ nodeId: String(node._id) });
428 if (count <= max) continue;
429
430 const oldest = await Note.find({ nodeId: String(node._id) })
431 .sort({ createdAt: 1 })
432 .limit(count - max)
433 .select("_id")
434 .lean();
435 if (oldest.length > 0) {
436 await Note.deleteMany({ _id: { $in: oldest.map(n => n._id) } });
437 log.verbose("HomeMemory", `Capped ${childName}: deleted ${oldest.length} old entries`);
438 }
439 }
440}
441
442function timeSince(ts) {
443 if (!ts) return "unknown";
444 const seconds = Math.floor((Date.now() - ts) / 1000);
445 if (seconds < 60) return "just now";
446 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
447 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
448 return `${Math.floor(seconds / 86400)}d ago`;
449}
4501export default {
2 name: "home-memory",
3 version: "1.0.1",
4 builtFor: "seed",
5 description:
6 "The lobby remembers. Tracks what the user cares about across home-zone conversations. " +
7 "Navigation patterns, explicit reminders, session topics. A .home system tree per user " +
8 "holds the memories. enrichContext injects them so the home agent greets with context " +
9 "instead of amnesia.",
10
11 needs: {
12 services: ["hooks", "llm", "metadata", "tree"],
13 models: ["Node", "Note", "User"],
14 },
15
16 optional: {
17 extensions: ["breath", "treeos-base"],
18 },
19
20 provides: {
21 models: {},
22 routes: false,
23 tools: false,
24 jobs: false,
25 orchestrator: false,
26 energyActions: {},
27 sessionTypes: {},
28 env: [],
29 cli: [],
30
31 hooks: {
32 fires: [],
33 listens: [
34 "afterNavigate",
35 "afterSessionEnd",
36 "beforeLLMCall",
37 "breath:exhale",
38 ],
39 },
40 },
41};
421import { page } from "../../html-rendering/html/layout.js";
2import {
3 baseStyles,
4 backNavStyles,
5 glassHeaderStyles,
6 glassCardStyles,
7 glassCardPanelStyles,
8 statGridStyles,
9 responsiveBase,
10} from "../../html-rendering/html/baseStyles.js";
11import { escapeHtml } from "../../html-rendering/html/utils.js";
12
13export function renderMemoryPage({ username, memories, reminders, qs }) {
14 // Color story:
15 // Memories -> glass-blue (cool, reflective. Looking back through water.)
16 // Reminders -> glass-orange (warm, active. A flag in the field.)
17 // Section panels stay default purple so they read as part of the surface.
18
19 const css = `
20 ${baseStyles}
21 ${backNavStyles}
22 ${glassHeaderStyles}
23 ${glassCardStyles}
24 ${glassCardPanelStyles}
25 ${statGridStyles}
26 ${responsiveBase}
27
28 /* Tighter note layout for memory entries (shorter than full notes) */
29 .memory-list { display: flex; flex-direction: column; gap: 12px; }
30 .memory-list .note-card { padding: 18px 22px; }
31 .memory-list .note-content {
32 font-size: 14px;
33 line-height: 1.6;
34 margin-bottom: 0;
35 }
36 .memory-list .note-date {
37 font-size: 11px;
38 font-weight: 500;
39 color: rgba(255, 255, 255, 0.6);
40 letter-spacing: 0.3px;
41 text-transform: uppercase;
42 margin-bottom: 6px;
43 }
44
45 .empty-row {
46 padding: 24px;
47 text-align: center;
48 color: rgba(255, 255, 255, 0.45);
49 font-size: 14px;
50 font-style: italic;
51 }
52
53 .glass-card h2 {
54 display: flex;
55 align-items: baseline;
56 gap: 10px;
57 }
58 .glass-card h2 .count {
59 font-size: 13px;
60 font-weight: 500;
61 color: rgba(255, 255, 255, 0.55);
62 letter-spacing: 0;
63 }
64 .glass-card .panel-sub {
65 font-size: 13px;
66 color: rgba(255, 255, 255, 0.6);
67 margin-top: -8px;
68 margin-bottom: 18px;
69 line-height: 1.5;
70 }
71 `;
72
73 function renderNotes(notes, colorClass) {
74 if (!notes || notes.length === 0) {
75 return '<div class="empty-row">Nothing yet.</div>';
76 }
77 return `<div class="memory-list">${notes.map(n => {
78 const time = n.createdAt
79 ? new Date(n.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
80 : "";
81 return `<div class="note-card ${colorClass}">
82 ${time ? `<div class="note-date">${escapeHtml(time)}</div>` : ""}
83 <div class="note-content">${escapeHtml(n.content || "").replace(/\n/g, "<br>")}</div>
84 </div>`;
85 }).join("")}</div>`;
86 }
87
88 const body = `
89 <div class="container" style="max-width: 760px;">
90 <div class="back-nav">
91 <a href="/dashboard${qs}" class="back-link" onclick="event.preventDefault();try{window.top.location.href='/dashboard${qs}'}catch(e){window.location.href='/dashboard${qs}'}">Home</a>
92 </div>
93
94 <div class="header">
95 <h1>Home Memory</h1>
96 <div class="header-subtitle">${escapeHtml(username || "User")} . What the lobby remembers.</div>
97 </div>
98
99 <div class="stat-grid" style="margin-bottom: 24px;">
100 <div class="stat-item">
101 <div class="stat-label">Memories</div>
102 <div class="stat-value">${memories?.length || 0}</div>
103 <div class="stat-sub">sessions remembered</div>
104 </div>
105 <div class="stat-item">
106 <div class="stat-label">Reminders</div>
107 <div class="stat-value">${reminders?.length || 0}</div>
108 <div class="stat-sub">explicitly held</div>
109 </div>
110 </div>
111
112 <div class="glass-card" style="animation-delay: 0.15s">
113 <h2>Memories <span class="count">${memories?.length || 0}</span></h2>
114 <div class="panel-sub">One sentence per home session. What you talked about.</div>
115 ${renderNotes(memories, "glass-blue")}
116 </div>
117
118 <div class="glass-card" style="animation-delay: 0.25s">
119 <h2>Reminders <span class="count">${reminders?.length || 0}</span></h2>
120 <div class="panel-sub">Things you explicitly asked the lobby to remember.</div>
121 ${renderNotes(reminders, "glass-orange")}
122 </div>
123 </div>
124 `;
125
126 return page({ title: `${username || "User"} . Home Memory`, css, body, js: "" });
127}
128
| Version | Published | Downloads |
|---|---|---|
| 1.0.1 | 38d ago | 0 |
| 1.0.0 | 46d ago | 0 |
treeos ext star home-memory
Post comments from the CLI: treeos ext comment home-memory "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...