da2b3c2aad125cc78ccd2b0b82714c3cbc677d52c7f8c70ac423ae7c3f2057b5| Command | Method | Description |
|---|---|---|
study | POST | Study session, queue management, progress. |
study switch | POST | Activate a queue item by name or number. |
study stop | POST | Deactivate topic, move back to queue. |
study remove | POST | Delete from queue or active. |
study status | GET | Show active topics and mastery. |
study gaps | GET | Show detected knowledge gaps. |
needlearn [topic...] | POST | Add a topic or URL to your study queue. |
enrichContextafterBoot1/**
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}
5591/**
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}
791/**
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;
671/**
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}
2411export 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};
1061/**
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};
571/**
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};
1101/**
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};
731/**
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};
1011/**
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}
3491import 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;
2421/**
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}
1351/**
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
treeos ext star study
Post comments from the CLI: treeos ext comment study "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...