EXTENSION for TreeOS
recovery
The tree that grows toward health. Track substances, feelings, cravings, and patterns. Taper schedules that bend around you. Pattern detection that finds what you can't see. A mirror, not a judge. Three modes: recovery-log for daily check-ins, recovery-reflect for pattern analysis, recovery-plan for taper scheduling. Milestone detection. Journal node for unstructured writing the AI doesn't analyze. Safety boundaries for dangerous withdrawals and crisis situations. Type 'be' at the Recovery tree to check in: the AI asks how you're doing today. The person is always the agent.
v1.0.2 by TreeOS Site 0 downloads 12 files 1,615 lines 55.9 KB published 38d ago
treeos ext install recovery
View changelog

Manifest

Provides

  • routes
  • tools
  • jobs
  • 5 CLI commands
  • 2 custom hooks

Requires

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

Optional

  • extensions: values, channels, fitness, food, scheduler, breath, notifications, html-rendering, treeos-base
SHA256: 0263541011d8d72187dbd6c5abf69dc181414b2d6e355a2a43c32985b55cb636

CLI Commands

CommandMethodDescription
recovery [message...]POSTCheck in or log how you're doing.
recovery-checkGETToday's status.
recovery-patternsGETDetected patterns.
recovery-milestonesGETYour milestones.
recovery-taperGETShow taper plan.

Hooks

Listens To

  • enrichContext
  • breath:exhale

Fires

HookDataDescription
recovery:milestone
recovery:patternDetected

Source Code

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

Versions

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

Comments

Loading comments...

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