EXTENSION seed
home-memory
The lobby remembers. Tracks what the user cares about across home-zone conversations. Navigation patterns, explicit reminders, session topics. A .home system tree per user holds the memories. enrichContext injects them so the home agent greets with context instead of amnesia.
v1.0.1 by TreeOS Site 0 downloads 4 files 679 lines 23.1 KB published 38d ago
treeos ext install home-memory
View changelog

Manifest

Requires

  • services: hooks, llm, metadata, tree
  • models: Node, Note, User

Optional

  • extensions: breath, treeos-base
SHA256: 6b66c590d2683ec07ca549bc6a53d0e708ed026053cf85808401dc03800bac01

Hooks

Listens To

  • afterNavigate
  • afterSessionEnd
  • beforeLLMCall
  • breath:exhale

Source Code

1import 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}
59
1import { 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}
450
1export 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};
42
1import { 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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 46d ago 0
0 stars
0 flags
React from the CLI: treeos ext star home-memory

Comments

Loading comments...

Post comments from the CLI: treeos ext comment home-memory "your comment"
Max 3 comments per extension. One star and one flag per user.