1/**
2 * Recovery Core
3 *
4 * The tree grows toward health. Scaffold, parse check-ins, track substances,
5 * detect patterns, manage taper schedules, archive history. The person is
6 * always the agent. The tree is the mirror.
7 */
8
9import log from "../../seed/log.js";
10import { setNodeMode } from "../../seed/modes/registry.js";
11import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
12
13// ── Dependencies ──
14
15let _Node = null;
16let _Note = null;
17let _runChat = null;
18let _metadata = null;
19let _hooks = null;
20
21export function configure({ Node, Note, runChat, metadata, hooks }) {
22 _Node = Node;
23 _Note = Note;
24 _runChat = runChat;
25 _metadata = metadata;
26 _hooks = hooks;
27}
28
29// ── Roles ──
30
31const ROLES = {
32 LOG: "log",
33 SUBSTANCE: "substance",
34 SUBSTANCE_ITEM: "substance-item",
35 DOSES: "doses",
36 SCHEDULE: "schedule",
37 FEELINGS: "feelings",
38 CRAVINGS: "cravings",
39 MOOD: "mood",
40 ENERGY: "energy",
41 PATTERNS: "patterns",
42 JOURNAL: "journal",
43 MILESTONES: "milestones",
44 SUPPORT: "support",
45 PROFILE: "profile",
46 HISTORY: "history",
47};
48
49// ── Scaffold ──
50
51export async function scaffold(rootId, userId) {
52 if (!_Node) throw new Error("Recovery core not configured");
53 const { createNode } = await import("../../seed/tree/treeManagement.js");
54
55 const logNode = await createNode({ name: "Log", parentId: rootId, userId });
56 const substanceNode = await createNode({ name: "Substance", parentId: rootId, userId });
57 const feelingsNode = await createNode({ name: "Feelings", parentId: rootId, userId });
58 const cravingsNode = await createNode({ name: "Cravings", parentId: feelingsNode._id, userId });
59 const moodNode = await createNode({ name: "Mood", parentId: feelingsNode._id, userId });
60 const energyNode = await createNode({ name: "Energy", parentId: feelingsNode._id, userId });
61 const patternsNode = await createNode({ name: "Patterns", parentId: rootId, userId });
62 const journalNode = await createNode({ name: "Journal", parentId: rootId, userId });
63 const milestonesNode = await createNode({ name: "Milestones", parentId: rootId, userId });
64 const supportNode = await createNode({ name: "Support", parentId: rootId, userId });
65 const profileNode = await createNode({ name: "Profile", parentId: rootId, userId });
66 const historyNode = await createNode({ name: "History", parentId: rootId, userId });
67
68 // Tag roles
69 const tags = [
70 [logNode, ROLES.LOG],
71 [substanceNode, ROLES.SUBSTANCE],
72 [feelingsNode, ROLES.FEELINGS],
73 [cravingsNode, ROLES.CRAVINGS],
74 [moodNode, ROLES.MOOD],
75 [energyNode, ROLES.ENERGY],
76 [patternsNode, ROLES.PATTERNS],
77 [journalNode, ROLES.JOURNAL],
78 [milestonesNode, ROLES.MILESTONES],
79 [supportNode, ROLES.SUPPORT],
80 [profileNode, ROLES.PROFILE],
81 [historyNode, ROLES.HISTORY],
82 ];
83
84 for (const [node, role] of tags) {
85 await _metadata.setExtMeta(node, "recovery", { role });
86 }
87
88 // Mode overrides
89 await setNodeMode(rootId, "respond", "tree:recovery-plan");
90 await setNodeMode(logNode._id, "respond", "tree:recovery-log");
91 await setNodeMode(journalNode._id, "respond", "tree:recovery-journal");
92 await setNodeMode(patternsNode._id, "respond", "tree:recovery-review");
93
94 // Mark initialized (base phase: scaffold done, substances not yet configured)
95 const root = await _Node.findById(rootId);
96 if (root) await _metadata.setExtMeta(root, "recovery", { initialized: true, setupPhase: "complete" });
97
98 const ids = {};
99 for (const [node, role] of tags) ids[role] = String(node._id);
100
101 log.info("Recovery", `Scaffolded tree under ${rootId}`);
102 return ids;
103}
104
105/**
106 * Add a substance to track. Creates a child under /Substance with Schedule and Doses children.
107 */
108export async function addSubstance(rootId, substanceName, userId, config = {}) {
109 const nodes = await findRecoveryNodes(rootId);
110 if (!nodes?.substance) throw new Error("Recovery tree not scaffolded");
111
112 const { createNode } = await import("../../seed/tree/treeManagement.js");
113
114 const substNode = await createNode({ name: substanceName, parentId: nodes.substance.id, userId });
115 await _metadata.setExtMeta(substNode, "recovery", { role: ROLES.SUBSTANCE_ITEM, substanceName: substanceName.toLowerCase() });
116
117 const scheduleNode = await createNode({ name: "Schedule", parentId: substNode._id, userId });
118 await _metadata.setExtMeta(scheduleNode, "recovery", { role: ROLES.SCHEDULE, substance: substanceName.toLowerCase() });
119
120 const dosesNode = await createNode({ name: "Doses", parentId: substNode._id, userId });
121 await _metadata.setExtMeta(dosesNode, "recovery", { role: ROLES.DOSES, substance: substanceName.toLowerCase() });
122
123 // Initialize dose values
124 await _metadata.batchSetExtMeta(dosesNode._id, "values", {
125 today: 0,
126 target: config.startingTarget || 0,
127 finalTarget: config.finalTarget || 0,
128 yesterday: 0,
129 streak: 0,
130 longestStreak: 0,
131 totalSlips: 0,
132 lastSlip: null,
133 });
134
135 // First substance added completes setup
136 const root = await _Node.findById(rootId);
137 if (root) {
138 const existing = _metadata.getExtMeta(root, "recovery") || {};
139 if (existing.setupPhase === "base") {
140 await _metadata.setExtMeta(root, "recovery", { ...existing, setupPhase: "complete" });
141 }
142 }
143
144 return { substance: String(substNode._id), schedule: String(scheduleNode._id), doses: String(dosesNode._id) };
145}
146
147// ── Find nodes ──
148
149export async function findRecoveryNodes(rootId) {
150 if (!_Node) return null;
151
152 const result = {};
153 const children = await _Node.find({ parent: rootId }).select("_id name metadata").lean();
154
155 for (const child of children) {
156 const meta = child.metadata instanceof Map ? child.metadata.get("recovery") : child.metadata?.recovery;
157 if (meta?.role) result[meta.role] = { id: String(child._id), name: child.name };
158 }
159
160 // Find Feelings children
161 if (result.feelings) {
162 const feelChildren = await _Node.find({ parent: result.feelings.id }).select("_id name metadata").lean();
163 for (const fc of feelChildren) {
164 const meta = fc.metadata instanceof Map ? fc.metadata.get("recovery") : fc.metadata?.recovery;
165 if (meta?.role) result[meta.role] = { id: String(fc._id), name: fc.name };
166 }
167 }
168
169 // Find Substance children (each substance has Doses and Schedule)
170 if (result.substance) {
171 result.substances = {};
172 const substChildren = await _Node.find({ parent: result.substance.id }).select("_id name metadata").lean();
173 for (const sc of substChildren) {
174 const meta = sc.metadata instanceof Map ? sc.metadata.get("recovery") : sc.metadata?.recovery;
175 if (meta?.substanceName) {
176 const name = meta.substanceName;
177 result.substances[name] = { id: String(sc._id), name: sc.name };
178 // Find Doses and Schedule under this substance
179 const subChildren = await _Node.find({ parent: sc._id }).select("_id name metadata").lean();
180 for (const sub of subChildren) {
181 const subMeta = sub.metadata instanceof Map ? sub.metadata.get("recovery") : sub.metadata?.recovery;
182 if (subMeta?.role === ROLES.DOSES) result.substances[name].doses = String(sub._id);
183 if (subMeta?.role === ROLES.SCHEDULE) result.substances[name].schedule = String(sub._id);
184 }
185 }
186 }
187 }
188
189 return result;
190}
191
192export async function isInitialized(rootId) {
193 if (!_Node) return false;
194 const root = await _Node.findById(rootId).select("metadata").lean();
195 if (!root) return false;
196 const meta = root.metadata instanceof Map ? root.metadata.get("recovery") : root.metadata?.recovery;
197 return !!meta?.initialized;
198}
199
200export async function getSetupPhase(rootId) {
201 if (!_Node) return null;
202 const root = await _Node.findById(rootId).select("metadata").lean();
203 if (!root) return null;
204 const meta = root.metadata instanceof Map ? root.metadata.get("recovery") : root.metadata?.recovery;
205 return meta?.setupPhase || (meta?.initialized ? "complete" : null);
206}
207
208export async function completeSetup(rootId) {
209 const root = await _Node.findById(rootId);
210 if (!root) return;
211 const existing = _metadata.getExtMeta(root, "recovery") || {};
212 await _metadata.setExtMeta(root, "recovery", { ...existing, setupPhase: "complete" });
213}
214
215// ── Parse check-in ──
216
217export async function parseCheckIn(message, userId, username, rootId) {
218 if (!_runChat) throw new Error("LLM not configured");
219
220 const { answer } = await _runChat({
221 userId,
222 username,
223 message,
224 mode: "tree:recovery-log",
225 rootId,
226 slot: "recovery",
227 });
228
229 if (!answer) return null;
230 return parseJsonSafe(answer);
231}
232
233// ── Record data ──
234
235export async function recordDoses(nodes, substance, amount) {
236 const sub = nodes.substances?.[substance.toLowerCase()];
237 if (!sub?.doses) return;
238
239 const dosesNode = await _Node.findById(sub.doses).select("metadata").lean();
240 const values = dosesNode?.metadata instanceof Map ? dosesNode.metadata.get("values") : dosesNode?.metadata?.values;
241 const target = values?.target || 0;
242 const currentStreak = values?.streak || 0;
243
244 await _metadata.incExtMeta(sub.doses, "values", "today", amount);
245
246 // Check if this is a slip (over target)
247 const newTotal = (values?.today || 0) + amount;
248 if (target > 0 && newTotal > target) {
249 await _metadata.batchSetExtMeta(sub.doses, "values", {
250 streak: 0,
251 totalSlips: (values?.totalSlips || 0) + 1,
252 lastSlip: new Date().toISOString(),
253 });
254 }
255}
256
257export async function recordCraving(nodes, intensity, resisted, trigger) {
258 if (!nodes.cravings) return;
259 const node = await _Node.findById(nodes.cravings.id).select("metadata").lean();
260 const values = node?.metadata instanceof Map ? node.metadata.get("values") : node?.metadata?.values;
261
262 const updates = {
263 intensity_today: Math.max(values?.intensity_today || 0, intensity),
264 triggers_today: (values?.triggers_today || 0) + 1,
265 };
266 if (resisted) updates.resisted_today = (values?.resisted_today || 0) + 1;
267
268 await _metadata.batchSetExtMeta(nodes.cravings.id, "values", updates);
269}
270
271export async function recordMood(nodes, score) {
272 if (!nodes.mood) return;
273 await _metadata.batchSetExtMeta(nodes.mood.id, "values", { today_avg: score });
274}
275
276export async function recordEnergy(nodes, level) {
277 if (!nodes.energy) return;
278 await _metadata.batchSetExtMeta(nodes.energy.id, "values", { today: level });
279}
280
281// ── Milestones ──
282
283const MILESTONE_DAYS = [1, 3, 7, 14, 21, 30, 60, 90, 100, 180, 365];
284
285export async function checkMilestones(nodes, substance, streak) {
286 if (!nodes.milestones || !_Note) return null;
287
288 for (const day of MILESTONE_DAYS) {
289 if (streak !== day) continue;
290
291 const messages = {
292 1: "First day on target.",
293 3: "Three days. The hardest part is starting.",
294 7: "One week.",
295 14: "Two weeks. Building momentum.",
296 21: "Three weeks. This is becoming a pattern. A good one.",
297 30: "One month.",
298 60: "Two months.",
299 90: "Ninety days.",
300 100: "Triple digits.",
301 180: "Six months.",
302 365: "One year.",
303 };
304
305 const text = `Day ${day}: ${messages[day] || `${day} days.`}`;
306
307 try {
308 const { createNote } = await import("../../seed/tree/notes.js");
309 await createNote({
310 nodeId: nodes.milestones.id,
311 content: text,
312 contentType: "text",
313 userId: "SYSTEM",
314 });
315 } catch {}
316
317 if (_hooks) {
318 _hooks.run("recovery:milestone", { substance, day, streak, message: text }).catch(() => {});
319 }
320
321 return text;
322 }
323 return null;
324}
325
326// ── Daily reset ──
327
328const lastReset = new Map();
329
330export async function checkDailyReset(rootId) {
331 if (!_Node) return;
332
333 const today = new Date().toISOString().slice(0, 10);
334 if (lastReset.get(rootId) === today) return;
335
336 const nodes = await findRecoveryNodes(rootId);
337 if (!nodes) return;
338
339 // Build daily summary
340 const summary = { date: today, substances: {}, feelings: {} };
341
342 // Read substance data
343 for (const [name, sub] of Object.entries(nodes.substances || {})) {
344 if (!sub.doses) continue;
345 const node = await _Node.findById(sub.doses).select("metadata").lean();
346 const values = node?.metadata instanceof Map ? node.metadata.get("values") : node?.metadata?.values;
347 if (!values) continue;
348
349 const doseToday = values.today || 0;
350 const target = values.target || 0;
351 const onTarget = target === 0 ? doseToday === 0 : doseToday <= target;
352 const streak = values.streak || 0;
353
354 summary.substances[name] = { doses: doseToday, target, onTarget, streak };
355
356 // Update streak and yesterday
357 const newStreak = onTarget ? streak + 1 : 0;
358 const longestStreak = Math.max(values.longestStreak || 0, newStreak);
359 await _metadata.batchSetExtMeta(sub.doses, "values", {
360 yesterday: doseToday,
361 today: 0,
362 streak: newStreak,
363 longestStreak,
364 });
365
366 // Check milestones
367 if (newStreak > 0) {
368 await checkMilestones(nodes, name, newStreak);
369 }
370 }
371
372 // Read feelings
373 if (nodes.cravings) {
374 const node = await _Node.findById(nodes.cravings.id).select("metadata").lean();
375 const values = node?.metadata instanceof Map ? node.metadata.get("values") : node?.metadata?.values;
376 if (values) {
377 summary.feelings.craving = {
378 peak: values.intensity_today || 0,
379 triggers: values.triggers_today || 0,
380 resisted: values.resisted_today || 0,
381 };
382 // Update weekly avg and reset
383 const weeklyAvg = values.intensity_weeklyAvg || values.intensity_today || 0;
384 const newAvg = Math.round((weeklyAvg * 6 + (values.intensity_today || 0)) / 7 * 10) / 10;
385 const totalResisted = (values.resisted_total || 0) + (values.resisted_today || 0);
386 const totalTriggers = (values.triggers_total || 0) + (values.triggers_today || 0);
387 await _metadata.batchSetExtMeta(nodes.cravings.id, "values", {
388 intensity_today: 0,
389 triggers_today: 0,
390 resisted_today: 0,
391 intensity_weeklyAvg: newAvg,
392 resisted_total: totalResisted,
393 triggers_total: totalTriggers,
394 resisted_rate: totalTriggers > 0 ? Math.round((totalResisted / totalTriggers) * 100) / 100 : 0,
395 });
396 }
397 }
398
399 if (nodes.mood) {
400 const node = await _Node.findById(nodes.mood.id).select("metadata").lean();
401 const values = node?.metadata instanceof Map ? node.metadata.get("values") : node?.metadata?.values;
402 if (values?.today_avg != null) {
403 summary.feelings.mood = values.today_avg;
404 const weeklyAvg = values.weeklyAvg || values.today_avg;
405 const newAvg = Math.round((weeklyAvg * 6 + values.today_avg) / 7 * 10) / 10;
406 await _metadata.batchSetExtMeta(nodes.mood.id, "values", {
407 yesterday_avg: values.today_avg,
408 today_avg: 0,
409 weeklyAvg: newAvg,
410 });
411 }
412 }
413
414 if (nodes.energy) {
415 const node = await _Node.findById(nodes.energy.id).select("metadata").lean();
416 const values = node?.metadata instanceof Map ? node.metadata.get("values") : node?.metadata?.values;
417 if (values?.today != null) {
418 summary.feelings.energy = values.today;
419 const weeklyAvg = values.weeklyAvg || values.today;
420 const newAvg = Math.round((weeklyAvg * 6 + values.today) / 7 * 10) / 10;
421 await _metadata.batchSetExtMeta(nodes.energy.id, "values", {
422 yesterday: values.today,
423 today: 0,
424 weeklyAvg: newAvg,
425 });
426 }
427 }
428
429 // Archive to History
430 if (nodes.history) {
431 try {
432 const { createNote } = await import("../../seed/tree/notes.js");
433 await createNote({
434 nodeId: nodes.history.id,
435 content: JSON.stringify(summary),
436 contentType: "text",
437 userId: "SYSTEM",
438 });
439 } catch (err) {
440 log.debug("Recovery", `History write failed: ${err.message}`);
441 }
442 }
443
444 lastReset.set(rootId, today);
445 log.verbose("Recovery", `Daily reset for ${rootId.slice(0, 8)}...`);
446}
447
448// ── Read state ──
449
450export async function getStatus(rootId) {
451 if (!_Node) return null;
452 const nodes = await findRecoveryNodes(rootId);
453 if (!nodes) return null;
454
455 const status = { substances: {}, feelings: {}, streaks: {} };
456
457 for (const [name, sub] of Object.entries(nodes.substances || {})) {
458 if (!sub.doses) continue;
459 const node = await _Node.findById(sub.doses).select("metadata").lean();
460 const values = node?.metadata instanceof Map ? node.metadata.get("values") : node?.metadata?.values;
461 if (values) {
462 status.substances[name] = {
463 today: values.today || 0,
464 target: values.target || 0,
465 onTarget: (values.target || 0) === 0 ? (values.today || 0) === 0 : (values.today || 0) <= (values.target || 0),
466 };
467 status.streaks[name] = {
468 current: values.streak || 0,
469 longest: values.longestStreak || 0,
470 totalSlips: values.totalSlips || 0,
471 lastSlip: values.lastSlip || null,
472 };
473 }
474 }
475
476 if (nodes.cravings) {
477 const node = await _Node.findById(nodes.cravings.id).select("metadata").lean();
478 const values = node?.metadata instanceof Map ? node.metadata.get("values") : node?.metadata?.values;
479 if (values) {
480 status.feelings.cravings = {
481 intensity: values.intensity_today || 0,
482 weeklyAvg: values.intensity_weeklyAvg || 0,
483 resistRate: values.resisted_rate || 0,
484 };
485 }
486 }
487
488 if (nodes.mood) {
489 const node = await _Node.findById(nodes.mood.id).select("metadata").lean();
490 const values = node?.metadata instanceof Map ? node.metadata.get("values") : node?.metadata?.values;
491 if (values) {
492 status.feelings.mood = { today: values.today_avg || 0, weeklyAvg: values.weeklyAvg || 0 };
493 }
494 }
495
496 if (nodes.energy) {
497 const node = await _Node.findById(nodes.energy.id).select("metadata").lean();
498 const values = node?.metadata instanceof Map ? node.metadata.get("values") : node?.metadata?.values;
499 if (values) {
500 status.feelings.energy = { today: values.today || 0, weeklyAvg: values.weeklyAvg || 0 };
501 }
502 }
503
504 return status;
505}
506
507export async function getPatterns(rootId) {
508 const nodes = await findRecoveryNodes(rootId);
509 if (!nodes?.patterns || !_Note) return [];
510
511 const notes = await _Note.find({ nodeId: nodes.patterns.id })
512 .sort({ createdAt: -1 })
513 .limit(20)
514 .select("content createdAt")
515 .lean();
516
517 return notes
518 .map(n => { try { return JSON.parse(n.content); } catch { return null; } })
519 .filter(Boolean);
520}
521
522export async function getMilestones(rootId) {
523 const nodes = await findRecoveryNodes(rootId);
524 if (!nodes?.milestones || !_Note) return [];
525
526 const notes = await _Note.find({ nodeId: nodes.milestones.id })
527 .sort({ createdAt: -1 })
528 .limit(50)
529 .select("content createdAt")
530 .lean();
531
532 return notes.map(n => ({ text: n.content, date: n.createdAt }));
533}
534
535export async function getHistory(rootId, days = 7) {
536 const nodes = await findRecoveryNodes(rootId);
537 if (!nodes?.history || !_Note) return [];
538
539 const notes = await _Note.find({ nodeId: nodes.history.id })
540 .sort({ createdAt: -1 })
541 .limit(days)
542 .select("content")
543 .lean();
544
545 return notes
546 .map(n => { try { return JSON.parse(n.content); } catch { return null; } })
547 .filter(Boolean);
548}
549
1/**
2 * No pre-processing needed. The AI in the mode handles everything.
3 * Recovery modes handle check-ins, journaling, and planning via tools.
4 */
5
6export async function handleMessage() {
7 return null;
8}
9
1import express from "express";
2import { sendError, ERR } from "../../seed/protocol.js";
3import urlAuth from "../html-rendering/urlAuth.js";
4import { htmlOnly } from "../html-rendering/htmlHelpers.js";
5import Node from "../../seed/models/node.js";
6import { isInitialized, getStatus, getMilestones } from "./core.js";
7import { renderRecoveryDashboard } from "./pages/dashboard.js";
8
9const router = express.Router();
10
11router.get("/root/:rootId/recovery", urlAuth, htmlOnly, async (req, res) => {
12 try {
13 const { rootId } = req.params;
14 const root = await Node.findById(rootId).select("name metadata").lean();
15 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Recovery tree not found");
16
17 if (!(await isInitialized(rootId))) {
18 return sendError(res, 404, ERR.TREE_NOT_FOUND, "Recovery tree not initialized");
19 }
20
21 const [status, milestones] = await Promise.all([
22 getStatus(rootId),
23 getMilestones(rootId),
24 ]);
25
26 res.send(renderRecoveryDashboard({
27 rootId,
28 rootName: root.name,
29 status,
30 milestones,
31 token: req.query.token || null,
32 }));
33 } catch (err) {
34 sendError(res, 500, ERR.INTERNAL, "Dashboard failed");
35 }
36});
37
38export default router;
39
1/**
2 * Recovery
3 *
4 * The tree grows toward health. Track substances, feelings, cravings,
5 * and patterns. Taper schedules that bend around you. Pattern detection
6 * that finds what you can't see. A mirror, not a judge.
7 */
8
9import log from "../../seed/log.js";
10import logMode from "./modes/log.js";
11import reflectMode from "./modes/reflect.js";
12import planMode from "./modes/plan.js";
13import journalMode from "./modes/journal.js";
14import getTools from "./tools.js";
15import {
16 configure,
17 scaffold,
18 isInitialized,
19 findRecoveryNodes,
20 getStatus,
21 getPatterns,
22 getMilestones,
23 getHistory,
24 checkDailyReset,
25 addSubstance,
26 recordDoses,
27 recordCraving,
28 recordMood,
29 recordEnergy,
30} from "./core.js";
31import { handleMessage } from "./handler.js";
32
33export async function init(core) {
34 core.llm.registerRootLlmSlot?.("recovery");
35
36 const runChat = core.llm?.runChat || null;
37 configure({
38 Node: core.models.Node,
39 Note: core.models.Note,
40 runChat: runChat
41 ? async (opts) => {
42 if (opts.userId && opts.userId !== "SYSTEM") {
43 const hasLlm = await core.llm.userHasLlm(opts.userId);
44 if (!hasLlm) return { answer: null };
45 }
46 return core.llm.runChat({
47 ...opts,
48 llmPriority: core.llm.LLM_PRIORITY.INTERACTIVE,
49 });
50 }
51 : null,
52 metadata: core.metadata,
53 hooks: core.hooks,
54 });
55
56 // Register modes
57 core.modes.registerMode("tree:recovery-log", logMode, "recovery");
58 core.modes.registerMode("tree:recovery-review", reflectMode, "recovery");
59 core.modes.registerMode("tree:recovery-plan", planMode, "recovery");
60 core.modes.registerMode("tree:recovery-journal", journalMode, "recovery");
61
62 if (core.llm?.registerModeAssignment) {
63 core.llm.registerModeAssignment("tree:recovery-log", "recovery");
64 core.llm.registerModeAssignment("tree:recovery-review", "recovery");
65 core.llm.registerModeAssignment("tree:recovery-plan", "recovery");
66 core.llm.registerModeAssignment("tree:recovery-journal", "recovery");
67 }
68
69 // ── Boot self-heal ──
70 core.hooks.register("afterBoot", async () => {
71 try {
72 const roots = await core.models.Node.find({
73 "metadata.recovery.initialized": true,
74 }).select("_id metadata").lean();
75 for (const root of roots) {
76 const modes = root.metadata instanceof Map
77 ? root.metadata.get("modes")
78 : root.metadata?.modes;
79 if (!modes?.respond) {
80 const { setNodeMode } = await import("../../seed/modes/registry.js");
81 await setNodeMode(root._id, "respond", "tree:recovery-log");
82 }
83 }
84 } catch {}
85 }, "recovery");
86
87 // ── breath:exhale: daily reset ──
88 let fallbackTimer = null;
89 const trackedRoots = new Set();
90
91 core.hooks.register("breath:exhale", async ({ rootId }) => {
92 if (!rootId) return;
93 try {
94 if (await isInitialized(rootId)) {
95 trackedRoots.add(rootId);
96 await checkDailyReset(rootId);
97 }
98 } catch {}
99 }, "recovery");
100
101 core.hooks.register("afterNavigate", async ({ rootId }) => {
102 if (!rootId) return;
103 try {
104 if (await isInitialized(rootId)) trackedRoots.add(rootId);
105 } catch {}
106 }, "recovery");
107
108 // Fallback timer
109 setTimeout(() => {
110 fallbackTimer = setInterval(async () => {
111 for (const rootId of trackedRoots) {
112 try { await checkDailyReset(rootId); } catch {}
113 }
114 }, 300000);
115 if (fallbackTimer.unref) fallbackTimer.unref();
116 }, 60000);
117
118 // ── enrichContext ──
119 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
120 if (!node?._id) return;
121 const recoveryMeta = meta?.recovery;
122 if (!recoveryMeta?.role) return;
123
124 // Find the root
125 let rootId = null;
126 if (recoveryMeta.initialized) {
127 rootId = String(node._id);
128 } else {
129 let cursor = node;
130 while (cursor && !cursor.systemRole) {
131 const curMeta = cursor.metadata instanceof Map
132 ? cursor.metadata.get("recovery")
133 : cursor.metadata?.recovery;
134 if (curMeta?.initialized) {
135 rootId = String(cursor._id);
136 break;
137 }
138 if (!cursor.parent) break;
139 cursor = await core.models.Node.findById(cursor.parent).select("_id metadata parent systemRole").lean();
140 }
141 }
142 if (!rootId) return;
143
144 const status = await getStatus(rootId);
145 if (!status) return;
146
147 context.recovery = {
148 today: status.substances,
149 streaks: status.streaks,
150 feelings: status.feelings,
151 };
152
153 // Add patterns summary
154 const patterns = await getPatterns(rootId);
155 if (patterns.length > 0) {
156 context.recovery.patterns = patterns.slice(0, 5).map(p => ({
157 pattern: p.pattern || p.description,
158 confidence: p.confidence,
159 }));
160 }
161
162 // Cross-domain awareness: find sibling extensions and read their state
163 try {
164 const { getExtension } = await import("../loader.js");
165 const life = getExtension("life");
166 if (life?.exports?.getDomainNodes) {
167 // Walk up to find the tree root for domain lookup
168 const treeRoot = node.rootOwner || rootId;
169 const domains = await life.exports.getDomainNodes(treeRoot);
170
171 // Food: what did the user eat today?
172 if (domains.food?.id) {
173 const food = getExtension("food");
174 if (food?.exports?.getDailyPicture) {
175 const picture = await food.exports.getDailyPicture(domains.food.id);
176 if (picture?.calories) {
177 context.recovery.foodToday = {
178 calories: picture.calories.today,
179 calorieGoal: picture.calories.goal,
180 };
181 }
182 }
183 }
184
185 // Fitness: recent workout activity
186 if (domains.fitness?.id) {
187 const fitness = getExtension("fitness");
188 if (fitness?.exports?.getWeeklyStats) {
189 const stats = await fitness.exports.getWeeklyStats(domains.fitness.id);
190 if (stats?.workoutsThisWeek > 0) {
191 context.recovery.fitnessThisWeek = {
192 workouts: stats.workoutsThisWeek,
193 lastWorkout: stats.lastWorkoutDate,
194 };
195 }
196 }
197 }
198 }
199 } catch {}
200 }, "recovery");
201
202 // ── Live dashboard updates ──
203 core.hooks.register("afterNote", async ({ nodeId }) => {
204 if (!nodeId) return;
205 try {
206 const node = await core.models.Node.findById(nodeId).select("rootOwner metadata").lean();
207 if (!node?.rootOwner) return;
208 const fm = node.metadata instanceof Map ? node.metadata.get("recovery") : node.metadata?.recovery;
209 if (!fm?.role) return;
210 core.websocket?.emitToUser?.(String(node.rootOwner), "dashboardUpdate", { rootId: String(node.rootOwner) });
211 } catch {}
212 }, "recovery");
213
214 core.hooks.register("afterMetadataWrite", async ({ nodeId, extName }) => {
215 if (extName !== "values" && extName !== "recovery") return;
216 try {
217 const node = await core.models.Node.findById(nodeId).select("rootOwner").lean();
218 if (!node?.rootOwner) return;
219 core.websocket?.emitToUser?.(String(node.rootOwner), "dashboardUpdate", { rootId: String(node.rootOwner) });
220 } catch {}
221 }, "recovery");
222
223 // ── Register apps-grid slot ──
224 try {
225 const { getExtension } = await import("../loader.js");
226 const base = getExtension("treeos-base");
227 base?.exports?.registerSlot?.("apps-grid", "recovery", ({ userId, rootMap, tokenParam, tokenField, esc: e }) => {
228 const entries = rootMap.get("Recovery") || [];
229 const existing = entries.map(entry =>
230 entry.ready
231 ? `<a class="app-active" href="/api/v1/root/${entry.id}/recovery?html${tokenParam}" style="margin-right:8px;margin-bottom:6px;">${e(entry.name)}</a>`
232 : `<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}/recovery?html${tokenParam}">${e(entry.name)} (setup)</a>`
233 ).join("");
234 return `<div class="app-card">
235 <div class="app-header"><span class="app-emoji">🌿</span><span class="app-name">Recovery</span></div>
236 <div class="app-desc">Check in, journal, track patterns. Substance taper plans, mood, cravings, milestones.</div>
237 ${entries.length > 0
238 ? `<div style="display:flex;flex-wrap:wrap;">${existing}</div>`
239 : `<form class="app-form" method="POST" action="/api/v1/user/${userId}/apps/create">
240 ${tokenField}<input type="hidden" name="app" value="recovery" />
241 <input class="app-input" name="message" placeholder="What are you working on? (e.g. alcohol, nicotine, general wellness)" required />
242 <button class="app-start" type="submit">Start Recovery</button>
243 </form>`}
244 </div>`;
245 }, { priority: 30 });
246 } catch {}
247
248 // ── Router ──
249 const { default: router } = await import("./routes.js");
250
251 log.info("Recovery", "Loaded. The tree grows toward health.");
252
253 const tools = getTools();
254
255 return {
256 router,
257 tools,
258 modeTools: [
259 { modeKey: "tree:recovery-log", toolNames: ["recovery-add-substance"] },
260 { modeKey: "tree:recovery-plan", toolNames: ["recovery-add-substance", "recovery-complete-setup"] },
261 ],
262 exports: {
263 scaffold,
264 isInitialized,
265 findRecoveryNodes,
266 getStatus,
267 getPatterns,
268 getMilestones,
269 addSubstance,
270 handleMessage,
271 },
272 jobs: [
273 {
274 name: "recovery-daily-reset",
275 start: () => {},
276 stop: () => { if (fallbackTimer) clearInterval(fallbackTimer); },
277 },
278 ],
279 };
280}
281
1export default {
2 name: "recovery",
3 version: "1.0.2",
4 builtFor: "TreeOS",
5 description:
6 "The tree that grows toward health. Track substances, feelings, cravings, " +
7 "and patterns. Taper schedules that bend around you. Pattern detection that " +
8 "finds what you can't see. A mirror, not a judge. Three modes: recovery-log " +
9 "for daily check-ins, recovery-reflect for pattern analysis, recovery-plan " +
10 "for taper scheduling. Milestone detection. Journal node for unstructured " +
11 "writing the AI doesn't analyze. Safety boundaries for dangerous withdrawals " +
12 "and crisis situations. Type 'be' at the Recovery tree to check in: the AI asks " +
13 "how you're doing today. The person is always the agent.",
14
15 territory: "rest, healing, soreness, sleep, substances, sobriety",
16 classifierHints: [
17 /\b(craving|crave|urge|tempt|slip|relapse|sober|clean days|quit)\b/i,
18 /\b(taper|taper plan|cut down|cut back|wean|withdraw)\b/i,
19 /\b(drink|smoke|vape|dose|substance|sobriety)\b/i,
20 /\b(days clean|days sober|sober streak|milestone)\b/i,
21 ],
22
23 needs: {
24 models: ["Node", "Note"],
25 services: ["hooks", "llm", "metadata"],
26 },
27
28 optional: {
29 extensions: [
30 "values",
31 "channels",
32 "fitness",
33 "food",
34 "scheduler",
35 "breath",
36 "notifications",
37 "html-rendering", // dashboard page
38 "treeos-base", // slot registration
39 ],
40 },
41
42 provides: {
43 models: {},
44 routes: "./routes.js",
45 tools: true,
46 jobs: true,
47 modes: true,
48 guidedMode: "tree:recovery-review",
49
50 hooks: {
51 fires: ["recovery:milestone", "recovery:patternDetected"],
52 listens: ["enrichContext", "breath:exhale"],
53 },
54
55 cli: [
56 {
57 command: "recovery [message...]",
58 scope: ["tree"],
59 description: "Check in or log how you're doing.",
60 method: "POST",
61 endpoint: "/root/:rootId/recovery",
62 body: ["message"],
63 },
64 {
65 command: "recovery-check",
66 scope: ["tree"],
67 description: "Today's status.",
68 method: "GET",
69 endpoint: "/root/:rootId/recovery/check",
70 },
71 {
72 command: "recovery-patterns",
73 scope: ["tree"],
74 description: "Detected patterns.",
75 method: "GET",
76 endpoint: "/root/:rootId/recovery/patterns",
77 },
78 {
79 command: "recovery-milestones",
80 scope: ["tree"],
81 description: "Your milestones.",
82 method: "GET",
83 endpoint: "/root/:rootId/recovery/milestones",
84 },
85 {
86 command: "recovery-taper",
87 scope: ["tree"],
88 description: "Show taper plan.",
89 method: "GET",
90 endpoint: "/root/:rootId/recovery/taper",
91 },
92 ],
93 },
94};
95
1// recovery/modes/journal.js
2// The safe space. Unstructured writing. No parsing. No analysis.
3// No pattern extraction. Just holds.
4
5export default {
6 name: "tree:recovery-journal",
7 emoji: "📓",
8 label: "Journal",
9 bigMode: "tree",
10 hidden: true,
11
12 maxMessagesBeforeLoop: 2,
13 preserveContextOnLoop: false,
14
15 toolNames: ["create-node-note", "get-node-notes"],
16
17 buildSystemPrompt({ username }) {
18 return `You are at ${username}'s journal. This is the safe space.
19
20When ${username} writes something here, save it as a note. That's it.
21
22Do not analyze. Do not extract patterns. Do not suggest. Do not summarize.
23Do not connect it to substance use or cravings. Do not say "I notice you..."
24
25Respond with one of:
26- "Written." (default)
27- A single short reflection if it feels right. One sentence max. No advice.
28 Example: "You're choosing the harder path knowing it's harder."
29 Example: "That's a lot to carry."
30
31If they ask to read old entries: show them. No commentary.
32If they ask "what have I been writing about": that's fine, summarize themes gently.
33
34This node is for the person, not for the system.`.trim();
35 },
36};
37
1// recovery/modes/log.js
2// The daily check-in. Low friction. Parse natural language into structured data.
3// Never lecture. Never judge. Reflect patterns. Acknowledge what's hard.
4
5const SAFETY = `
6HARD RULES:
7- Never provide medical advice about withdrawal symptoms
8- Never recommend specific medications or dosages
9- Never diagnose conditions
10- Never minimize the severity of substance dependency
11- If someone mentions alcohol or benzodiazepine withdrawal symptoms
12 (seizures, tremors, hallucinations, severe anxiety), immediately
13 recommend they contact a medical professional. Do not attempt
14 to manage these through tapering advice. These withdrawals
15 can be life-threatening.
16- If someone expresses hopelessness or mentions self-harm,
17 respond with care and provide crisis resources:
18 988 Suicide and Crisis Lifeline (call or text 988)
19- Never use shame, guilt, or disappointment language
20- A slip is data, not failure
21- The person is always the agent. Never say "you should" or "you must"
22- Track honestly. Never minimize or inflate numbers.
23`.trim();
24
25import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
26import { findRecoveryNodes, getStatus } from "../core.js";
27
28export default {
29 name: "tree:recovery-log",
30 emoji: "🌱",
31 label: "Recovery Log",
32 bigMode: "tree",
33 hidden: true,
34
35 maxMessagesBeforeLoop: 4,
36 preserveContextOnLoop: false,
37
38 toolNames: [],
39
40 async buildSystemPrompt({ username, rootId, currentNodeId }) {
41 const recRoot = await findExtensionRoot(currentNodeId || rootId, "recovery") || rootId;
42 // Read tracked substances so the LLM knows what names to use
43 let substanceList = "";
44 try {
45 const nodes = await findRecoveryNodes(recRoot);
46 if (nodes?.substances && Object.keys(nodes.substances).length > 0) {
47 const status = await getStatus(recRoot);
48 const subs = Object.entries(nodes.substances).map(([name]) => {
49 const s = status?.substances?.[name] || {};
50 return ` ${name} (today: ${s.today || 0}, target: ${s.target || 0})`;
51 });
52 substanceList = `\nTRACKED SUBSTANCES (use these exact names):\n${subs.join("\n")}\n`;
53 }
54 } catch {}
55
56 return `You are ${username}'s recovery companion. You parse daily check-ins into structured data.
57${substanceList}
58When the user tells you how their day is going, extract:
59- Substance use: what, how much (use the exact substance names listed above)
60- Cravings: intensity (1-10), whether resisted, what triggered it
61- Mood: score (1-10), description
62- Energy: level (1-10)
63- Sleep quality if mentioned
64- Any context about what happened
65
66Return ONLY JSON when parsing a check-in:
67{
68 "substances": [{ "name": "caffeine", "doses": 2, "target": 3 }],
69 "cravings": [{ "intensity": 6, "resisted": true, "trigger": "afternoon slump" }],
70 "mood": { "score": 6, "description": "anxious" },
71 "energy": 5,
72 "sleep": "poor",
73 "slip": false,
74 "context": "almost broke in the afternoon"
75}
76
77Only include fields the user mentioned. If they just say "had 2 coffees", return only substances.
78If the user is NOT logging (just talking, asking questions, or venting), respond naturally.
79Be warm but not performative. Short is fine. Acknowledge what's hard without dramatizing.
80Point out patterns from context if you see them. "The afternoon is your hard window."
81If they slipped, log it without shame. Ask what happened. Context helps pattern detection.
82
83${SAFETY}`.trim();
84 },
85};
86
1// recovery/modes/plan.js
2// Taper scheduling. Creates or adjusts plans that bend around the person.
3// Prompt is async: reads the live tree so the AI adapts to custom structures.
4
5import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
6import { findRecoveryNodes } from "../core.js";
7
8const SAFETY = `
9HARD RULES:
10- Never provide medical advice about withdrawal symptoms
11- Never recommend specific medications or dosages
12- For alcohol and benzodiazepines: ALWAYS recommend medical supervision for tapering.
13 These substances have dangerous withdrawal syndromes. The AI can track progress but
14 the taper plan must be designed with a doctor.
15- If someone expresses hopelessness or mentions self-harm: 988 Suicide and Crisis Lifeline (call or text 988)
16- Never pressure faster reduction. The person sets the pace.
17- If they ask to slow down, slow down. No judgment.
18`.trim();
19
20export default {
21 name: "tree:recovery-plan",
22 emoji: "📋",
23 label: "Recovery Plan",
24 bigMode: "tree",
25 hidden: true,
26
27 maxMessagesBeforeLoop: 10,
28 preserveContextOnLoop: true,
29
30 toolNames: ["navigate-tree", "get-tree-context", "create-node-note", "create-new-node", "edit-node-schedule"],
31
32 async buildSystemPrompt({ username, rootId, currentNodeId }) {
33 const recRoot = await findExtensionRoot(currentNodeId || rootId, "recovery") || rootId;
34 const nodes = recRoot ? await findRecoveryNodes(recRoot) : null;
35
36 const EXPECTED = ["log", "feelings", "milestones", "profile", "substance"];
37 const found = [];
38 const missing = [];
39 if (nodes) {
40 for (const role of EXPECTED) {
41 if (nodes[role]) found.push(`${nodes[role].name} (role: ${role}, id: ${nodes[role].id})`);
42 else missing.push(role);
43 }
44 // Substance children with their sub-nodes
45 if (nodes.substances) {
46 for (const [name, info] of Object.entries(nodes.substances)) {
47 let detail = `${name} (id: ${info.id})`;
48 if (info.doses) detail += `, Doses: ${info.doses}`;
49 if (info.schedule) detail += `, Schedule: ${info.schedule}`;
50 found.push(detail);
51 }
52 }
53 // Custom user-created nodes
54 for (const [role, info] of Object.entries(nodes)) {
55 if (!EXPECTED.includes(role) && role !== "substances" && info?.id) {
56 found.push(`${info.name} (role: ${role}, id: ${info.id}) [user-created]`);
57 }
58 }
59 }
60
61 const structureBlock = found.length > 0
62 ? `CURRENT TREE STRUCTURE\n${found.map(f => `- ${f}`).join("\n")}`
63 : "TREE STRUCTURE: not yet discovered.";
64
65 const missingBlock = missing.length > 0
66 ? `\nMISSING STRUCTURAL NODES: ${missing.join(", ")}\nUse create-node to recreate them under root ${recRoot} with the correct metadata.recovery.role.`
67 : "";
68
69 const hasSubstances = nodes?.substances && Object.keys(nodes.substances).length > 0;
70
71 return `You are ${username}'s recovery plan assistant.
72Root ID: ${recRoot}
73
74${hasSubstances ? "STATUS: Substances being tracked. Plan mode." : "STATUS: No substances configured. Help them set up what they're tracking."}
75
76${structureBlock}${missingBlock}
77
78You help set up substance tracking and create reduction schedules. The person
79tells you where they are and where they want to be. You build a gradual plan.
80
81${!hasSubstances ? "SETUP (no substances yet)" : "SETUP (for adding new substances)"}
82- When the user tells you what they want to track, use recovery-add-substance to create it.
83- Pass rootId, substanceName, startingTarget (current daily amount), finalTarget (goal, 0 for quit).
84- Ask about each substance separately. Add each one with the tool.
85- After adding substances, ask about timeline and build a taper plan if they want one.
86
87CREATING A PLAN
88- Ask: what substance, current daily amount, target amount, timeline preference
89- Build weekly steps. Gradual reduction. One step per week is typical.
90- Write each step as a note on the substance's Schedule node.
91- Set the initial target on the substance's Doses node.
92
93ADJUSTING A PLAN
94- Read the current schedule, craving data, and slip history
95- If they ask to slow down: extend the current step by a week. No judgment.
96- If they're ahead of schedule: acknowledge it. Don't push faster unless they ask.
97- If they slipped: adjust the timeline. "The streak was 12 days. That's still 12 days."
98
99ADAPTING TO CUSTOM STRUCTURE
100The user may have added, renamed, or reorganized nodes. Work with whatever is there.
101If they added a Triggers node or a Support node, use it. The tree shape IS the application.
102Read it, don't assume it.
103
104PLAN FORMAT (written as notes):
105 "Week 1 (Mar 29 - Apr 4): 5 per day"
106 "Week 2 (Apr 5 - Apr 11): 4 per day"
107 etc.
108
109${SAFETY}`.trim();
110 },
111};
112
1// recovery/modes/reflect.js
2// The pattern analyzer. Reads across all nodes. Finds connections.
3// Presents observations, not prescriptions.
4
5const SAFETY = `
6HARD RULES:
7- Never provide medical advice about withdrawal symptoms
8- Never recommend specific medications or dosages
9- If someone mentions dangerous withdrawal symptoms, recommend a medical professional immediately.
10- If someone expresses hopelessness or mentions self-harm: 988 Suicide and Crisis Lifeline (call or text 988)
11- Never use shame, guilt, or disappointment language
12- Present patterns as observations. "Here's what I see." Not "You should."
13- The person decides what to do with the information.
14`.trim();
15
16export default {
17 name: "tree:recovery-review",
18 emoji: "🔍",
19 label: "Recovery Review",
20 bigMode: "tree",
21 hidden: true,
22
23 maxMessagesBeforeLoop: 8,
24 preserveContextOnLoop: true,
25
26 toolNames: ["navigate-tree", "get-tree-context", "get-node-notes"],
27
28 buildSystemPrompt({ username }) {
29 return `You are ${username}'s recovery pattern analyst.
30
31You see the full picture: substance use with streaks, cravings with resist rates,
32mood and energy trends, detected patterns, milestones, and history.
33All of this is in your context.
34
35YOUR ROLE
36- Find correlations: exercise and cravings, sleep and mood, time of day and intensity
37- Present trends: "Mood has been trending up. Week 1 averaged 4.2. This week averaged 6.1."
38- Highlight what's working: "On mornings after workouts, craving intensity drops by 40%."
39- Be honest about setbacks: "Two slips this month, both on weekends after social events."
40- Connect dots the person can't see: "4 of your 5 highest craving days had below 1200 calories by 3pm."
41
42TONE
43- Observations, not prescriptions
44- "Here's what I see in your data" not "You should do X"
45- Celebrate streaks without theater. "Day 30. You're here." The person knows what it means.
46- If patterns suggest something actionable, present it as data: "The pattern is consistent. Worth considering."
47- Match the user's energy. Quick check gets a summary. "Tell me everything" gets the full analysis.
48
49${SAFETY}`.trim();
50 },
51};
52
1/**
2 * Recovery Dashboard
3 *
4 * Substances, streaks, feelings, patterns, milestones, history.
5 * Renders via the generic app dashboard.
6 */
7
8import { renderAppDashboard } from "../../html-rendering/html/appDashboard.js";
9import { timeAgo } from "../../html-rendering/html/utils.js";
10
11export function renderRecoveryDashboard({ rootId, rootName, status, milestones, patterns, history, token, userId, inApp }) {
12 const substances = status?.substances || {};
13 const feelings = status?.feelings || {};
14 const streaks = status?.streaks || {};
15
16 // Stats
17 const stats = [];
18 for (const [name, streak] of Object.entries(streaks)) {
19 if (streak.current > 0) stats.push({ value: `${streak.current}d`, label: name });
20 }
21
22 // Bars for feelings (mood, energy, cravings all as 0-10 bars)
23 const bars = [];
24 if (feelings.mood) {
25 bars.push({
26 label: "Mood", current: feelings.mood.today || 0, goal: 10,
27 color: "#48bb78", unit: "/10",
28 sub: feelings.mood.weeklyAvg ? `avg: ${feelings.mood.weeklyAvg}` : "",
29 });
30 }
31 if (feelings.energy) {
32 bars.push({
33 label: "Energy", current: feelings.energy.today || 0, goal: 10,
34 color: "#667eea", unit: "/10",
35 sub: feelings.energy.weeklyAvg ? `avg: ${feelings.energy.weeklyAvg}` : "",
36 });
37 }
38 if (feelings.cravings) {
39 bars.push({
40 label: "Cravings", current: feelings.cravings.intensity || 0, goal: 10,
41 color: "#ef4444", unit: "/10",
42 sub: feelings.cravings.resistRate ? `${Math.round(feelings.cravings.resistRate * 100)}% resisted` : "",
43 });
44 }
45
46 // Cards
47 const cards = [];
48
49 // Substance tracking cards
50 const subNames = Object.keys(substances);
51 if (subNames.length > 0) {
52 cards.push({
53 title: "Tracking",
54 items: subNames.map(name => {
55 const sub = substances[name];
56 const streak = streaks[name] || {};
57 const badge = sub.onTarget ? "on target" : "over target";
58 return {
59 text: `${name}: ${sub.today || 0} today (target: ${sub.target || 0}) ${badge}`,
60 sub: streak.current ? `${streak.current}d streak (best: ${streak.longest || 0}d)` : null,
61 };
62 }),
63 empty: "No substances tracked yet. Check in below to start.",
64 });
65 }
66
67 // Patterns
68 cards.push({
69 title: "Patterns",
70 items: (patterns || []).slice(0, 5).map(p => ({
71 text: typeof p === "string" ? p : p.pattern || p.text || JSON.stringify(p),
72 })),
73 empty: "Patterns appear as the AI detects correlations.",
74 });
75
76 // Milestones
77 cards.push({
78 title: "Milestones",
79 items: (milestones || []).slice(0, 8).map(m => ({
80 text: typeof m === "string" ? m : m.text || m.name || "Milestone",
81 sub: m.date ? timeAgo(new Date(m.date)) : null,
82 })),
83 empty: "Milestones appear as you progress.",
84 });
85
86 // History
87 if (history?.length > 0) {
88 cards.push({
89 title: "Recent Days",
90 items: history.slice(0, 7).map(h => ({
91 text: h.date || "?",
92 sub: typeof h === "string" ? h : h.summary || JSON.stringify(h).slice(0, 120),
93 bg: true,
94 })),
95 });
96 }
97
98 return renderAppDashboard({
99 rootId, rootName, token, userId, inApp,
100 stats: stats.length > 0 ? stats : null,
101 bars: bars.length > 0 ? bars : null,
102 cards,
103 emptyState: !status ? { title: "Not initialized yet", message: "Check in below to get started." } : null,
104 commands: [
105 { cmd: "recovery <message>", desc: "Daily check-in" },
106 { cmd: "recovery reflect", desc: "Pattern analysis" },
107 { cmd: "recovery plan", desc: "Taper schedule" },
108 { cmd: "be", desc: "Check in now" },
109 ],
110 chatBar: { placeholder: "Check in. How are you doing today?", endpoint: `/api/v1/root/${rootId}/recovery` },
111 });
112}
113
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 findRecoveryNodes,
10 getStatus,
11 getPatterns,
12 getMilestones,
13 getHistory,
14 addSubstance,
15} from "./core.js";
16import { handleMessage } from "./handler.js";
17
18const router = express.Router();
19
20// ── HTML Dashboard (GET with ?html) ──
21router.get("/root/:rootId/recovery", async (req, res, next) => {
22 if (!("html" in req.query)) return next();
23 try {
24 const { isHtmlEnabled } = await import("../html-rendering/config.js");
25 if (!isHtmlEnabled()) return next();
26 const urlAuth = (await import("../html-rendering/urlAuth.js")).default;
27 urlAuth(req, res, async () => {
28 const { rootId } = req.params;
29 const root = await NodeModel.findById(rootId).select("name metadata").lean();
30 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Not found");
31 let status = null, milestones = null, patterns = null, history = null;
32 if (await isInitialized(rootId)) {
33 [status, milestones, patterns, history] = await Promise.all([
34 getStatus(rootId), getMilestones(rootId), getPatterns(rootId), getHistory(rootId),
35 ]);
36 }
37 const { renderRecoveryDashboard } = await import("./pages/dashboard.js");
38 res.send(renderRecoveryDashboard({ rootId, rootName: root.name, status, milestones, patterns, history, token: req.query.token || null, userId: req.userId, inApp: !!req.query.inApp }));
39 });
40 } catch (err) {
41 sendError(res, 500, ERR.INTERNAL, "Dashboard failed");
42 }
43});
44
45/**
46 * POST /root/:rootId/recovery
47 * Main entry point. Three paths: setup, check-in, questions.
48 */
49router.post("/root/:rootId/recovery", authenticate, async (req, res) => {
50 try {
51 const { rootId } = req.params;
52 const rawMessage = req.body.message;
53 const message = Array.isArray(rawMessage) ? rawMessage.join(" ") : rawMessage;
54 if (!message) return sendError(res, 400, ERR.INVALID_INPUT, "message required");
55
56 const root = await NodeModel.findById(rootId).select("rootOwner contributors").lean();
57 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
58
59 const userId = req.userId;
60 const isOwner = root.rootOwner?.toString() === userId;
61 const isContributor = root.contributors?.some(c => c.toString() === userId);
62 if (!isOwner && !isContributor) return sendError(res, 403, ERR.FORBIDDEN, "No access");
63
64 const { isExtensionBlockedAtNode } = await import("../../seed/tree/extensionScope.js");
65 if (await isExtensionBlockedAtNode("recovery", rootId)) {
66 return sendError(res, 403, ERR.EXTENSION_BLOCKED, "Recovery is blocked on this branch.");
67 }
68
69 const user = await UserModel.findById(userId).select("username").lean();
70 const username = user?.username || "user";
71
72 const result = await handleMessage(message, { userId, username, rootId, res });
73
74 if (result.error) {
75 if (!res.headersSent) sendError(res, result.status || 500, result.code || ERR.INTERNAL, result.message);
76 return;
77 }
78
79 if (!res.headersSent) sendOk(res, result);
80 } catch (err) {
81 log.error("Recovery", "Route error:", err.message);
82 if (!res.headersSent) sendError(res, 500, ERR.INTERNAL, err.message);
83 }
84});
85
86/**
87 * GET /root/:rootId/recovery/check
88 */
89router.get("/root/:rootId/recovery/check", authenticate, async (req, res) => {
90 try {
91 const status = await getStatus(req.params.rootId);
92 if (!status) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Recovery tree not found");
93 sendOk(res, status);
94 } catch (err) {
95 sendError(res, 500, ERR.INTERNAL, err.message);
96 }
97});
98
99/**
100 * GET /root/:rootId/recovery/patterns
101 */
102router.get("/root/:rootId/recovery/patterns", authenticate, async (req, res) => {
103 try {
104 const patterns = await getPatterns(req.params.rootId);
105 sendOk(res, { patterns });
106 } catch (err) {
107 sendError(res, 500, ERR.INTERNAL, err.message);
108 }
109});
110
111/**
112 * GET /root/:rootId/recovery/milestones
113 */
114router.get("/root/:rootId/recovery/milestones", authenticate, async (req, res) => {
115 try {
116 const milestones = await getMilestones(req.params.rootId);
117 sendOk(res, { milestones });
118 } catch (err) {
119 sendError(res, 500, ERR.INTERNAL, err.message);
120 }
121});
122
123/**
124 * GET /root/:rootId/recovery/taper
125 */
126router.get("/root/:rootId/recovery/taper", authenticate, async (req, res) => {
127 try {
128 const nodes = await findRecoveryNodes(req.params.rootId);
129 if (!nodes) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Recovery tree not found");
130
131 const Note = (await import("../../seed/models/note.js")).default;
132 const taperData = {};
133
134 for (const [name, sub] of Object.entries(nodes.substances || {})) {
135 if (!sub.schedule) continue;
136 const notes = await Note.find({ nodeId: sub.schedule })
137 .sort({ createdAt: 1 })
138 .select("content createdAt")
139 .lean();
140 taperData[name] = notes.map(n => n.content);
141
142 // Current dose values
143 if (sub.doses) {
144 const doseNode = await NodeModel.findById(sub.doses).select("metadata").lean();
145 const values = doseNode?.metadata instanceof Map ? doseNode.metadata.get("values") : doseNode?.metadata?.values;
146 if (values) {
147 taperData[name + "_status"] = {
148 today: values.today || 0,
149 target: values.target || 0,
150 finalTarget: values.finalTarget || 0,
151 streak: values.streak || 0,
152 };
153 }
154 }
155 }
156
157 sendOk(res, { taper: taperData });
158 } catch (err) {
159 sendError(res, 500, ERR.INTERNAL, err.message);
160 }
161});
162
163/**
164 * POST /root/:rootId/recovery/substance
165 * Add a new substance to track.
166 */
167router.post("/root/:rootId/recovery/substance", authenticate, async (req, res) => {
168 try {
169 const { name, startingTarget, finalTarget } = req.body;
170 if (!name) return sendError(res, 400, ERR.INVALID_INPUT, "Substance name required");
171
172 const result = await addSubstance(req.params.rootId, name, req.userId, { startingTarget, finalTarget });
173 sendOk(res, result);
174 } catch (err) {
175 sendError(res, 500, ERR.INTERNAL, err.message);
176 }
177});
178
179export default router;
180
1/**
2 * Recovery tools
3 *
4 * MCP tools for the AI to modify the recovery tree.
5 * addSubstance creates a substance node with schedule and dose tracking.
6 * completeSetup marks the tree as configured.
7 */
8
9import { z } from "zod";
10import { addSubstance, completeSetup } from "./core.js";
11
12export default function getTools() {
13 return [
14 {
15 name: "recovery-add-substance",
16 description:
17 "Add a substance to track. Creates a substance node with schedule and dose tracking. " +
18 "Call this during setup when the user tells you what they want to track.",
19 schema: {
20 rootId: z.string().describe("Root node ID of the recovery tree."),
21 substanceName: z.string().describe("Name of the substance (e.g. 'vape', 'caffeine', 'alcohol')."),
22 startingTarget: z.number().optional().describe("Current daily usage amount."),
23 finalTarget: z.number().optional().describe("Target daily usage (0 for quit)."),
24 userId: z.string().describe("Injected by server. Ignore."),
25 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
26 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
27 },
28 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
29 handler: async ({ rootId, substanceName, startingTarget, finalTarget, userId }) => {
30 try {
31 const result = await addSubstance(rootId, substanceName, userId, {
32 startingTarget: startingTarget || 0,
33 finalTarget: finalTarget || 0,
34 });
35 return { content: [{ type: "text", text: `Now tracking "${substanceName}". Setup auto-completes on first substance.` }] };
36 } catch (err) {
37 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
38 }
39 },
40 },
41 {
42 name: "recovery-complete-setup",
43 description: "Mark recovery tree setup as complete. Call after adding all substances.",
44 schema: {
45 rootId: z.string().describe("Root node ID of the recovery tree."),
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: true },
51 handler: async ({ rootId }) => {
52 try {
53 await completeSetup(rootId);
54 return { content: [{ type: "text", text: "Setup complete." }] };
55 } catch (err) {
56 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
57 }
58 },
59 },
60 ];
61}
62
Loading comments...