EXTENSION for TreeOS
study
The tree that teaches you. Queue topics. Build curricula. Study through conversation. Track mastery. Detect gaps. The AI guides you through the subject, asks questions, evaluates understanding, and adapts to your learning style. Integrates with the learn extension for URL content fetching. Mastery scoring from 0 to 100: introduced, basics, solid understanding, can teach it. When all subtopics hit 80%, the topic completes. Gap detection notices missing prerequisites during study and routes you through them. Type 'be' at the Study tree to start a guided session: the AI picks the next subtopic and begins teaching immediately. Part of the proficiency stack: food fuels, fitness builds, recovery heals, study grows.
v1.0.2 by TreeOS Site 0 downloads 13 files 2,312 lines 87.0 KB published 38d ago
treeos ext install study
View changelog

Manifest

Provides

  • routes
  • tools
  • 2 CLI commands

Requires

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

Optional

  • extensions: learn, values, channels, scheduler, notifications, gateway, html-rendering, breath, treeos-base
SHA256: da2b3c2aad125cc78ccd2b0b82714c3cbc677d52c7f8c70ac423ae7c3f2057b5

CLI Commands

CommandMethodDescription
studyPOSTStudy session, queue management, progress.
study switchPOSTActivate a queue item by name or number.
study stopPOSTDeactivate topic, move back to queue.
study removePOSTDelete from queue or active.
study statusGETShow active topics and mastery.
study gapsGETShow detected knowledge gaps.
needlearn [topic...]POSTAdd a topic or URL to your study queue.

Hooks

Listens To

  • enrichContext
  • afterBoot

Source Code

1/**
2 * Study Core
3 *
4 * Queue management, mastery tracking, gap detection, progress stats.
5 * The tree holds curricula. The AI teaches through conversation.
6 * Learn extension fetches content. Study organizes and tracks mastery.
7 */
8
9import log from "../../seed/log.js";
10import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
11
12let _Node = null;
13let _Note = null;
14let _runChat = null;
15let _metadata = null;
16
17export function configure({ Node, Note, runChat, metadata }) {
18  _Node = Node;
19  _Note = Note;
20  _runChat = runChat;
21  _metadata = metadata;
22}
23
24// ── Constants ──
25
26const MAX_HISTORY = 90;
27const MASTERY_COMPLETE = 80;
28
29// Fuzzy name match: exact, then includes, then starts-with
30function fuzzyFind(items, name) {
31  if (!items || !name) return null;
32  const lower = name.toLowerCase();
33  return items.find(t => t.name.toLowerCase() === lower)
34    || items.find(t => t.name.toLowerCase().includes(lower))
35    || items.find(t => lower.includes(t.name.toLowerCase()))
36    || null;
37}
38
39const ROLES = {
40  LOG: "log",
41  QUEUE: "queue",
42  ACTIVE: "active",
43  COMPLETED: "completed",
44  GAPS: "gaps",
45  PROFILE: "profile",
46  HISTORY: "history",
47  TOPIC: "topic",
48  SUBTOPIC: "subtopic",
49  RESOURCES: "resources",
50  QUEUE_ITEM: "queue-item",
51  GAP_ITEM: "gap-item",
52};
53
54export { ROLES };
55
56// ── Initialization ──
57
58export async function isInitialized(rootId) {
59  if (!_Node) return false;
60  const root = await _Node.findById(rootId).select("metadata").lean();
61  if (!root) return false;
62  const meta = root.metadata instanceof Map
63    ? root.metadata.get("study")
64    : root.metadata?.study;
65  return !!meta?.initialized;
66}
67
68export async function getSetupPhase(rootId) {
69  if (!_Node) return null;
70  const root = await _Node.findById(rootId).select("metadata").lean();
71  if (!root) return null;
72  const meta = root.metadata instanceof Map
73    ? root.metadata.get("study")
74    : root.metadata?.study;
75  return meta?.setupPhase || (meta?.initialized ? "complete" : null);
76}
77
78export async function getProfile(rootId) {
79  if (!_Node) return {};
80  const nodes = await findStudyNodes(rootId);
81  if (!nodes?.profile) return {};
82  const node = await _Node.findById(nodes.profile.id).select("metadata").lean();
83  if (!node) return {};
84  const meta = node.metadata instanceof Map
85    ? node.metadata.get("study")
86    : node.metadata?.study;
87  return meta?.profile || {};
88}
89
90// ── Find study nodes by role ──
91
92export async function findStudyNodes(rootId) {
93  if (!_Node) return null;
94  const children = await _Node.find({ parent: rootId }).select("_id name metadata").lean();
95  const result = {};
96
97  for (const child of children) {
98    const meta = child.metadata instanceof Map
99      ? child.metadata.get("study")
100      : child.metadata?.study;
101    if (!meta?.role) continue;
102
103    result[meta.role] = { id: String(child._id), name: child.name };
104  }
105
106  return result;
107}
108
109// ── Queue management ──
110
111export async function getQueue(rootId) {
112  const nodes = await findStudyNodes(rootId);
113  if (!nodes?.queue) return [];
114
115  const items = await _Node.find({ parent: nodes.queue.id }).select("_id name metadata dateCreated").lean();
116  return items.map(item => {
117    const meta = item.metadata instanceof Map
118      ? item.metadata.get("study")
119      : item.metadata?.study;
120    const vals = item.metadata instanceof Map
121      ? item.metadata.get("values")
122      : item.metadata?.values;
123    return {
124      id: String(item._id),
125      name: item.name,
126      priority: vals?.priority || 0,
127      status: meta?.status || "queued",
128      url: meta?.url || null,
129      added: item.dateCreated,
130    };
131  }).sort((a, b) => (b.priority || 0) - (a.priority || 0));
132}
133
134export async function addToQueue(rootId, topicName, userId, opts = {}) {
135  const nodes = await findStudyNodes(rootId);
136  if (!nodes?.queue) throw new Error("Study tree not scaffolded");
137
138  const { createNode } = await import("../../seed/tree/treeManagement.js");
139  const node = await createNode({ name: topicName, parentId: nodes.queue.id, userId });
140
141  const studyMeta = { role: ROLES.QUEUE_ITEM, status: "queued" };
142  if (opts.url) studyMeta.url = opts.url;
143  await _metadata.setExtMeta(node, "study", studyMeta);
144
145  if (opts.priority) {
146    await _metadata.batchSetExtMeta(node._id, "values", { priority: opts.priority });
147  }
148
149  // If URL, fetch content and optionally decompose with learn extension
150  if (opts.url) {
151    try {
152      // Fetch the URL content
153      const response = await fetch(opts.url, {
154        headers: { "User-Agent": "TreeOS/Study" },
155        signal: AbortSignal.timeout(15000),
156      });
157      if (response.ok) {
158        const text = await response.text();
159        // Extract readable text (strip HTML tags if present)
160        const content = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
161          .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
162          .replace(/<[^>]+>/g, " ")
163          .replace(/\s+/g, " ")
164          .trim()
165          .slice(0, 500000); // cap at 500KB
166
167        if (content.length > 100) {
168          // Store fetched content as a note on the queue item
169          const { createNote } = await import("../../seed/tree/notes.js");
170          await createNote({ nodeId: String(node._id), content, contentType: "text", userId });
171
172          // If learn extension available, decompose into tree structure
173          const { getExtension } = await import("../loader.js");
174          const learnExt = getExtension("learn");
175          if (learnExt?.exports?.initLearnState && learnExt?.exports?.processQueue) {
176            await learnExt.exports.initLearnState(String(node._id), 3000);
177            await learnExt.exports.processQueue(String(node._id), userId, "system", 5);
178            log.info("Study", `URL fetched and learn decomposition started: ${opts.url}`);
179          } else {
180            log.info("Study", `URL fetched (${content.length} chars), learn extension not available for decomposition`);
181          }
182        }
183      }
184    } catch (err) {
185      log.verbose("Study", `URL fetch failed: ${err.message}`);
186    }
187  }
188
189  return { id: String(node._id), name: topicName };
190}
191
192// ── Active topic management ──
193
194export async function getActiveTopics(rootId) {
195  const nodes = await findStudyNodes(rootId);
196  if (!nodes?.active) return [];
197
198  const topics = await _Node.find({ parent: nodes.active.id }).select("_id name metadata").lean();
199  const result = [];
200
201  for (const topic of topics) {
202    const meta = topic.metadata instanceof Map
203      ? topic.metadata.get("study")
204      : topic.metadata?.study;
205    if (meta?.role !== ROLES.TOPIC) continue;
206
207    // Get subtopics
208    const subtopics = await _Node.find({ parent: topic._id }).select("_id name metadata").lean();
209    const subs = subtopics
210      .filter(s => {
211        const sm = s.metadata instanceof Map ? s.metadata.get("study") : s.metadata?.study;
212        return sm?.role === ROLES.SUBTOPIC;
213      })
214      .map(s => {
215        const vals = s.metadata instanceof Map ? s.metadata.get("values") : s.metadata?.values;
216        const sm = s.metadata instanceof Map ? s.metadata.get("study") : s.metadata?.study;
217        return {
218          id: String(s._id),
219          name: s.name,
220          mastery: vals?.mastery || 0,
221          attempts: vals?.attempts || 0,
222          complete: (vals?.mastery || 0) >= MASTERY_COMPLETE,
223          lastStudied: vals?.lastStudied || null,
224        };
225      });
226
227    const completion = subs.length > 0
228      ? Math.round(subs.filter(s => s.complete).length / subs.length * 100)
229      : 0;
230
231    result.push({
232      id: String(topic._id),
233      name: topic.name,
234      subtopics: subs,
235      completion,
236      lastStudied: meta?.lastStudied || null,
237    });
238  }
239
240  return result;
241}
242
243export async function moveToActive(rootId, queueItemId, userId) {
244  const nodes = await findStudyNodes(rootId);
245  if (!nodes?.active) throw new Error("Study tree not scaffolded");
246
247  const item = await _Node.findById(queueItemId).select("name metadata").lean();
248  if (!item) throw new Error("Queue item not found");
249
250  // Create topic under Active
251  const { createNode } = await import("../../seed/tree/treeManagement.js");
252  const topicNode = await createNode({ name: item.name, parentId: nodes.active.id, userId });
253  await _metadata.setExtMeta(topicNode, "study", { role: ROLES.TOPIC, lastStudied: new Date().toISOString() });
254
255  // Create Resources child
256  const resourcesNode = await createNode({ name: "Resources", parentId: topicNode._id, userId });
257  await _metadata.setExtMeta(resourcesNode, "study", { role: ROLES.RESOURCES });
258
259  // Update queue item status
260  const queueNode = await _Node.findById(queueItemId);
261  if (queueNode) {
262    const existing = _metadata.getExtMeta(queueNode, "study") || {};
263    await _metadata.setExtMeta(queueNode, "study", { ...existing, status: "active" });
264  }
265
266  return { topicId: String(topicNode._id), name: item.name };
267}
268
269export async function deactivateTopic(rootId, topicName, userId) {
270  const nodes = await findStudyNodes(rootId);
271  if (!nodes?.active || !nodes?.queue) throw new Error("Study tree not scaffolded");
272
273  const topics = await _Node.find({ parent: nodes.active.id }).select("_id name").lean();
274  const topic = fuzzyFind(topics, topicName);
275  if (!topic) throw new Error(`No active topic named "${topicName}"`);
276
277  // Move back to queue
278  const { updateParentRelationship } = await import("../../seed/tree/treeManagement.js");
279  await updateParentRelationship(String(topic._id), nodes.queue.id, userId);
280
281  // Update status
282  const node = await _Node.findById(topic._id);
283  if (node) {
284    const existing = _metadata.getExtMeta(node, "study") || {};
285    await _metadata.setExtMeta(node, "study", { ...existing, role: ROLES.QUEUE_ITEM, status: "queued" });
286  }
287
288  log.info("Study", `Deactivated "${topic.name}", moved back to queue`);
289  return { name: topic.name };
290}
291
292export async function removeFromQueue(rootId, topicName, userId) {
293  const nodes = await findStudyNodes(rootId);
294  if (!nodes?.queue && !nodes?.active) throw new Error("Study tree not scaffolded");
295
296  // Search queue first, then active
297  let found = null;
298  if (nodes.queue) {
299    const queueItems = await _Node.find({ parent: nodes.queue.id }).select("_id name").lean();
300    found = fuzzyFind(queueItems, topicName);
301  }
302  if (!found && nodes.active) {
303    const activeItems = await _Node.find({ parent: nodes.active.id }).select("_id name").lean();
304    found = fuzzyFind(activeItems, topicName);
305  }
306  if (!found) throw new Error(`No topic named "${topicName}" in queue or active`);
307
308  const { deleteNodeBranch } = await import("../../seed/tree/treeManagement.js");
309  await deleteNodeBranch(String(found._id), userId);
310
311  log.info("Study", `Removed "${found.name}"`);
312  return { name: found.name };
313}
314
315export async function switchToTopic(rootId, topicName, userId) {
316  const nodes = await findStudyNodes(rootId);
317  if (!nodes?.queue || !nodes?.active) throw new Error("Study tree not scaffolded");
318
319  // Find in queue (exact, then fuzzy contains, then starts-with)
320  const queueItems = await _Node.find({ parent: nodes.queue.id }).select("_id name").lean();
321  let item = fuzzyFind(queueItems, topicName);
322
323  // Try by index (1-based)
324  if (!item && /^\d+$/.test(topicName.trim())) {
325    const idx = parseInt(topicName.trim()) - 1;
326    if (idx >= 0 && idx < queueItems.length) item = queueItems[idx];
327  }
328
329  if (!item) {
330    // Check if already active
331    const activeItems = await _Node.find({ parent: nodes.active.id }).select("_id name").lean();
332    const already = fuzzyFind(activeItems, topicName);
333    if (already) return { name: already.name, alreadyActive: true };
334    throw new Error(`No topic named "${topicName}" in queue`);
335  }
336
337  // Move to active
338  return await moveToActive(rootId, String(item._id), userId);
339}
340
341// ── Mastery tracking ──
342
343export async function updateMastery(subtopicId, score, userId) {
344  const node = await _Node.findById(subtopicId);
345  if (!node) throw new Error("Subtopic not found");
346
347  const vals = _metadata.getExtMeta(node, "values") || {};
348  const newMastery = Math.max(vals.mastery || 0, Math.min(score, 100));
349
350  await _metadata.batchSetExtMeta(subtopicId, "values", {
351    mastery: newMastery,
352    attempts: (vals.attempts || 0) + 1,
353    lastStudied: new Date().toISOString(),
354  });
355
356  // Check if topic is complete (all subtopics at 80%+)
357  const parent = await _Node.findById(node.parent).select("metadata").lean();
358  const parentMeta = parent?.metadata instanceof Map
359    ? parent.metadata.get("study")
360    : parent?.metadata?.study;
361
362  if (parentMeta?.role === ROLES.TOPIC) {
363    await checkTopicCompletion(String(node.parent), userId);
364  }
365
366  return { mastery: newMastery, complete: newMastery >= MASTERY_COMPLETE };
367}
368
369async function checkTopicCompletion(topicId, userId) {
370  const subtopics = await _Node.find({ parent: topicId }).select("metadata").lean();
371  let allComplete = true;
372  let subtopicCount = 0;
373
374  for (const sub of subtopics) {
375    const sm = sub.metadata instanceof Map ? sub.metadata.get("study") : sub.metadata?.study;
376    if (sm?.role !== ROLES.SUBTOPIC) continue;
377    subtopicCount++;
378    const vals = sub.metadata instanceof Map ? sub.metadata.get("values") : sub.metadata?.values;
379    if ((vals?.mastery || 0) < MASTERY_COMPLETE) {
380      allComplete = false;
381    }
382  }
383
384  if (subtopicCount > 0 && allComplete) {
385    // Find root to get Completed node
386    const topic = await _Node.findById(topicId).select("parent name").lean();
387    if (!topic) return;
388    const activeNode = await _Node.findById(topic.parent).select("parent").lean();
389    if (!activeNode) return;
390    const rootId = String(activeNode.parent);
391
392    const nodes = await findStudyNodes(rootId);
393    if (!nodes?.completed) return;
394
395    // Move topic from Active to Completed
396    try {
397      const { updateParentRelationship } = await import("../../seed/tree/treeManagement.js");
398      await updateParentRelationship(topicId, nodes.completed.id, userId);
399      log.info("Study", `Topic "${topic.name}" completed and moved to Completed`);
400    } catch (err) {
401      log.warn("Study", `Failed to move completed topic: ${err.message}`);
402    }
403  }
404}
405
406// ── Gap detection ──
407
408export async function addGap(rootId, gapName, detectedDuring, userId) {
409  const nodes = await findStudyNodes(rootId);
410  if (!nodes?.gaps) return null;
411
412  // Check if gap already exists
413  const existing = await _Node.find({ parent: nodes.gaps.id, name: gapName }).select("_id").lean();
414  if (existing.length > 0) return { id: String(existing[0]._id), name: gapName, existed: true };
415
416  const { createNode } = await import("../../seed/tree/treeManagement.js");
417  const gapNode = await createNode({ name: gapName, parentId: nodes.gaps.id, userId });
418  await _metadata.setExtMeta(gapNode, "study", {
419    role: ROLES.GAP_ITEM,
420    detectedDuring,
421    detectedAt: new Date().toISOString(),
422  });
423  await _metadata.batchSetExtMeta(gapNode._id, "values", { priority: 1 });
424
425  return { id: String(gapNode._id), name: gapName };
426}
427
428export async function getGaps(rootId) {
429  const nodes = await findStudyNodes(rootId);
430  if (!nodes?.gaps) return [];
431
432  const items = await _Node.find({ parent: nodes.gaps.id }).select("_id name metadata dateCreated").lean();
433  return items.map(item => {
434    const meta = item.metadata instanceof Map ? item.metadata.get("study") : item.metadata?.study;
435    return {
436      id: String(item._id),
437      name: item.name,
438      detectedDuring: meta?.detectedDuring || null,
439      detectedAt: meta?.detectedAt || item.dateCreated,
440    };
441  });
442}
443
444// ── Progress stats ──
445
446export async function getStudyProgress(rootId) {
447  const nodes = await findStudyNodes(rootId);
448  if (!nodes) return null;
449
450  const queue = nodes.queue ? await _Node.countDocuments({ parent: nodes.queue.id }) : 0;
451  const active = await getActiveTopics(rootId);
452  const completedCount = nodes.completed ? await _Node.countDocuments({ parent: nodes.completed.id }) : 0;
453  const gapCount = nodes.gaps ? await _Node.countDocuments({ parent: nodes.gaps.id }) : 0;
454
455  // Current topic (most recently studied)
456  let currentTopic = null;
457  if (active.length > 0) {
458    active.sort((a, b) => (b.lastStudied || "").localeCompare(a.lastStudied || ""));
459    currentTopic = active[0];
460  }
461
462  // Current subtopic (lowest mastery in current topic)
463  let currentSubtopic = null;
464  if (currentTopic?.subtopics?.length > 0) {
465    const incomplete = currentTopic.subtopics.filter(s => !s.complete);
466    if (incomplete.length > 0) {
467      incomplete.sort((a, b) => (a.mastery || 0) - (b.mastery || 0));
468      currentSubtopic = incomplete[0];
469    }
470  }
471
472  return {
473    queue: { count: queue },
474    active: currentTopic ? {
475      topic: currentTopic.name,
476      completion: currentTopic.completion,
477      currentSubtopic: currentSubtopic?.name || null,
478      mastery: currentSubtopic?.mastery || 0,
479      lastStudied: currentTopic.lastStudied,
480    } : null,
481    gaps: { count: gapCount },
482    completed: { allTime: completedCount },
483    activeTopics: active.map(t => ({ name: t.name, completion: t.completion })),
484  };
485}
486
487// ── Completed topics ──
488
489export async function getCompletedTopics(rootId) {
490  const nodes = await findStudyNodes(rootId);
491  if (!nodes?.completed) return [];
492
493  const topics = await _Node.find({ parent: nodes.completed.id }).select("_id name metadata dateCreated").lean();
494  return topics.map(t => {
495    const meta = t.metadata instanceof Map ? t.metadata.get("study") : t.metadata?.study;
496    return {
497      id: String(t._id),
498      name: t.name,
499      completedAt: meta?.completedAt || t.dateCreated,
500    };
501  }).sort((a, b) => (b.completedAt || "").localeCompare(a.completedAt || ""));
502}
503
504// ── Study history (from Log node notes) ──
505
506export async function getStudyHistory(rootId, limit = 20) {
507  const nodes = await findStudyNodes(rootId);
508  if (!nodes?.log || !_Note) return [];
509
510  const notes = await _Note.find({ nodeId: nodes.log.id })
511    .sort({ createdAt: -1 })
512    .limit(limit)
513    .select("content createdAt")
514    .lean();
515
516  return notes.map(n => ({
517    text: typeof n.content === "string" ? n.content.slice(0, 200) : "",
518    date: n.createdAt,
519  }));
520}
521
522// ── Parse study input ──
523
524export async function parseStudyInput(message, userId, username, rootId) {
525  if (!_runChat) return null;
526
527  const { answer } = await _runChat({
528    userId, username, message,
529    mode: "tree:study-log",
530    rootId,
531    slot: "study",
532  });
533
534  if (!answer) return null;
535  return parseJsonSafe(answer);
536}
537
538// ── Record study session to History ──
539
540export async function recordStudySession(rootId, session, userId) {
541  const nodes = await findStudyNodes(rootId);
542  if (!nodes?.history) return;
543
544  try {
545    const { createNote } = await import("../../seed/tree/notes.js");
546    await createNote({
547      nodeId: nodes.history.id,
548      content: JSON.stringify({
549        date: new Date().toISOString(),
550        ...session,
551      }),
552      contentType: "text",
553      userId,
554    });
555  } catch (err) {
556    log.warn("Study", `History note failed: ${err.message}`);
557  }
558}
559
1/**
2 * Study Handler
3 *
4 * Only does data work: mechanical commands (switch, remove, add, deactivate).
5 * Returns { answer } for commands that were executed.
6 * Returns null for everything else (AI handles it).
7 */
8
9import { createNote } from "../../seed/tree/notes.js";
10import {
11  findStudyNodes,
12  getActiveTopics,
13  getQueue,
14  addToQueue,
15  switchToTopic,
16  deactivateTopic,
17  removeFromQueue,
18} from "./core.js";
19
20export async function handleMessage(message, { userId, username, rootId, targetNodeId }) {
21  const studyRoot = targetNodeId || rootId;
22  const lower = message.trim().toLowerCase();
23
24  // switch / activate <topic>
25  if (/^(switch|activate)\b/i.test(lower)) {
26    const topic = message.replace(/^(switch|activate)\s*/i, "").trim();
27    if (!topic) {
28      const [active, queue] = await Promise.all([getActiveTopics(studyRoot), getQueue(studyRoot)]);
29      const list = [...active.map(t => `[active] ${t.name}`), ...queue.map(q => `[queued] ${q.name}`)];
30      return { answer: list.length > 0 ? `Switch to what?\n${list.join("\n")}` : "Nothing to switch to. Add a topic first." };
31    }
32    try {
33      const result = await switchToTopic(studyRoot, topic, userId);
34      return { answer: result.alreadyActive ? `"${result.name}" is already active.` : `Switched to "${result.name}".` };
35    } catch (err) {
36      return { answer: err.message };
37    }
38  }
39
40  // remove / delete / drop <topic>
41  if (/^(remove|delete|drop)\s+/i.test(lower)) {
42    const topic = message.replace(/^(remove|delete|drop)\s+/i, "").trim();
43    try {
44      const result = await removeFromQueue(studyRoot, topic, userId);
45      return { answer: `Removed "${result.name}".` };
46    } catch (err) {
47      return { answer: err.message };
48    }
49  }
50
51  // stop / pause / deactivate <topic>
52  if (/^(stop|pause|deactivate)\s+/i.test(lower)) {
53    const topic = message.replace(/^(stop|pause|deactivate)\s+/i, "").trim();
54    try {
55      const result = await deactivateTopic(studyRoot, topic, userId);
56      return { answer: `Deactivated "${result.name}". Back in queue.` };
57    } catch (err) {
58      return { answer: err.message };
59    }
60  }
61
62  // needlearn / queue / add <topic> or URL
63  if (/^(needlearn|need to learn|want to learn|add to queue|queue|add)\b/i.test(lower) || /^https?:\/\//.test(lower)) {
64    const topic = message.replace(/^(needlearn|need to learn|want to learn|add to queue|queue|add)\s*/i, "").trim();
65    if (topic) {
66      const isUrl = /^https?:\/\//.test(topic);
67      const result = await addToQueue(studyRoot, topic, userId, { url: isUrl ? topic : null });
68      const nodes = await findStudyNodes(studyRoot);
69      if (nodes?.log) {
70        try { await createNote({ nodeId: nodes.log.id, content: `Queued: ${topic}`, contentType: "text", userId }); } catch {}
71      }
72      return { answer: `Queued: "${result.name}".` };
73    }
74  }
75
76  // Not a command. Let the AI handle it.
77  return null;
78}
79
1/**
2 * Study HTML Routes
3 *
4 * Server-rendered dashboard. Reads the tree. Renders what's there.
5 */
6
7import express from "express";
8import { sendError, ERR } from "../../seed/protocol.js";
9import urlAuth from "../html-rendering/urlAuth.js";
10import { htmlOnly } from "../html-rendering/htmlHelpers.js";
11import Node from "../../seed/models/node.js";
12import {
13  getQueue,
14  getActiveTopics,
15  getGaps,
16  getStudyProgress,
17  getProfile,
18  getCompletedTopics,
19  getStudyHistory,
20} from "./core.js";
21import { renderStudyDashboard } from "./pages/dashboard.js";
22
23const router = express.Router();
24
25// GET /root/:rootId/study?html - Full dashboard
26router.get("/root/:rootId/study", urlAuth, htmlOnly, async (req, res) => {
27  try {
28    const { rootId } = req.params;
29    const root = await Node.findById(rootId).select("name metadata").lean();
30    if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Study tree not found");
31
32    const meta = root.metadata instanceof Map ? root.metadata.get("study") : root.metadata?.study;
33    let queue = [], activeTopics = [], gaps = [], progress = null, profile = {}, completed = [], history = [];
34    if (meta?.initialized) {
35      [queue, activeTopics, gaps, progress, profile, completed, history] = await Promise.all([
36        getQueue(rootId),
37        getActiveTopics(rootId),
38        getGaps(rootId),
39        getStudyProgress(rootId),
40        getProfile(rootId),
41        getCompletedTopics(rootId),
42        getStudyHistory(rootId),
43      ]);
44    }
45
46    res.send(renderStudyDashboard({
47      rootId,
48      rootName: root.name,
49      queue,
50      activeTopics,
51      gaps,
52      progress,
53      profile,
54      completed,
55      history,
56      token: req.query.token || null,
57      userId: req.userId,
58      qs: req.query,
59      inApp: !!req.query.inApp,
60    }));
61  } catch (err) {
62    sendError(res, 500, ERR.INTERNAL, "Dashboard failed");
63  }
64});
65
66export default router;
67
1/**
2 * Study
3 *
4 * The tree that teaches you. Queue topics. Build curricula.
5 * Study through conversation. Track mastery. Detect gaps.
6 * Part of the proficiency stack: food fuels, fitness builds,
7 * recovery heals, study grows.
8 */
9
10import log from "../../seed/log.js";
11import sessionMode from "./modes/session.js";
12import planMode from "./modes/plan.js";
13import getTools from "./tools.js";
14import {
15  configure,
16  isInitialized,
17  getSetupPhase,
18  findStudyNodes,
19  getActiveTopics,
20  getStudyProgress,
21  getQueue,
22  getGaps,
23} from "./core.js";
24import { setDeps as setSetupDeps } from "./setup.js";
25import { handleMessage } from "./handler.js";
26
27export async function init(core) {
28  core.llm.registerRootLlmSlot?.("study");
29
30  const runChat = core.llm?.runChat || null;
31  configure({
32    Node: core.models.Node,
33    Note: core.models.Note,
34    runChat: runChat
35      ? async (opts) => {
36          if (opts.userId && opts.userId !== "SYSTEM") {
37            const hasLlm = await core.llm.userHasLlm(opts.userId);
38            if (!hasLlm) return { answer: null };
39          }
40          return core.llm.runChat({
41            ...opts,
42            llmPriority: core.llm.LLM_PRIORITY.INTERACTIVE,
43          });
44        }
45      : null,
46    metadata: core.metadata,
47  });
48  setSetupDeps({ metadata: core.metadata, Node: core.models.Node });
49
50  // ── Register modes: two modes only ──
51  core.modes.registerMode("tree:study-coach", sessionMode, "study");
52  core.modes.registerMode("tree:study-plan", planMode, "study");
53
54  if (core.llm?.registerModeAssignment) {
55    core.llm.registerModeAssignment("tree:study-coach", "studySession");
56    core.llm.registerModeAssignment("tree:study-plan", "studyPlan");
57  }
58
59  // ── Boot self-heal ──
60  core.hooks.register("afterBoot", async () => {
61    try {
62      const studyRoots = await core.models.Node.find({
63        "metadata.study.initialized": true,
64      }).select("_id metadata").lean();
65      for (const root of studyRoots) {
66        const modes = root.metadata instanceof Map
67          ? root.metadata.get("modes")
68          : root.metadata?.modes;
69        if (!modes?.respond) {
70          await core.modes.setNodeMode(root._id, "respond", "tree:study-coach");
71          log.verbose("Study", `Self-healed mode on ${String(root._id).slice(0, 8)}...`);
72        }
73      }
74    } catch {}
75  }, "study");
76
77  // ── enrichContext ──
78  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
79    if (!node?._id) return;
80
81    const studyMeta = meta?.study;
82    if (!studyMeta?.role) return;
83
84    const role = studyMeta.role;
85
86    if (role === "log" || role === "queue" || role === "active") {
87      // At structural nodes: show full study state
88      const rootId = String(node.parent || node._id);
89      const progress = await getStudyProgress(rootId);
90      if (progress) context.study = progress;
91
92    } else if (role === "topic") {
93      // At a topic: show subtopics with mastery
94      const subtopics = await core.models.Node.find({ parent: node._id })
95        .select("name metadata").lean();
96      context.studyTopic = {
97        name: node.name,
98        subtopics: subtopics
99          .filter(s => {
100            const sm = s.metadata instanceof Map ? s.metadata.get("study") : s.metadata?.study;
101            return sm?.role === "subtopic";
102          })
103          .map(s => {
104            const vals = s.metadata instanceof Map ? s.metadata.get("values") : s.metadata?.values;
105            return {
106              name: s.name,
107              mastery: vals?.mastery || 0,
108              attempts: vals?.attempts || 0,
109              lastStudied: vals?.lastStudied || null,
110            };
111          }),
112      };
113
114    } else if (role === "subtopic") {
115      // At a subtopic: show its mastery details
116      const vals = meta?.values || {};
117      context.studySubtopic = {
118        name: node.name,
119        mastery: vals.mastery || 0,
120        attempts: vals.attempts || 0,
121        lastStudied: vals.lastStudied || null,
122      };
123
124    } else if (role === "gaps") {
125      // At gaps node: show all gaps
126      const rootId = String(node.parent);
127      const gaps = await getGaps(rootId);
128      context.studyGaps = gaps;
129    }
130  }, "study");
131
132  // ── Register tool navigation (if treeos-base installed) ──
133  try {
134    const { getExtension } = await import("../loader.js");
135    const base = getExtension("treeos-base");
136    if (base?.exports?.registerToolNavigations) {
137      const nodeNav = ({ args, withToken: t }) => t(`/api/v1/node/${args.rootId || args.activeNodeId || args.topicId || args.subtopicId}?html`);
138      base.exports.registerToolNavigations({
139        "study-add-to-queue": nodeNav,
140        "study-create-topic": nodeNav,
141        "study-add-subtopic": nodeNav,
142        "study-update-mastery": nodeNav,
143        "study-move-to-active": nodeNav,
144        "study-detect-gap": nodeNav,
145        "study-complete-setup": nodeNav,
146        "study-save-profile": nodeNav,
147      });
148    }
149  } catch {}
150
151  // ── Live dashboard updates ──
152  core.hooks.register("afterNote", async ({ nodeId }) => {
153    if (!nodeId) return;
154    try {
155      const node = await core.models.Node.findById(nodeId).select("rootOwner metadata").lean();
156      if (!node?.rootOwner) return;
157      const fm = node.metadata instanceof Map ? node.metadata.get("study") : node.metadata?.study;
158      if (!fm?.role) return;
159      core.websocket?.emitToUser?.(String(node.rootOwner), "dashboardUpdate", { rootId: String(node.rootOwner) });
160    } catch {}
161  }, "study");
162
163  core.hooks.register("afterMetadataWrite", async ({ nodeId, extName }) => {
164    if (extName !== "values" && extName !== "study") return;
165    try {
166      const node = await core.models.Node.findById(nodeId).select("rootOwner").lean();
167      if (!node?.rootOwner) return;
168      core.websocket?.emitToUser?.(String(node.rootOwner), "dashboardUpdate", { rootId: String(node.rootOwner) });
169    } catch {}
170  }, "study");
171
172  // ── Register HTML dashboard (if html-rendering installed) ──
173  try {
174    const { getExtension } = await import("../loader.js");
175    const htmlExt = getExtension("html-rendering");
176    if (htmlExt) {
177      const { default: htmlRoutes } = await import("./htmlRoutes.js");
178      htmlExt.router.use("/", htmlRoutes);
179      log.verbose("Study", "HTML dashboard registered");
180    }
181  } catch {}
182
183  // ── Register apps-grid slot ──
184  try {
185    const { getExtension } = await import("../loader.js");
186    const base = getExtension("treeos-base");
187    base?.exports?.registerSlot?.("apps-grid", "study", ({ userId, rootMap, tokenParam, tokenField, esc: e }) => {
188      const entries = rootMap.get("Study") || [];
189      const existing = entries.map(entry =>
190        entry.ready
191          ? `<a class="app-active" href="/api/v1/root/${entry.id}/study?html${tokenParam}" style="margin-right:8px;margin-bottom:6px;">${e(entry.name)}</a>`
192          : `<a class="app-active" style="background:rgba(236,201,75,0.12);border-color:rgba(236,201,75,0.3);color:#ecc94b;margin-right:8px;margin-bottom:6px;" href="/api/v1/root/${entry.id}/study?html${tokenParam}">${e(entry.name)} (setup)</a>`
193      ).join("");
194      return `<div class="app-card">
195        <div class="app-header"><span class="app-emoji">📚</span><span class="app-name">Study</span></div>
196        <div class="app-desc">Queue topics, track mastery, detect gaps. The tree manages your curriculum.</div>
197        ${entries.length > 0
198          ? `<div style="display:flex;flex-wrap:wrap;">${existing}</div>`
199          : `<form class="app-form" method="POST" action="/api/v1/user/${userId}/apps/create">
200              ${tokenField}<input type="hidden" name="app" value="study" />
201              <input class="app-input" name="message" placeholder="What do you want to learn? (e.g. distributed systems, react hooks)" required />
202              <button class="app-start" type="submit">Start Study</button>
203            </form>`}
204      </div>`;
205    }, { priority: 40 });
206  } catch {}
207
208  // ── Import router ──
209  const { default: router, setServices } = await import("./routes.js");
210  setServices({ Node: core.models.Node });
211
212  const tools = getTools();
213
214  log.info("Study", "Loaded. The tree that teaches you.");
215
216  return {
217    router,
218    tools,
219    modeTools: [
220      { modeKey: "tree:study-plan", toolNames: [
221        "study-create-topic", "study-add-subtopic", "study-move-to-active",
222        "study-add-to-queue", "study-complete-setup", "study-save-profile",
223      ]},
224      { modeKey: "tree:study-coach", toolNames: [
225        "study-update-mastery", "study-detect-gap", "study-add-subtopic", "study-add-to-queue",
226      ]},
227    ],
228    exports: {
229      isInitialized,
230      getSetupPhase,
231      findStudyNodes,
232      getActiveTopics,
233      getStudyProgress,
234      getQueue,
235      getGaps,
236      handleMessage,
237      scaffold: (await import("./setup.js")).scaffold,
238    },
239  };
240}
241
1export default {
2  name: "study",
3  version: "1.0.2",
4  builtFor: "TreeOS",
5  description:
6    "The tree that teaches you. Queue topics. Build curricula. Study through conversation. " +
7    "Track mastery. Detect gaps. The AI guides you through the subject, asks questions, " +
8    "evaluates understanding, and adapts to your learning style. Integrates with the learn " +
9    "extension for URL content fetching. Mastery scoring from 0 to 100: introduced, basics, " +
10    "solid understanding, can teach it. When all subtopics hit 80%, the topic completes. " +
11    "Gap detection notices missing prerequisites during study and routes you through them. " +
12    "Type 'be' at the Study tree to start a guided session: the AI picks the next subtopic " +
13    "and begins teaching immediately. Part of the proficiency stack: food fuels, fitness " +
14    "builds, recovery heals, study grows.",
15
16  territory: "learning, teaching, quizzes, curriculum, mastery tracking",
17  classifierHints: [
18    /\b(study|teach me|quiz me|test me|drill me)\b/i,
19    /\b(need to learn|want to learn|should learn|add to queue)\b/i,
20    /\b(mastery|flashcard|curriculum|lesson|course|tutorial)\b/i,
21    /\b(studied|study session|study plan|study streak)\b/i,
22  ],
23
24  needs: {
25    models: ["Node", "Note"],
26    services: ["hooks", "llm", "metadata"],
27  },
28
29  optional: {
30    extensions: [
31      "learn",             // URL content fetching and decomposition
32      "values",            // mastery tracking on subtopic nodes
33      "channels",          // signal routing from Log to topics
34      "scheduler",         // daily study goal reminders
35      "notifications",     // study reminders
36      "gateway",           // push reminders externally
37      "html-rendering",    // study interface with iframe
38      "breath",            // sync to activity rhythm
39      "treeos-base",       // tool navigation registration
40    ],
41  },
42
43  provides: {
44    models: {},
45    routes: "./routes.js",
46    tools: true,
47    jobs: false,
48
49    guidedMode: "tree:study-coach",
50
51    hooks: {
52      fires: [],
53      listens: ["enrichContext", "afterBoot"],
54    },
55
56    cli: [
57      {
58        command: "study [message...]",
59        scope: ["tree"],
60        description: "Study session, queue management, progress.",
61        method: "POST",
62        endpoint: "/root/:rootId/study",
63        bodyMap: { message: 0 },
64        subcommands: {
65          switch: {
66            method: "POST",
67            endpoint: "/root/:rootId/study/switch",
68            description: "Activate a queue item by name or number.",
69            body: ["topic"],
70          },
71          stop: {
72            method: "POST",
73            endpoint: "/root/:rootId/study/deactivate",
74            description: "Deactivate topic, move back to queue.",
75            body: ["topic"],
76          },
77          remove: {
78            method: "POST",
79            endpoint: "/root/:rootId/study/remove",
80            description: "Delete from queue or active.",
81            body: ["topic"],
82          },
83          status: {
84            method: "GET",
85            endpoint: "/root/:rootId/study/status",
86            description: "Show active topics and mastery.",
87          },
88          gaps: {
89            method: "GET",
90            endpoint: "/root/:rootId/study/gaps",
91            description: "Show detected knowledge gaps.",
92          },
93        },
94      },
95      {
96        command: "needlearn [topic...]",
97        scope: ["tree"],
98        description: "Add a topic or URL to your study queue.",
99        method: "POST",
100        endpoint: "/root/:rootId/study/queue",
101        bodyMap: { topic: 0 },
102      },
103    ],
104  },
105};
106
1/**
2 * Study Log Mode
3 *
4 * Universal receiver. Handles queue adds, URL routing, questions.
5 * "needlearn X" adds to queue. URLs trigger learn extension. Questions answered.
6 */
7
8import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
9import { getQueue, getActiveTopics, getStudyProgress } from "../core.js";
10
11export default {
12  emoji: "📚",
13  label: "Study Log",
14  bigMode: "tree",
15  hidden: true,
16  maxMessagesBeforeLoop: 4,
17  preserveContextOnLoop: false,
18
19  toolNames: [
20    "study-add-to-queue",
21    "navigate-tree",
22  ],
23
24  async buildSystemPrompt({ username, rootId, currentNodeId }) {
25    const studyRoot = await findExtensionRoot(currentNodeId || rootId, "study") || rootId;
26    const queue = await getQueue(studyRoot);
27    const progress = await getStudyProgress(studyRoot);
28
29    const queueStr = queue.length > 0
30      ? queue.slice(0, 10).map((q, i) => `  ${i + 1}. ${q.name}${q.url ? " (URL)" : ""}${q.status === "active" ? " [active]" : ""}`).join("\n")
31      : "  (empty)";
32
33    const activeStr = progress?.active
34      ? `Currently studying: ${progress.active.topic} (${progress.active.completion}% complete, current: ${progress.active.currentSubtopic || "none"})`
35      : "No active topic.";
36
37    return `You are ${username}'s study companion. Handle incoming study requests.
38
39QUEUE (${queue.length} items):
40${queueStr}
41
42${activeStr}
43
44YOUR JOB:
451. If they say "needlearn X" or "I need to learn X" or "add X to queue": Use study-add-to-queue tool. Confirm briefly.
462. If they share a URL: Use study-add-to-queue with the URL. Note that the learn extension will fetch the content.
473. If they say "study" or "continue": Tell them to use the study command (routes to session mode).
484. If they ask a question about a topic: Answer it directly from your knowledge. Be concise.
495. If they ask about progress/status: Summarize their queue and active topics.
50
51STYLE:
52- Brief confirmations for queue adds. "Queued: React hooks. 5 items in queue."
53- Direct answers for questions. No fluff.
54- Never mention node IDs or metadata.`.trim();
55  },
56};
57
1/**
2 * Study Plan Mode
3 *
4 * Curriculum builder. Breaks topics into subtopics using AI knowledge.
5 * Scaffolds the Active tree with study tools. Handles first-time setup.
6 */
7
8import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
9import { getActiveTopics, getQueue, getStudyProgress, findStudyNodes } from "../core.js";
10
11export default {
12  emoji: "📋",
13  label: "Study Plan",
14  bigMode: "tree",
15  hidden: true,
16  maxMessagesBeforeLoop: 25,
17  preserveContextOnLoop: true,
18
19  toolNames: [
20    "study-create-topic",
21    "study-add-subtopic",
22    "study-move-to-active",
23    "study-add-to-queue",
24    "study-complete-setup",
25    "study-save-profile",
26    "navigate-tree",
27    "get-tree-context",
28    "create-node-note",
29    "edit-node-schedule",
30  ],
31
32  async buildSystemPrompt({ username, rootId, currentNodeId }) {
33    const studyRoot = await findExtensionRoot(currentNodeId || rootId, "study") || rootId;
34    const nodes = await findStudyNodes(studyRoot);
35    const queue = await getQueue(studyRoot);
36    const topics = await getActiveTopics(studyRoot);
37    const progress = await getStudyProgress(studyRoot);
38
39    const activeNodeId = nodes?.active?.id || "unknown";
40
41    const queueStr = queue.length > 0
42      ? queue.map(q => `  ${q.name}${q.url ? " (URL)" : ""}`).join("\n")
43      : "  (empty)";
44
45    const activeStr = topics.length > 0
46      ? topics.map(t => `  ${t.name}: ${t.subtopics.length} subtopics, ${t.completion}%`).join("\n")
47      : "  (none yet)";
48
49    if (!progress?.active && queue.length === 0 && topics.length === 0) {
50      return `You are ${username}'s curriculum builder.
51
52STATUS: No topics yet. Help them get started.
53Active node ID: ${activeNodeId}
54
55SETUP:
561. Ask what they want to learn. Could be a technology, a subject, a skill.
572. Ask about their learning style: theory-first, examples-first, or challenge-first.
583. Ask about daily study time goal (in minutes).
594. Save profile with study-save-profile.
605. Add their first topic to the queue with study-add-to-queue.
616. Move it to active with study-move-to-active.
627. Break it into subtopics with study-add-subtopic (5-10 concepts, ordered by prerequisite).
638. Call study-complete-setup.
64
65BUILDING A CURRICULUM:
66When breaking a topic into subtopics, think about:
67- What are the fundamental concepts? (learn these first)
68- What builds on what? (prerequisites as ordering)
69- What's the natural learning progression? (simple to complex)
70- 5-10 subtopics per topic is ideal. Too few = too broad. Too many = overwhelming.
71
72Example: "React Hooks" breaks into:
73  1. useState (fundamentals, start here)
74  2. useEffect (side effects, after useState)
75  3. useContext (state sharing, after useEffect)
76  4. useRef (DOM and values, parallel to useContext)
77  5. useMemo/useCallback (optimization, after basics)
78  6. useReducer (complex state, after useState)
79  7. Custom Hooks (composition, after all above)
80
81Be conversational. Don't dump a list. Ask what they know, then build from there.`;
82    }
83
84    return `You are ${username}'s curriculum builder.
85
86STATUS: ${topics.length} active topic${topics.length !== 1 ? "s" : ""}, ${queue.length} queued.
87Active node ID: ${activeNodeId}
88
89QUEUE:
90${queueStr}
91
92ACTIVE TOPICS:
93${activeStr}
94
95You can:
96- Move queue items to active study (study-move-to-active)
97- Create new topics directly (study-create-topic, needs activeNodeId)
98- Break topics into subtopics (study-add-subtopic)
99- Add to queue (study-add-to-queue)
100- Write study plans as notes (create-node-note on topic nodes)
101
102When the user says "break down X" or "build curriculum for X":
1031. Use study-create-topic to create it under Active
1042. Use study-add-subtopic for each concept (5-10 per topic)
1053. Order by prerequisites (order param, lower = learn first)
106
107Ask what they want to work on. Build it.`.trim();
108  },
109};
110
1/**
2 * Study Review Mode
3 *
4 * Progress analysis. Mastery across topics, gaps, streaks, time spent.
5 */
6
7import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
8import { getActiveTopics, getStudyProgress, getGaps, getProfile } from "../core.js";
9
10export default {
11  emoji: "📊",
12  label: "Study Review",
13  bigMode: "tree",
14  hidden: true,
15  maxMessagesBeforeLoop: 20,
16  preserveContextOnLoop: true,
17
18  toolNames: [
19    "navigate-tree",
20    "get-tree-context",
21    "get-node-notes",
22  ],
23
24  async buildSystemPrompt({ username, rootId, currentNodeId }) {
25    const studyRoot = await findExtensionRoot(currentNodeId || rootId, "study") || rootId;
26    const topics = await getActiveTopics(studyRoot);
27    const progress = await getStudyProgress(studyRoot);
28    const gaps = await getGaps(studyRoot);
29    const profile = await getProfile(studyRoot);
30
31    const topicsStr = topics.length > 0
32      ? topics.map(t => {
33          const subs = t.subtopics.map(s =>
34            `    ${s.complete ? "done" : s.mastery + "%"} ${s.name}`
35          ).join("\n");
36          return `  ${t.name} (${t.completion}% complete)\n${subs}`;
37        }).join("\n\n")
38      : "  No active topics.";
39
40    const gapsStr = gaps.length > 0
41      ? gaps.map(g => `  ${g.name} (found during ${g.detectedDuring})`).join("\n")
42      : "  None detected.";
43
44    return `You are ${username}'s learning analyst. Review their study progress.
45
46ACTIVE TOPICS:
47${topicsStr}
48
49COMPLETED: ${progress?.completed?.allTime || 0} topics all time
50QUEUED: ${progress?.queue?.count || 0} topics waiting
51GAPS:
52${gapsStr}
53
54Daily goal: ${profile?.dailyStudyMinutes || "not set"} minutes
55
56ANALYZE:
571. Which concepts are they strongest in? Weakest?
582. Are there patterns in what they struggle with?
593. How are the gaps affecting progress?
604. Is the curriculum order working? Should anything be reordered?
615. What should they study next?
62
63Use navigate-tree and get-node-notes to read History for session details and time tracking.
64
65STYLE:
66- Lead with progress. What's working.
67- Then gaps and what needs attention.
68- Suggest next steps concretely.
69- Use percentages for mastery, but phrase naturally: "You've got closures down solid but useEffect cleanup is shaky."
70- Never mention node IDs or tools.`.trim();
71  },
72};
73
1/**
2 * Study Session Mode
3 *
4 * The one mode that handles teaching. The prompt carries all intelligence.
5 * The handler sends raw messages. The AI decides what to do.
6 */
7
8import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
9import { getActiveTopics, getQueue, getProfile, getGaps } from "../core.js";
10
11export default {
12  emoji: "🎓",
13  label: "Study Session",
14  bigMode: "tree",
15  hidden: true,
16  maxMessagesBeforeLoop: 30,
17  preserveContextOnLoop: true,
18
19  toolNames: [
20    "study-update-mastery",
21    "study-detect-gap",
22    "study-add-subtopic",
23    "study-add-to-queue",
24    "create-node-note",
25    "get-tree-context",
26  ],
27
28  async buildSystemPrompt({ username, rootId, currentNodeId }) {
29    const studyRoot = await findExtensionRoot(currentNodeId || rootId, "study") || rootId;
30    const topics = await getActiveTopics(studyRoot);
31    const queue = await getQueue(studyRoot);
32    const profile = await getProfile(studyRoot);
33    const gaps = await getGaps(studyRoot);
34
35    // Current topic: most recently studied
36    let currentTopic = null;
37    if (topics.length > 0) {
38      topics.sort((a, b) => (b.lastStudied || "").localeCompare(a.lastStudied || ""));
39      currentTopic = topics[0];
40    }
41
42    // Next subtopic: lowest mastery, not complete
43    let nextSubtopic = null;
44    if (currentTopic?.subtopics?.length > 0) {
45      const incomplete = currentTopic.subtopics.filter(s => !s.complete);
46      if (incomplete.length > 0) {
47        incomplete.sort((a, b) => (a.mastery || 0) - (b.mastery || 0));
48        nextSubtopic = incomplete[0];
49      }
50    }
51
52    // Build state blocks
53    const topicBlock = currentTopic
54      ? `CURRENT TOPIC: ${currentTopic.name} (${currentTopic.completion}% complete)\n` +
55        (currentTopic.subtopics || []).map(s =>
56          `  ${s.complete ? "DONE" : s.mastery + "%"} ${s.name} [id: ${s.id}]`
57        ).join("\n")
58      : "No active topic.";
59
60    const nextBlock = nextSubtopic
61      ? `TEACH NEXT: "${nextSubtopic.name}" [subtopicId: ${nextSubtopic.id}] (${nextSubtopic.mastery}%)`
62      : currentTopic && (currentTopic.subtopics || []).length === 0
63        ? `NO SUBTOPICS: Create 3-8 with study-add-subtopic (topicId: ${currentTopic.id}), then teach the first one.`
64        : currentTopic
65          ? "ALL SUBTOPICS COMPLETE. Suggest next topic from queue."
66          : "";
67
68    const queueBlock = queue.length > 0
69      ? `QUEUE: ${queue.map(q => q.name).join(", ")}`
70      : "";
71
72    const otherTopics = topics.filter(t => t !== currentTopic);
73    const otherBlock = otherTopics.length > 0
74      ? `OTHER ACTIVE: ${otherTopics.map(t => `${t.name} (${t.completion}%)`).join(", ")}`
75      : "";
76
77    const gapBlock = gaps.length > 0
78      ? `GAPS: ${gaps.map(g => g.name).join(", ")}`
79      : "";
80
81    const style = profile?.learningStyle || "examples-first";
82
83    return `You are ${username}'s tutor. You teach through dialogue.
84
85${topicBlock}
86${nextBlock}
87${[queueBlock, otherBlock, gapBlock].filter(Boolean).join("\n")}
88
89RULES:
901. Explain a concept briefly. Use ${style === "theory-first" ? "principles first" : style === "challenge-first" ? "a problem to solve" : "a concrete example"}.
912. Ask ONE question.
923. When they answer, call study-update-mastery with the subtopicId and a score (0-100).
934. Move to the next subtopic when mastery reaches 80%.
945. If they lack a prerequisite, call study-detect-gap.
956. If they want to switch topics, stop studying, learn something new, or navigate away, help them. Tell them available commands: "switch <topic>", "needlearn <topic>", "stop <topic>".
967. If they ask about progress, answer from the data above.
978. Never show IDs, scores, or tool names to the user.
989. Never offer menus or lists of options. Teach or navigate. Nothing else.`.trim();
99  },
100};
101
1/**
2 * Study Dashboard
3 *
4 * Active topics with mastery, queue with actions, completed topics,
5 * knowledge gaps with promote/delete, study history, profile.
6 */
7
8import { page } from "../../html-rendering/html/layout.js";
9import { esc, timeAgo } from "../../html-rendering/html/utils.js";
10import { glassCardStyles, glassHeaderStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
11import { chatBarCss, chatBarHtml, chatBarJs, commandsRefHtml } from "../../html-rendering/html/chatBar.js";
12
13function masteryColor(pct) {
14  if (pct >= 80) return "#48bb78";
15  if (pct >= 30) return "#ecc94b";
16  return "#718096";
17}
18
19function masteryLabel(pct) {
20  if (pct >= 80) return "mastered";
21  if (pct >= 60) return "solid";
22  if (pct >= 30) return "learning";
23  if (pct > 0) return "introduced";
24  return "not started";
25}
26
27export function renderStudyDashboard({ rootId, rootName, queue, activeTopics, gaps, progress, profile, completed, history, token, userId, inApp }) {
28  const completedCount = completed?.length || progress?.completed?.allTime || 0;
29  const queueCount = queue?.length || 0;
30  const dailyGoal = profile?.dailyStudyMinutes || 0;
31  const learningStyle = profile?.learningStyle || null;
32  const today = new Date();
33  const dateStr = today.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
34
35  let totalMastery = 0, totalSubtopics = 0;
36  for (const topic of (activeTopics || [])) {
37    for (const s of (topic.subtopics || [])) {
38      totalMastery += s.mastery || 0;
39      totalSubtopics++;
40    }
41  }
42  const avgMastery = totalSubtopics > 0 ? Math.round(totalMastery / totalSubtopics) : 0;
43
44  const css = `
45    ${glassHeaderStyles}
46    ${glassCardStyles}
47    ${responsiveBase}
48
49    .study-container { max-width: 1000px; margin: 0 auto; padding: 1.5rem; }
50    .study-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-top: 1.5rem; }
51    @media (max-width: 900px) { .study-layout { grid-template-columns: 1fr; } }
52
53    .stat-row { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0 16px; }
54    .stat-chip { background: rgba(255,255,255,0.06); border-radius: 16px; padding: 4px 12px; font-size: 0.8rem; color: rgba(255,255,255,0.5); }
55    .stat-chip strong { color: rgba(255,255,255,0.8); }
56
57    .section-title { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(255,255,255,0.5); margin-bottom: 0.5rem; }
58
59    .topic-card { margin-bottom: 1rem; padding: 16px; }
60    .topic-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
61    .topic-name { font-size: 1.05rem; color: #fff; font-weight: 600; }
62    .topic-pct { font-size: 0.85rem; font-weight: 500; }
63    .topic-bar { height: 4px; background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden; margin-bottom: 12px; }
64    .topic-bar-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
65
66    .subtopic { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.04); }
67    .subtopic:last-child { border-bottom: none; }
68    .subtopic-name { flex: 1; color: rgba(255,255,255,0.8); font-size: 0.9rem; }
69    .subtopic-bar { width: 100px; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
70    .subtopic-fill { height: 100%; border-radius: 3px; }
71    .subtopic-pct { width: 35px; text-align: right; font-size: 0.8rem; color: rgba(255,255,255,0.5); }
72    .subtopic-meta { display: flex; flex-direction: column; align-items: flex-end; min-width: 70px; }
73    .subtopic-label { font-size: 0.7rem; color: rgba(255,255,255,0.3); }
74    .subtopic-last { font-size: 0.7rem; color: rgba(255,255,255,0.2); }
75
76    .queue-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.04); font-size: 0.9rem; }
77    .queue-item:last-child { border-bottom: none; }
78    .queue-name { color: #fff; }
79    .queue-meta { color: rgba(255,255,255,0.35); font-size: 0.8rem; display: flex; gap: 6px; align-items: center; }
80    .queue-url { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: rgba(102,126,234,0.4); }
81
82    .gap-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.04); }
83    .gap-item:last-child { border-bottom: none; }
84    .gap-name { color: #ecc94b; font-size: 0.9rem; }
85    .gap-context { color: rgba(255,255,255,0.3); font-size: 0.75rem; }
86
87    .completed-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-size: 0.85rem; }
88    .completed-item:last-child { border-bottom: none; }
89    .completed-name { color: rgba(72,187,120,0.8); }
90    .completed-date { color: rgba(255,255,255,0.25); font-size: 0.75rem; }
91
92    .history-item { padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-size: 0.8rem; color: rgba(255,255,255,0.4); }
93    .history-item:last-child { border-bottom: none; }
94
95    .action-btn { background: none; border: none; cursor: pointer; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; color: rgba(255,255,255,0.3); }
96    .action-btn:hover { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.06); }
97    .action-btn.danger:hover { color: #ef4444; }
98    .action-btn.activate:hover { color: #4ade80; }
99    .action-row { display: flex; gap: 4px; align-items: center; }
100
101    .rename-input { background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.3); border-radius: 4px; color: #fff; font-size: 0.9rem; padding: 2px 6px; outline: none; width: 100%; }
102    .rename-input:focus { border-color: #4ade80; }
103
104    .iframe-container { border-radius: 12px; overflow: hidden; background: rgba(255,255,255,0.03); min-height: 350px; }
105    .iframe-container iframe { width: 100%; height: 450px; border: none; }
106    .iframe-placeholder { display: flex; align-items: center; justify-content: center; min-height: 350px; color: rgba(255,255,255,0.25); font-size: 0.85rem; }
107
108    .empty-state { color: rgba(255,255,255,0.35); font-size: 0.9rem; padding: 1rem 0; font-style: italic; }
109  `;
110
111  // ── Queue ──
112  const queueHtml = queue.length > 0
113    ? queue.map(q => {
114      const nameDisplay = q.url
115        ? `<span class="queue-name" style="color:rgba(102,126,234,0.9);cursor:pointer" onclick="loadResource('${esc(q.url)}')">${esc(q.name.length > 60 ? q.name.slice(0, 60) + "..." : q.name)}</span>`
116        : `<span class="queue-name">${esc(q.name)}</span>`;
117      return `
118        <div class="queue-item" data-id="${esc(q.id)}">
119          <div style="flex:1;display:flex;align-items:center;gap:6px">
120            ${nameDisplay}
121            ${!q.url ? `<button class="action-btn" onclick="renameItem('${esc(q.id)}', this)" title="Rename">\u270e</button>` : ""}
122          </div>
123          <div class="action-row">
124            <span class="queue-meta">${q.added ? timeAgo(q.added) : ""}${q.url ? ' <span class="queue-url"></span>' : ""}</span>
125            <button class="action-btn activate" onclick="activateItem('${esc(q.id)}')" title="Start studying">\u25b6</button>
126            <button class="action-btn danger" onclick="deleteItem('${esc(q.id)}')" title="Delete">\u00d7</button>
127          </div>
128        </div>`;
129    }).join("")
130    : '<div class="empty-state">Queue empty. Use needlearn to add topics.</div>';
131
132  // ── Active topics ──
133  const activeHtml = (activeTopics || []).length > 0
134    ? activeTopics.map(topic => {
135        const subsHtml = (topic.subtopics || []).map(s => `
136          <div class="subtopic">
137            <span class="subtopic-name">${esc(s.name)}</span>
138            <div class="subtopic-bar">
139              <div class="subtopic-fill" style="width:${s.mastery}%;background:${masteryColor(s.mastery)}"></div>
140            </div>
141            <span class="subtopic-pct">${s.mastery}%</span>
142            <div class="subtopic-meta">
143              <span class="subtopic-label">${masteryLabel(s.mastery)}</span>
144              ${s.lastStudied ? `<span class="subtopic-last">${timeAgo(new Date(s.lastStudied))}</span>` : ""}
145              ${s.attempts ? `<span class="subtopic-last">${s.attempts} attempt${s.attempts > 1 ? "s" : ""}</span>` : ""}
146            </div>
147          </div>
148        `).join("");
149
150        const topicColor = masteryColor(topic.completion);
151
152        return `
153          <div class="topic-card glass-card" data-id="${esc(topic.id)}">
154            <div class="topic-header">
155              <div style="display:flex;align-items:center;gap:6px">
156                <span class="topic-name">${esc(topic.name)}</span>
157                <button class="action-btn" onclick="renameItem('${esc(topic.id)}', this)" title="Rename">\u270e</button>
158              </div>
159              <div class="action-row">
160                <span class="topic-pct" style="color:${topicColor}">${topic.completion}%</span>
161                <button class="action-btn" onclick="dequeueItem('${esc(topic.id)}')" title="Back to queue">\u23f8</button>
162              </div>
163            </div>
164            <div class="topic-bar"><div class="topic-bar-fill" style="width:${topic.completion}%;background:${topicColor}"></div></div>
165            ${subsHtml || '<div class="empty-state" style="padding:0.5rem 0">No subtopics yet. Start a study session to decompose this topic.</div>'}
166          </div>
167        `;
168      }).join("")
169    : '<div class="empty-state">No active topics. Pick one from the queue to start studying.</div>';
170
171  // ── Gaps ──
172  const gapsHtml = (gaps || []).length > 0
173    ? gaps.map(g => `
174      <div class="gap-item" data-id="${esc(g.id)}">
175        <div>
176          <span class="gap-name">${esc(g.name)}</span>
177          <div class="gap-context">found studying ${esc(g.detectedDuring || "unknown")}${g.detectedAt ? " . " + timeAgo(new Date(g.detectedAt)) : ""}</div>
178        </div>
179        <div class="action-row">
180          <button class="action-btn activate" onclick="queueGap('${esc(g.name)}')" title="Add to queue">\u25b6</button>
181          <button class="action-btn danger" onclick="deleteItem('${esc(g.id)}')" title="Delete">\u00d7</button>
182        </div>
183      </div>
184    `).join("")
185    : '<div class="empty-state">No gaps detected yet.</div>';
186
187  // ── Completed ──
188  const completedHtml = (completed || []).length > 0
189    ? completed.slice(0, 10).map(c => `
190      <div class="completed-item">
191        <span class="completed-name">${esc(c.name)}</span>
192        <span class="completed-date">${c.completedAt ? timeAgo(new Date(c.completedAt)) : ""}</span>
193      </div>
194    `).join("")
195    : '<div class="empty-state">No topics completed yet.</div>';
196
197  // ── History ──
198  const historyHtml = (history || []).length > 0
199    ? history.slice(0, 15).map(h => `
200      <div class="history-item">${esc(h.text)}${h.date ? ` <span style="color:rgba(255,255,255,0.2)">${timeAgo(new Date(h.date))}</span>` : ""}</div>
201    `).join("")
202    : '<div class="empty-state">No activity yet.</div>';
203
204  // ── Profile ──
205  const profileParts = [];
206  if (learningStyle) profileParts.push(learningStyle.replace("-", " "));
207  if (dailyGoal) profileParts.push(`${dailyGoal} min/day`);
208
209  const body = `
210    <div class="study-container">
211      ${userId ? `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><a href="/api/v1/user/${userId}/apps?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">\u2190 Apps</a><div style="display:flex;gap:16px;"><a href="/api/v1/root/${rootId}?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">Tree</a><a href="/api/v1/node/${rootId}/metadata?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">Metadata</a><a href="/api/v1/user/${userId}/llm?html${token ? "&token=" + esc(token) : ""}" style="font-size:0.85rem;color:rgba(255,255,255,0.4);text-decoration:none;">LLM</a></div></div>` : ""}
212      <h1 style="font-size:1.5rem;color:#fff;margin-bottom:0.2rem">${esc(rootName || "Study")}</h1>
213      <div style="color:rgba(255,255,255,0.35);font-size:0.85rem;margin-top:4px">${dateStr}${profileParts.length > 0 ? " . " + esc(profileParts.join(" . ")) : ""}</div>
214
215      <div class="stat-row">
216        ${completedCount ? `<span class="stat-chip"><strong>${completedCount}</strong> completed</span>` : ""}
217        <span class="stat-chip"><strong>${queueCount}</strong> queued</span>
218        ${(activeTopics || []).length > 0 ? `<span class="stat-chip"><strong>${activeTopics.length}</strong> active</span>` : ""}
219        ${totalSubtopics > 0 ? `<span class="stat-chip">avg mastery <strong>${avgMastery}%</strong></span>` : ""}
220        ${(gaps || []).length > 0 ? `<span class="stat-chip" style="color:#ecc94b"><strong>${gaps.length}</strong> gap${gaps.length > 1 ? "s" : ""}</span>` : ""}
221      </div>
222
223      <div class="study-layout">
224        <div>
225          <div class="section-title">Active Topics</div>
226          ${activeHtml}
227
228          <div class="glass-card" style="padding:14px">
229            <div class="section-title" style="margin-top:0">Queue (${queue.length})</div>
230            ${queueHtml}
231          </div>
232
233          ${(gaps || []).length > 0 ? `
234          <div class="glass-card" style="padding:14px;margin-top:1rem">
235            <div class="section-title" style="margin-top:0">Knowledge Gaps</div>
236            ${gapsHtml}
237          </div>
238          ` : ""}
239        </div>
240
241        <div>
242          ${queue.some(q => q.url) ? `
243          <div class="glass-card" style="padding:14px;margin-bottom:1rem">
244            <div class="section-title" style="margin-top:0">Resources</div>
245            <div class="iframe-container" id="resource-frame">
246              <div class="iframe-placeholder">Click a URL in the queue to preview.</div>
247            </div>
248          </div>` : ""}
249
250          ${(completed || []).length > 0 ? `
251          <div class="glass-card" style="padding:14px;margin-bottom:1rem">
252            <div class="section-title" style="margin-top:0">Completed (${completed.length})</div>
253            ${completedHtml}
254          </div>
255          ` : ""}
256
257          <div class="glass-card" style="padding:14px">
258            <div class="section-title" style="margin-top:0">Activity</div>
259            ${historyHtml}
260          </div>
261        </div>
262      </div>
263
264      ${commandsRefHtml([
265        { cmd: "needlearn <topic>", desc: "Add to study queue" },
266        { cmd: "study", desc: "Start or continue studying" },
267        { cmd: "study switch <topic>", desc: "Activate a queue item" },
268        { cmd: "study stop <topic>", desc: "Deactivate, back to queue" },
269        { cmd: "study remove <topic>", desc: "Delete from queue" },
270        { cmd: "study progress", desc: "Review mastery and gaps" },
271        { cmd: "study plan", desc: "Build or modify curriculum" },
272        { cmd: "be", desc: "AI picks next lesson and teaches" },
273      ])}
274    </div>
275  `;
276
277  const js = `
278    function loadResource(url) {
279      var container = document.getElementById('resource-frame');
280      if (container) container.innerHTML = '<iframe src="' + url + '" sandbox="allow-same-origin allow-scripts"></iframe>';
281    }
282
283    async function studyFetch(url, method, body) {
284      var opts = { method: method || 'POST', credentials: 'include' };
285      if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); }
286      return fetch(url, opts);
287    }
288
289    async function activateItem(id) {
290      var res = await studyFetch('/api/v1/root/${rootId}/study/activate/' + id, 'POST');
291      if (res.ok) location.reload();
292    }
293
294    async function dequeueItem(id) {
295      var res = await studyFetch('/api/v1/root/${rootId}/study/dequeue/' + id, 'POST');
296      if (res.ok) location.reload();
297    }
298
299    async function deleteItem(id) {
300      var el = document.querySelector('[data-id="' + id + '"]');
301      if (el) el.style.opacity = '0.3';
302      var res = await studyFetch('/api/v1/root/${rootId}/study/item/' + id, 'DELETE');
303      if (res.ok) location.reload();
304      else if (el) el.style.opacity = '1';
305    }
306
307    async function queueGap(name) {
308      var res = await studyFetch('/api/v1/root/${rootId}/study/queue', 'POST', { topic: name });
309      if (res.ok) location.reload();
310    }
311
312    function renameItem(id, btn) {
313      var nameEl = btn.previousElementSibling;
314      var oldName = nameEl.textContent;
315      var input = document.createElement('input');
316      input.className = 'rename-input';
317      input.value = oldName;
318      nameEl.style.display = 'none';
319      btn.style.display = 'none';
320      nameEl.parentElement.insertBefore(input, nameEl);
321      input.focus();
322      input.select();
323
324      async function save() {
325        var newName = input.value.trim();
326        if (!newName || newName === oldName) {
327          input.remove(); nameEl.style.display = ''; btn.style.display = ''; return;
328        }
329        var res = await studyFetch('/api/v1/root/${rootId}/study/rename/' + id, 'POST', { name: newName });
330        if (res.ok) nameEl.textContent = newName;
331        input.remove(); nameEl.style.display = ''; btn.style.display = '';
332      }
333
334      input.addEventListener('keydown', function(e) {
335        if (e.key === 'Enter') { e.preventDefault(); save(); }
336        if (e.key === 'Escape') { input.remove(); nameEl.style.display = ''; btn.style.display = ''; }
337      });
338      input.addEventListener('blur', save);
339    }
340  `;
341
342  return page({
343    title: `${rootName || "Study"} . ${dateStr}`,
344    css: css + (!inApp ? chatBarCss() : ""),
345    body: body + (!inApp ? chatBarHtml({ placeholder: "What do you want to learn? Or say 'study' to start a session..." }) : ""),
346    js: js + (!inApp ? chatBarJs({ endpoint: `/api/v1/root/${rootId}/study`, token, rootId }) : ""),
347  });
348}
349
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import log from "../../seed/log.js";
5import NodeModel from "../../seed/models/node.js";
6import UserModel from "../../seed/models/user.js";
7import {
8  isInitialized,
9  getStudyProgress,
10  getGaps,
11  addToQueue,
12  switchToTopic,
13  deactivateTopic,
14  removeFromQueue,
15} from "./core.js";
16import { handleMessage } from "./handler.js";
17
18let Node = NodeModel;
19export function setServices({ Node: N }) { if (N) Node = N; }
20
21
22const router = express.Router();
23
24/**
25 * POST /root/:rootId/study
26 */
27router.post("/root/:rootId/study", authenticate, async (req, res) => {
28  try {
29    const { rootId } = req.params;
30    const rawMessage = req.body.message;
31    const message = Array.isArray(rawMessage) ? rawMessage.join(" ") : (rawMessage || "");
32
33    const root = await Node.findById(rootId).select("rootOwner contributors").lean();
34    if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
35
36    const userId = req.userId;
37    const isOwner = root.rootOwner?.toString() === userId;
38    const isContributor = root.contributors?.some(c => c.toString() === userId);
39    if (!isOwner && !isContributor) return sendError(res, 403, ERR.FORBIDDEN, "No access");
40
41    const { isExtensionBlockedAtNode } = await import("../../seed/tree/extensionScope.js");
42    if (await isExtensionBlockedAtNode("study", rootId)) {
43      return sendError(res, 403, ERR.EXTENSION_BLOCKED, "Study is blocked on this branch.");
44    }
45
46    const user = await UserModel.findById(userId).select("username").lean();
47    const username = user?.username || "user";
48
49    const result = await handleMessage(message, { userId, username, rootId, res });
50
51    if (result.error) {
52      if (!res.headersSent) sendError(res, result.status || 500, result.code || ERR.INTERNAL, result.message);
53      return;
54    }
55
56    if (!res.headersSent) sendOk(res, result);
57  } catch (err) {
58    log.error("Study", "Route error:", err.message);
59    if (!res.headersSent) sendError(res, 500, ERR.INTERNAL, "Study request failed");
60  }
61});
62
63/**
64 * POST /root/:rootId/study/queue - needlearn shortcut
65 */
66router.post("/root/:rootId/study/queue", authenticate, async (req, res) => {
67  try {
68    const { rootId } = req.params;
69    const rawTopic = req.body.topic;
70    const topic = Array.isArray(rawTopic) ? rawTopic.join(" ") : rawTopic;
71    if (!topic) return sendError(res, 400, ERR.INVALID_INPUT, "topic required");
72
73    if (!(await isInitialized(rootId))) {
74      return sendError(res, 400, ERR.INVALID_INPUT, "Study tree not initialized. Use 'study' first.");
75    }
76
77    const isUrl = /^https?:\/\//.test(topic);
78    const result = await addToQueue(rootId, topic, req.userId, { url: isUrl ? topic : null });
79    sendOk(res, { queued: result.name, url: isUrl || undefined });
80  } catch (err) {
81    log.error("Study", "Queue error:", err.message);
82    sendError(res, 500, ERR.INTERNAL, "Queue add failed");
83  }
84});
85
86/**
87 * GET /root/:rootId/study/status
88 */
89router.get("/root/:rootId/study/status", authenticate, async (req, res) => {
90  try {
91    const progress = await getStudyProgress(req.params.rootId);
92    if (!progress) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Study tree not found");
93    sendOk(res, progress);
94  } catch (err) {
95    sendError(res, 500, ERR.INTERNAL, "Status failed");
96  }
97});
98
99/**
100 * GET /root/:rootId/study/gaps
101 */
102router.get("/root/:rootId/study/gaps", authenticate, async (req, res) => {
103  try {
104    const gaps = await getGaps(req.params.rootId);
105    sendOk(res, { gaps });
106  } catch (err) {
107    sendError(res, 500, ERR.INTERNAL, "Gaps failed");
108  }
109});
110
111/**
112 * POST /root/:rootId/study/switch - activate a queue item
113 */
114router.post("/root/:rootId/study/switch", authenticate, async (req, res) => {
115  try {
116    const { rootId } = req.params;
117    const rawTopic = req.body.topic;
118    const topic = Array.isArray(rawTopic) ? rawTopic.join(" ") : rawTopic;
119    if (!topic) return sendError(res, 400, ERR.INVALID_INPUT, "topic required");
120
121    if (!(await isInitialized(rootId))) {
122      return sendError(res, 400, ERR.INVALID_INPUT, "Study tree not initialized.");
123    }
124
125    const result = await switchToTopic(rootId, topic, req.userId);
126    if (result.alreadyActive) {
127      sendOk(res, { answer: `"${result.name}" is already active.`, name: result.name });
128    } else {
129      sendOk(res, { answer: `Switched to "${result.name}".`, name: result.name });
130    }
131  } catch (err) {
132    sendError(res, 500, ERR.INTERNAL, err.message);
133  }
134});
135
136/**
137 * POST /root/:rootId/study/deactivate - move active topic back to queue
138 */
139router.post("/root/:rootId/study/deactivate", authenticate, async (req, res) => {
140  try {
141    const { rootId } = req.params;
142    const rawTopic = req.body.topic;
143    const topic = Array.isArray(rawTopic) ? rawTopic.join(" ") : rawTopic;
144    if (!topic) return sendError(res, 400, ERR.INVALID_INPUT, "topic required");
145
146    if (!(await isInitialized(rootId))) {
147      return sendError(res, 400, ERR.INVALID_INPUT, "Study tree not initialized.");
148    }
149
150    const result = await deactivateTopic(rootId, topic, req.userId);
151    sendOk(res, { answer: `Deactivated "${result.name}". Moved back to queue.`, name: result.name });
152  } catch (err) {
153    sendError(res, 500, ERR.INTERNAL, err.message);
154  }
155});
156
157/**
158 * POST /root/:rootId/study/remove - delete from queue or active
159 */
160router.post("/root/:rootId/study/remove", authenticate, async (req, res) => {
161  try {
162    const { rootId } = req.params;
163    const rawTopic = req.body.topic;
164    const topic = Array.isArray(rawTopic) ? rawTopic.join(" ") : rawTopic;
165    if (!topic) return sendError(res, 400, ERR.INVALID_INPUT, "topic required");
166
167    if (!(await isInitialized(rootId))) {
168      return sendError(res, 400, ERR.INVALID_INPUT, "Study tree not initialized.");
169    }
170
171    const result = await removeFromQueue(rootId, topic, req.userId);
172    sendOk(res, { answer: `Removed "${result.name}".`, name: result.name });
173  } catch (err) {
174    sendError(res, 500, ERR.INTERNAL, err.message);
175  }
176});
177
178/**
179 * POST /root/:rootId/study/rename/:nodeId - Rename a topic/queue item
180 */
181router.post("/root/:rootId/study/rename/:nodeId", authenticate, async (req, res) => {
182  try {
183    const { name } = req.body;
184    if (!name) return sendError(res, 400, ERR.INVALID_INPUT, "name required");
185    // Block renaming URL-based queue items (name is the URL)
186    const node = await Node.findById(req.params.nodeId).select("metadata").lean();
187    if (node) {
188      const meta = node.metadata instanceof Map ? node.metadata.get("study") : node.metadata?.study;
189      if (meta?.url) return sendError(res, 400, ERR.INVALID_INPUT, "Cannot rename URL-based items");
190    }
191    await Node.findByIdAndUpdate(req.params.nodeId, { name: name.trim() });
192    sendOk(res, { renamed: true, name: name.trim() });
193  } catch (err) {
194    sendError(res, 500, ERR.INTERNAL, err.message);
195  }
196});
197
198/**
199 * POST /root/:rootId/study/activate/:nodeId - Activate a queue item by ID
200 */
201router.post("/root/:rootId/study/activate/:nodeId", authenticate, async (req, res) => {
202  try {
203    const node = await Node.findById(req.params.nodeId).select("name").lean();
204    if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Not found");
205    const result = await switchToTopic(req.params.rootId, node.name, req.userId);
206    sendOk(res, { activated: true, name: result.name });
207  } catch (err) {
208    sendError(res, 500, ERR.INTERNAL, err.message);
209  }
210});
211
212/**
213 * POST /root/:rootId/study/dequeue/:nodeId - Move active topic back to queue by ID
214 */
215router.post("/root/:rootId/study/dequeue/:nodeId", authenticate, async (req, res) => {
216  try {
217    const node = await Node.findById(req.params.nodeId).select("name").lean();
218    if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Not found");
219    const result = await deactivateTopic(req.params.rootId, node.name, req.userId);
220    sendOk(res, { dequeued: true, name: result.name });
221  } catch (err) {
222    log.error("Study", `Dequeue failed: ${err.message} (userId: ${req.userId}, nodeId: ${req.params.nodeId})`);
223    sendError(res, 500, ERR.INTERNAL, err.message);
224  }
225});
226
227/**
228 * DELETE /root/:rootId/study/item/:nodeId - Delete a queue item or topic by ID
229 */
230router.delete("/root/:rootId/study/item/:nodeId", authenticate, async (req, res) => {
231  try {
232    const node = await Node.findById(req.params.nodeId).select("name").lean();
233    if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Not found");
234    await removeFromQueue(req.params.rootId, node.name, req.userId);
235    sendOk(res, { deleted: true, name: node.name });
236  } catch (err) {
237    sendError(res, 500, ERR.INTERNAL, err.message);
238  }
239});
240
241export default router;
242
1/**
2 * Study Setup
3 *
4 * Scaffolds the study tree. Log, Queue, Active, Completed, Gaps, Profile, History.
5 * Topic and subtopic creators for the AI to build curricula conversationally.
6 */
7
8import log from "../../seed/log.js";
9import { setNodeMode } from "../../seed/modes/registry.js";
10import { ROLES } from "./core.js";
11
12let _metadata = null;
13let _Node = null;
14
15export function setDeps({ metadata, Node }) {
16  _metadata = metadata;
17  _Node = Node;
18}
19
20// ── Base scaffold ──
21
22export async function scaffold(rootId, userId) {
23  if (!_Node) throw new Error("Study core not configured");
24  const { createNode } = await import("../../seed/tree/treeManagement.js");
25
26  const logNode = await createNode({ name: "Log", parentId: rootId, userId });
27  const queueNode = await createNode({ name: "Queue", parentId: rootId, userId });
28  const activeNode = await createNode({ name: "Active", parentId: rootId, userId });
29  const completedNode = await createNode({ name: "Completed", parentId: rootId, userId });
30  const gapsNode = await createNode({ name: "Gaps", parentId: rootId, userId });
31  const profileNode = await createNode({ name: "Profile", parentId: rootId, userId });
32  const historyNode = await createNode({ name: "History", parentId: rootId, userId });
33
34  const nodes = [
35    { node: logNode, role: ROLES.LOG },
36    { node: queueNode, role: ROLES.QUEUE },
37    { node: activeNode, role: ROLES.ACTIVE },
38    { node: completedNode, role: ROLES.COMPLETED },
39    { node: gapsNode, role: ROLES.GAPS },
40    { node: profileNode, role: ROLES.PROFILE },
41    { node: historyNode, role: ROLES.HISTORY },
42  ];
43
44  for (const { node, role } of nodes) {
45    await _metadata.setExtMeta(node, "study", { role });
46  }
47
48  // Mode overrides
49  await setNodeMode(rootId, "respond", "tree:study-coach");
50  await setNodeMode(logNode._id, "respond", "tree:study-log");
51
52  // Mark initialized with base phase
53  const root = await _Node.findById(rootId);
54  if (root) {
55    await _metadata.setExtMeta(root, "study", { initialized: true, setupPhase: "complete" });
56  }
57
58  log.info("Study", "Scaffolded: Log, Queue, Active, Completed, Gaps, Profile, History");
59
60  return {
61    log: String(logNode._id),
62    queue: String(queueNode._id),
63    active: String(activeNode._id),
64    completed: String(completedNode._id),
65    gaps: String(gapsNode._id),
66    profile: String(profileNode._id),
67    history: String(historyNode._id),
68  };
69}
70
71// ── Topic creators (AI calls these via tools) ──
72
73export async function addTopic(activeNodeId, topicName, userId) {
74  const { createNode } = await import("../../seed/tree/treeManagement.js");
75  const topicNode = await createNode({ name: topicName, parentId: activeNodeId, userId });
76  await _metadata.setExtMeta(topicNode, "study", { role: ROLES.TOPIC, lastStudied: new Date().toISOString() });
77
78  // Create Resources child under topic
79  const resourcesNode = await createNode({ name: "Resources", parentId: topicNode._id, userId });
80  await _metadata.setExtMeta(resourcesNode, "study", { role: ROLES.RESOURCES });
81
82  return { id: String(topicNode._id), name: topicName };
83}
84
85export async function addSubtopic(topicId, subtopicName, userId, opts = {}) {
86  const { createNode } = await import("../../seed/tree/treeManagement.js");
87  const subNode = await createNode({ name: subtopicName, parentId: topicId, userId });
88
89  await _metadata.setExtMeta(subNode, "study", {
90    role: ROLES.SUBTOPIC,
91    order: opts.order || 0,
92    prerequisites: opts.prerequisites || [],
93  });
94
95  // Initialize mastery values
96  await _metadata.batchSetExtMeta(subNode._id, "values", {
97    mastery: 0,
98    attempts: 0,
99    lastStudied: null,
100  });
101
102  return { id: String(subNode._id), name: subtopicName };
103}
104
105export async function completeSetup(rootId) {
106  const root = await _Node.findById(rootId);
107  if (!root) return;
108  const existing = _metadata.getExtMeta(root, "study") || {};
109  await _metadata.setExtMeta(root, "study", { ...existing, setupPhase: "complete" });
110  log.info("Study", "Setup complete");
111}
112
113export async function saveProfile(rootId, profile) {
114  const { findStudyNodes } = await import("./core.js");
115  const nodes = await findStudyNodes(rootId);
116  if (!nodes?.profile) return;
117
118  const profileNode = await _Node.findById(nodes.profile.id);
119  if (profileNode) {
120    await _metadata.setExtMeta(profileNode, "study", {
121      role: ROLES.PROFILE,
122      profile,
123    });
124  }
125
126  // Also mark setup as complete if it was base
127  const root = await _Node.findById(rootId);
128  if (root) {
129    const existing = _metadata.getExtMeta(root, "study") || {};
130    if (existing.setupPhase === "base") {
131      await _metadata.setExtMeta(root, "study", { ...existing, setupPhase: "complete" });
132    }
133  }
134}
135
1/**
2 * Study Tools
3 *
4 * MCP tools for building curricula, tracking mastery, managing queue.
5 * Used by plan and session modes.
6 */
7
8import { z } from "zod";
9import {
10  addToQueue, moveToActive, updateMastery, addGap,
11} from "./core.js";
12import {
13  addTopic, addSubtopic, completeSetup, saveProfile,
14} from "./setup.js";
15
16export default function getTools() {
17  return [
18    {
19      name: "study-add-to-queue",
20      description: "Add a topic or URL to the study queue.",
21      schema: {
22        rootId: z.string().describe("Study root node ID."),
23        topic: z.string().describe("Topic name or URL to queue."),
24        priority: z.number().optional().describe("Priority (higher = study sooner). Default 0."),
25        userId: z.string().describe("Injected by server. Ignore."),
26        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
27        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
28      },
29      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
30      handler: async ({ rootId, topic, priority, userId }) => {
31        try {
32          const isUrl = /^https?:\/\//.test(topic);
33          const result = await addToQueue(rootId, topic, userId, { url: isUrl ? topic : null, priority });
34          return { content: [{ type: "text", text: `Queued: "${result.name}"` }] };
35        } catch (err) {
36          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
37        }
38      },
39    },
40    {
41      name: "study-create-topic",
42      description: "Create a topic under the Active branch with a Resources child. Use after picking a topic from the queue.",
43      schema: {
44        activeNodeId: z.string().describe("The Active node ID (parent of topics)."),
45        topicName: z.string().describe("Topic name."),
46        userId: z.string().describe("Injected by server. Ignore."),
47        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
48        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
49      },
50      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
51      handler: async ({ activeNodeId, topicName, userId }) => {
52        try {
53          const result = await addTopic(activeNodeId, topicName, userId);
54          return { content: [{ type: "text", text: `Created topic "${result.name}" (${result.id})` }] };
55        } catch (err) {
56          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
57        }
58      },
59    },
60    {
61      name: "study-add-subtopic",
62      description: "Add a subtopic (concept to learn) under a topic. Sets initial mastery to 0.",
63      schema: {
64        topicId: z.string().describe("Parent topic node ID."),
65        subtopicName: z.string().describe("Subtopic/concept name (e.g. 'useState', 'Closures')."),
66        order: z.number().optional().describe("Learning order (lower = learn first)."),
67        prerequisites: z.array(z.string()).optional().describe("Names of subtopics that should be learned first."),
68        userId: z.string().describe("Injected by server. Ignore."),
69        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
70        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
71      },
72      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
73      handler: async ({ topicId, subtopicName, order, prerequisites, userId }) => {
74        try {
75          const result = await addSubtopic(topicId, subtopicName, userId, { order, prerequisites });
76          return { content: [{ type: "text", text: `Added subtopic "${result.name}" (mastery: 0%)` }] };
77        } catch (err) {
78          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
79        }
80      },
81    },
82    {
83      name: "study-update-mastery",
84      description:
85        "Update mastery score on a subtopic after evaluating the student's understanding. " +
86        "0-30: introduced. 30-60: basics understood. 60-80: solid. 80+: can teach it.",
87      schema: {
88        subtopicId: z.string().describe("Subtopic node ID."),
89        score: z.number().min(0).max(100).optional().describe("Mastery score (0-100)."),
90        masteryScore: z.number().min(0).max(100).optional().describe("Alias for score."),
91        userId: z.string().describe("Injected by server. Ignore."),
92        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
93        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
94      },
95      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
96      handler: async ({ subtopicId, score, masteryScore, userId }) => {
97        try {
98          const result = await updateMastery(subtopicId, score ?? masteryScore ?? 0, userId);
99          const status = result.complete ? "Complete!" : `${result.mastery}%`;
100          return { content: [{ type: "text", text: `Mastery updated: ${status}` }] };
101        } catch (err) {
102          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
103        }
104      },
105    },
106    {
107      name: "study-move-to-active",
108      description: "Move a queue item to Active study. Creates topic node with Resources child.",
109      schema: {
110        rootId: z.string().describe("Study root node ID."),
111        queueItemId: z.string().describe("Queue item node ID to activate."),
112        userId: z.string().describe("Injected by server. Ignore."),
113        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
114        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
115      },
116      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
117      handler: async ({ rootId, queueItemId, userId }) => {
118        try {
119          const result = await moveToActive(rootId, queueItemId, userId);
120          return { content: [{ type: "text", text: `Activated: "${result.name}" (${result.topicId}). Ready to build curriculum.` }] };
121        } catch (err) {
122          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
123        }
124      },
125    },
126    {
127      name: "study-detect-gap",
128      description: "Record a knowledge gap detected during a study session.",
129      schema: {
130        rootId: z.string().describe("Study root node ID."),
131        gapName: z.string().describe("The missing concept (e.g. 'Closures')."),
132        detectedDuring: z.string().describe("What was being studied when the gap was found."),
133        userId: z.string().describe("Injected by server. Ignore."),
134        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
135        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
136      },
137      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
138      handler: async ({ rootId, gapName, detectedDuring, userId }) => {
139        try {
140          const result = await addGap(rootId, gapName, detectedDuring, userId);
141          if (result?.existed) return { content: [{ type: "text", text: `Gap already tracked: "${gapName}"` }] };
142          return { content: [{ type: "text", text: `Gap detected: "${gapName}" (found while studying ${detectedDuring})` }] };
143        } catch (err) {
144          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
145        }
146      },
147    },
148    {
149      name: "study-complete-setup",
150      description: "Mark study setup as complete after initial configuration.",
151      schema: {
152        rootId: z.string().describe("Study root node ID."),
153        userId: z.string().describe("Injected by server. Ignore."),
154        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
155        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
156      },
157      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
158      handler: async ({ rootId }) => {
159        try {
160          await completeSetup(rootId);
161          return { content: [{ type: "text", text: "Study setup complete. Queue topics with needlearn, then study." }] };
162        } catch (err) {
163          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
164        }
165      },
166    },
167    {
168      name: "study-save-profile",
169      description: "Save the user's learning profile (style, daily goal, preferences).",
170      schema: {
171        rootId: z.string().describe("Study root node ID."),
172        profile: z.object({
173          learningStyle: z.enum(["theory-first", "examples-first", "challenge-first"]).optional(),
174          dailyStudyMinutes: z.number().optional(),
175          preferredTime: z.string().optional(),
176        }).describe("Learning profile settings."),
177        userId: z.string().describe("Injected by server. Ignore."),
178        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
179        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
180      },
181      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
182      handler: async ({ rootId, profile }) => {
183        try {
184          await saveProfile(rootId, profile);
185          return { content: [{ type: "text", text: "Profile saved." }] };
186        } catch (err) {
187          return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
188        }
189      },
190    },
191  ];
192}
193

Versions

Version Published Downloads
1.0.2 38d ago 0
1.0.1 46d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star study

Comments

Loading comments...

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