EXTENSION for TreeOS
dreams
Trees accumulate entropy. Nodes end up under the wrong parent. Dense notes pile up on a single node instead of branching into structure. Deferred items sit in short-term memory waiting to be placed. Compress summaries go stale. Left alone, a tree drifts from organized knowledge into a cluttered attic. Dreams is the nightly maintenance cycle that fights this. Each tree can set a dream time (HH:MM). A background job checks every 30 minutes whether any tree's dream time has passed and whether it has already dreamed today. When a tree is due, the full pipeline runs. Phase 1: cleanup. The AI analyzes the tree structure and moves misplaced nodes to better parents, deletes empty orphans, and expands dense notes into proper subtree branches. Cleanup runs up to five passes, stopping early when a pass produces zero changes. Phase 2: short-term drain. Pending items in the ShortMemory collection are clustered by theme, scouted for placement locations in the tree, planned with confidence scores, then placed as notes on the correct nodes. Items that fail three drain attempts are escalated to holdings for human review. Drain runs up to five passes. Phase 3: understanding. If the understanding extension is installed, a compression run updates the bottom-up summaries across the tree. Phase 4: notifications. The AI reviews all chat records from the dream's sessions and generates two outputs: a factual summary of what changed and a reflective thought about the tree's direction. Both are saved as notifications and dispatched to gateway channels if the gateway extension is present. Seven custom AI modes power the pipeline: cleanup-analyze, cleanup-expand-scan, drain-cluster, drain-scout, drain-plan, dream-summary, and dream-thought. Each has its own LLM slot mapping so land operators can assign different models to different phases. The cleanup slot, drain slot, and notification slot are independently configurable per tree. Concurrency is controlled by the kernel's lock system. Only one dream can run per tree at a time. Trees with no children, no LLM connection, or no configured dream time are skipped. The lastDreamAt timestamp on root metadata prevents double-runs within the same day. Holdings (deferred items) are accessible via CLI and API for manual review, detail inspection, and dismissal.
v1.0.1 by TreeOS Site 0 downloads 17 files 2,046 lines 71.3 KB published 38d ago
treeos ext install dreams
View changelog

Manifest

Provides

  • 1 models
  • routes
  • jobs
  • 4 CLI commands

Requires

  • models: Node, Contribution
  • extensions: understanding

Optional

  • services: energy, llm
  • extensions: gateway, notifications, treeos-base
SHA256: 25529861d58116592d26d8eb7986bb186179ebaf1910b0e2b660500ddf7adde0

CLI Commands

CommandMethodDescription
dream-time <time>POSTSet daily dream time (HH:MM) for current tree
holdingsGETList deferred items for current tree
holdings-dismiss <id>POSTDismiss a deferred item
holdings-view <id>GETView details of a deferred item

Source Code

1// orchestrators/pipelines/cleanupExpand.js
2// Scans nodes with dense notes and expands them into subtree structure.
3// Pipeline: find candidates -> scan each (tool-less) -> create branches via tree:structure -> delete notes via tree:notes.
4
5import log from "../../seed/log.js";
6import { OrchestratorRuntime, LLM_PRIORITY } from "../../seed/orchestrators/runtime.js";
7import { SESSION_TYPES } from "../../seed/ws/sessionRegistry.js";
8import Node from "../../seed/models/node.js";
9import Note from "../../seed/models/note.js";
10import User from "../../seed/models/user.js";
11
12// ─────────────────────────────────────────────────────────────────────────
13// CONSTANTS
14// ─────────────────────────────────────────────────────────────────────────
15
16const MAX_CANDIDATES_PER_RUN = 10;
17const MIN_NOTE_LENGTH = 300;
18const MIN_NOTE_COUNT = 3;
19
20function parseNewBranchId(text) {
21  if (!text) return null;
22  const str = typeof text === "object" ? JSON.stringify(text) : String(text);
23  const match = str.match(/Root ID:\s*(\S+)/);
24  return match ? match[1] : null;
25}
26
27/**
28 * Walk the tree and find nodes with dense notes worth scanning for expansion.
29 */
30async function findExpansionCandidates(rootId) {
31  const candidates = [];
32
33  async function walk(nodeId, depth) {
34    if (candidates.length >= MAX_CANDIDATES_PER_RUN) return;
35
36    const node = await Node.findById(nodeId).select("_id name type metadata children").lean();
37    if (!node) return;
38
39    const pMeta = node.metadata instanceof Map ? node.metadata.get("prestige") : node.metadata?.prestige;
40    const currentPrestige = pMeta?.current || 0;
41
42    const notes = await Note.find({
43      nodeId: node._id,
44      contentType: "text",
45    })
46      .select("_id content userId")
47      .lean();
48
49    if (notes.length > 0) {
50      const totalLength = notes.reduce((sum, n) => sum + (n.content?.length || 0), 0);
51
52      if (totalLength >= MIN_NOTE_LENGTH || notes.length >= MIN_NOTE_COUNT) {
53        candidates.push({
54          nodeId: node._id,
55          nodeName: node.name,
56          nodeType: node.type || null,
57          prestige: currentPrestige,
58          children: node.children || [],
59          notes,
60          totalLength,
61        });
62      }
63    }
64
65    if (node.children?.length && depth < 6) {
66      for (const childId of node.children) {
67        if (candidates.length >= MAX_CANDIDATES_PER_RUN) break;
68        await walk(childId, depth + 1);
69      }
70    }
71  }
72
73  await walk(rootId, 0);
74  candidates.sort((a, b) => b.totalLength - a.totalLength);
75  return candidates;
76}
77
78// ─────────────────────────────────────────────────────────────────────────
79// MAIN ORCHESTRATOR
80// ─────────────────────────────────────────────────────────────────────────
81
82export async function orchestrateExpand({
83  rootId,
84  userId,
85  username,
86  source = "orchestrator",
87}) {
88  const rt = new OrchestratorRuntime({
89    rootId,
90    userId,
91    username,
92    visitorId: `cleanup-expand:${rootId}:${Date.now()}`,
93    sessionType: SESSION_TYPES.CLEANUP_EXPAND,
94    description: `Cleanup expand: ${rootId}`,
95    modeKeyForLlm: "tree:cleanup-expand-scan",
96    source,
97    lockNamespace: "cleanup-expand",
98    llmPriority: LLM_PRIORITY.BACKGROUND,
99  });
100
101  const initialized = await rt.init();
102  if (!initialized) {
103 log.verbose("Dreams", `Cleanup expand already running for tree ${rootId}, skipping`);
104    return { success: false, error: "already running", sessionId: null };
105  }
106
107 log.verbose("Dreams", `Cleanup expand started for tree ${rootId}`);
108
109  try {
110    // STEP 1: FIND CANDIDATE NODES
111    const candidates = await findExpansionCandidates(rootId);
112
113    if (candidates.length === 0) {
114 log.debug("Dreams", "No dense notes found, nothing to expand");
115      rt.setResult("No expansion candidates", "cleanup-expand:complete");
116      return { success: true, expanded: 0, sessionId: rt.sessionId };
117    }
118
119 log.verbose("Dreams", `Found ${candidates.length} candidate node(s) with dense notes`);
120
121    let totalExpansions = 0;
122
123    // STEP 2: SCAN + EXPAND EACH CANDIDATE
124    for (const candidate of candidates) {
125      if (rt.aborted) break;
126
127      // Resolve child names for context
128      let childrenNames = [];
129      if (candidate.children.length > 0) {
130        const childNodes = await Node.find({ _id: { $in: candidate.children } })
131          .select("name")
132          .lean();
133        childrenNames = childNodes.map((c) => c.name);
134      }
135
136      // Resolve usernames for notes
137      const notesWithUsernames = [];
138      for (const note of candidate.notes) {
139        let noteUsername = "system";
140        if (note.userId) {
141          const noteUser = await User.findById(note.userId).select("username").lean();
142          if (noteUser) noteUsername = noteUser.username;
143        }
144        notesWithUsernames.push({ ...note, username: noteUsername });
145      }
146
147      // SCAN PHASE (tool-less)
148      const { parsed: scanPlan } = await rt.runStep("tree:cleanup-expand-scan", {
149        prompt: `Evaluate the notes for "${candidate.nodeName}" and determine if any should be expanded into subtree structure.`,
150        modeCtx: {
151          nodeName: candidate.nodeName,
152          nodeId: candidate.nodeId,
153          nodeType: candidate.nodeType,
154          notes: notesWithUsernames,
155          childrenNames,
156        },
157        input: `Scan "${candidate.nodeName}" (${candidate.notes.length} notes, ${candidate.totalLength} chars)`,
158      });
159
160      if (!scanPlan?.expansions?.length) {
161 log.debug("Dreams", `  "${candidate.nodeName}", notes are fine, no expansion needed`);
162        continue;
163      }
164
165      // EXECUTE EXPANSIONS
166      for (const expansion of scanPlan.expansions) {
167        if (rt.aborted) break;
168
169        // Create branch via tree:structure
170        const { parsed: buildData, raw: buildResult } = await rt.runStep("tree:structure", {
171          prompt: `Create this branch structure under the current node: ${JSON.stringify(expansion.newBranch)}. Reason: ${expansion.reason}`,
172          modeCtx: { targetNodeId: candidate.nodeId },
173          input: `Create branch "${expansion.newBranch?.name}" under "${candidate.nodeName}"`,
174          treeContext: (data) => ({ targetNodeId: candidate.nodeId, stepResult: data ? "success" : "failed" }),
175        });
176
177        if (!buildData) {
178 log.warn("Dreams", ` Failed to create branch for expansion in "${candidate.nodeName}"`);
179          continue;
180        }
181
182        // Transfer original note to new branch via tree:notes
183        if (expansion.deleteOriginalNote && expansion.noteId) {
184          const newBranchId = parseNewBranchId(buildResult?.answer || buildResult);
185
186          if (newBranchId) {
187            await rt.runStep("tree:notes", {
188              prompt: `Transfer note ${expansion.noteId} to node ${newBranchId}. Its content has been expanded into that branch structure.`,
189              modeCtx: {
190                targetNodeId: candidate.nodeId,
191                prestige: candidate.prestige,
192              },
193              input: `Transfer note ${expansion.noteId} to ${newBranchId}`,
194              treeContext: (data) => ({ targetNodeId: candidate.nodeId, stepResult: data ? "success" : "failed" }),
195            });
196          } else {
197            // Fallback: delete if we couldn't parse the new branch ID
198            await rt.runStep("tree:notes", {
199              prompt: `Delete note ${expansion.noteId}. Its content has been expanded into a new branch structure.`,
200              modeCtx: {
201                targetNodeId: candidate.nodeId,
202                prestige: candidate.prestige,
203              },
204              input: `Delete note ${expansion.noteId} (fallback)`,
205              treeContext: (data) => ({ targetNodeId: candidate.nodeId, stepResult: data ? "success" : "failed" }),
206            });
207          }
208        }
209
210        totalExpansions++;
211 log.debug("Dreams", `Expanded note in "${candidate.nodeName}" -> branch "${expansion.newBranch?.name}"`);
212      }
213    }
214
215    rt.setResult(`Expanded ${totalExpansions} note(s) across ${candidates.length} candidate(s)`, "cleanup-expand:complete");
216 log.verbose("Dreams", `Cleanup expand complete: ${totalExpansions} expansion(s)`);
217    return { success: true, expanded: totalExpansions, sessionId: rt.sessionId };
218  } catch (err) {
219 log.error("Dreams", `Cleanup expand error for tree ${rootId}:`, err.message);
220    rt.setError(err.message, "cleanup-expand:complete");
221    return { success: false, error: err.message, sessionId: rt.sessionId };
222  } finally {
223    await rt.cleanup();
224  }
225}
226
1// orchestrators/pipelines/cleanupReorganize.js
2// Analyzes tree structure and moves misplaced nodes / removes empty misplaced nodes.
3// Pipeline: analyze (tool-less) -> execute moves via tree:structure -> execute deletes via tree:structure.
4
5import log from "../../seed/log.js";
6import { OrchestratorRuntime, LLM_PRIORITY } from "../../seed/orchestrators/runtime.js";
7import { SESSION_TYPES } from "../../seed/ws/sessionRegistry.js";
8import { buildDeepTreeSummary } from "../../seed/tree/treeFetch.js";
9import Node from "../../seed/models/node.js";
10
11const MAX_MOVES = 5;
12const MAX_DELETES = 3;
13
14// ─────────────────────────────────────────────────────────────────────────
15// MAIN ORCHESTRATOR
16// ─────────────────────────────────────────────────────────────────────────
17
18export async function orchestrateReorganize({
19  rootId,
20  userId,
21  username,
22  source = "orchestrator",
23}) {
24  const rt = new OrchestratorRuntime({
25    rootId,
26    userId,
27    username,
28    visitorId: `cleanup-reorg:${rootId}:${Date.now()}`,
29    sessionType: SESSION_TYPES.CLEANUP_REORGANIZE,
30    description: `Cleanup reorganize: ${rootId}`,
31    modeKeyForLlm: "tree:cleanup-analyze",
32    source,
33    lockNamespace: "cleanup-reorg",
34    llmPriority: LLM_PRIORITY.BACKGROUND,
35  });
36
37  const initialized = await rt.init();
38  if (!initialized) {
39 log.verbose("Dreams", `Cleanup reorganize already running for tree ${rootId}, skipping`);
40    return { success: false, error: "already running", sessionId: null };
41  }
42
43 log.verbose("Dreams", `Cleanup reorganize started for tree ${rootId}`);
44
45  try {
46    // STEP 1: ANALYZE TREE STRUCTURE
47    let encodingMap = null;
48    try {
49      const { getExtension } = await import("../loader.js");
50      const uExt = getExtension("understanding");
51      if (uExt?.exports?.getEncodingMap) encodingMap = await uExt.exports.getEncodingMap(rootId);
52    } catch {}
53    const treeSummary = await buildDeepTreeSummary(rootId, {
54      includeIds: true,
55      encodingMap,
56    });
57
58    const { parsed: plan } = await rt.runStep("tree:cleanup-analyze", {
59      prompt: "Analyze this tree for misplaced or empty nodes that should be moved or removed.",
60      modeCtx: { treeSummary },
61      input: "tree analysis",
62    });
63
64    if (!plan) {
65 log.error("Dreams", "Cleanup reorganize: analysis returned invalid JSON");
66      rt.setResult("Analysis failed, invalid JSON", "cleanup-reorg:complete");
67      return { success: false, error: "analysis failed", sessionId: rt.sessionId };
68    }
69
70    const moves = (plan.moves || []).slice(0, MAX_MOVES);
71    const deletes = (plan.deletes || []).slice(0, MAX_DELETES);
72
73    if (moves.length === 0 && deletes.length === 0) {
74 log.debug("Dreams", "Tree is well-organized, no changes needed");
75      rt.setResult("Tree is well-organized", "cleanup-reorg:complete");
76      return { success: true, moves: 0, deletes: 0, sessionId: rt.sessionId };
77    }
78
79 log.debug("Dreams", `Plan: ${moves.length} move(s), ${deletes.length} delete(s)`);
80
81    // STEP 2: EXECUTE MOVES via tree:structure
82    let moveCount = 0;
83    for (const move of moves) {
84      if (rt.aborted) break;
85
86      const node = await Node.findById(move.nodeId).select("_id name").lean();
87      if (!node) {
88 log.warn("Dreams", `Skipping move, node ${move.nodeId} no longer exists`);
89        continue;
90      }
91
92      const { parsed: moveData } = await rt.runStep("tree:structure", {
93        prompt: `Move node ${move.nodeId} ("${move.nodeName}") to this parent node. Reason: ${move.reason}`,
94        modeCtx: { targetNodeId: move.newParentId },
95        input: `Move "${move.nodeName}" to ${move.newParentId}`,
96        treeContext: (data) => ({ targetNodeId: move.newParentId, stepResult: data ? "success" : "failed" }),
97      });
98
99      if (moveData) moveCount++;
100 log.debug("Dreams", `Moved "${move.nodeName}": ${moveData ? "success" : "failed"}`);
101    }
102
103    // STEP 3: EXECUTE DELETES via tree:structure
104    let deleteCount = 0;
105    for (const del of deletes) {
106      if (rt.aborted) break;
107
108      const node = await Node.findById(del.nodeId).select("_id name children").lean();
109      if (!node) {
110 log.warn("Dreams", `Skipping delete, node ${del.nodeId} no longer exists`);
111        continue;
112      }
113
114      const { parsed: delData } = await rt.runStep("tree:structure", {
115        prompt: `Delete node ${del.nodeId} ("${del.nodeName}"). It is empty and misplaced. Reason: ${del.reason}`,
116        modeCtx: { targetNodeId: del.nodeId },
117        input: `Delete "${del.nodeName}"`,
118        treeContext: (data) => ({ targetNodeId: del.nodeId, stepResult: data ? "success" : "failed" }),
119      });
120
121      if (delData) deleteCount++;
122 log.debug("Dreams", `Deleted "${del.nodeName}": ${delData ? "success" : "failed"}`);
123    }
124
125    rt.setResult(`Reorganized: ${moveCount} moved, ${deleteCount} deleted`, "cleanup-reorg:complete");
126 log.verbose("Dreams", `Cleanup reorganize complete: ${moveCount} moved, ${deleteCount} deleted`);
127    return { success: true, moves: moveCount, deletes: deleteCount, sessionId: rt.sessionId };
128  } catch (err) {
129 log.error("Dreams", `Cleanup reorganize error for tree ${rootId}:`, err.message);
130    rt.setError(err.message, "cleanup-reorg:complete");
131    return { success: false, error: err.message, sessionId: rt.sessionId };
132  } finally {
133    await rt.cleanup();
134  }
135}
136
1// orchestrators/pipelines/dreamNotify.js
2// Phase 4 of tree dream: generates summary + thought notifications from dream AI chats.
3// Two tool-less LLM calls, then saves Notification documents.
4
5import log from "../../seed/log.js";
6import { OrchestratorRuntime, LLM_PRIORITY } from "../../seed/orchestrators/runtime.js";
7import { SESSION_TYPES } from "../../seed/ws/sessionRegistry.js";
8import { getExtension } from "../loader.js";
9import Chat from "../../seed/models/chat.js";
10import Node from "../../seed/models/node.js";
11
12function getNotificationModel() {
13  const ext = getExtension("notifications");
14  return ext?.exports?.Notification || null;
15}
16
17const MSG_CAP = 1500;
18
19function capText(text) {
20  if (!text || text.length <= MSG_CAP) return text || "";
21  return text.slice(0, MSG_CAP) + "...";
22}
23
24/**
25 * Build a condensed dream log from Chat records.
26 */
27function buildDreamLog(chats) {
28  const entries = [];
29  for (const chat of chats) {
30    const mode = chat.aiContext?.path || "unknown";
31    const result = chat.treeContext?.stepResult || "";
32    const target = chat.treeContext?.targetPath || chat.treeContext?.targetNodeName || "";
33
34    let header = `[${mode}]`;
35    if (target) header += ` on "${target}"`;
36    if (result) header += ` (${result})`;
37
38    const startMsg = capText(chat.startMessage?.content);
39    const endMsg = capText(chat.endMessage?.content);
40
41    let entry = header;
42    if (startMsg) entry += `\nInput: ${startMsg}`;
43    if (endMsg) entry += `\nOutput: ${endMsg}`;
44
45    entries.push(entry);
46  }
47  return entries.slice(0, 60).join("\n---\n");
48}
49
50// ─────────────────────────────────────────────────────────────────────────
51// MAIN ORCHESTRATOR
52// ─────────────────────────────────────────────────────────────────────────
53
54export async function orchestrateDreamNotify({
55  rootId,
56  userId,
57  username,
58  treeName,
59  dreamSessionIds,
60  source = "background",
61}) {
62  const rt = new OrchestratorRuntime({
63    rootId,
64    userId,
65    username,
66    visitorId: `dream-notify:${rootId}:${Date.now()}`,
67    sessionType: SESSION_TYPES.DREAM_NOTIFY,
68    description: `Dream notifications: ${treeName}`,
69    modeKeyForLlm: "tree:dream-summary",
70    source,
71    llmPriority: LLM_PRIORITY.BACKGROUND,
72  });
73
74  const initialized = await rt.init(`Dream notifications for "${treeName}"`);
75  if (!initialized) return;
76
77 log.verbose("Dreams", `Dream notifications starting for "${treeName}"`);
78
79  try {
80    // Fetch dream AI chats
81    const dreamChats = await Chat.find({
82      sessionId: { $in: dreamSessionIds },
83    })
84      .sort({ sessionId: 1, chainIndex: 1 })
85      .select("aiContext treeContext startMessage endMessage")
86      .lean();
87
88    if (dreamChats.length === 0) {
89 log.debug("Dreams", `No AI chats found for dream sessions, skipping notifications`);
90      rt.setResult("No dream activity to summarize", "dream-notify:complete");
91      return;
92    }
93
94    const dreamLog = buildDreamLog(dreamChats);
95
96    // STEP 1: DREAM SUMMARY
97    const { parsed: summary } = await rt.runStep("tree:dream-summary", {
98      prompt: "Summarize this dream.",
99      modeCtx: { treeName, dreamLog },
100      input: "dream summary",
101    });
102
103    // STEP 2: DREAM THOUGHT
104    const { parsed: thought } = await rt.runStep("tree:dream-thought", {
105      prompt: "Generate a thought for today.",
106      modeCtx: { treeName, dreamLog },
107      input: "dream thought",
108    });
109
110    // SAVE NOTIFICATIONS
111    const rootNode = await Node.findById(rootId).select("rootOwner contributors").lean();
112    const recipients = new Set();
113    if (rootNode?.rootOwner) recipients.add(rootNode.rootOwner);
114    if (rootNode?.contributors) {
115      for (const c of rootNode.contributors) recipients.add(c);
116    }
117
118    // Strip HTML tags from LLM output. The prompt asks for plain text
119    // but models sometimes return HTML/markdown. Notifications render
120    // as escaped text, so tags would show as literal <h1>, <strong>, etc.
121    function stripTags(str) {
122      if (typeof str !== "string") return str;
123      return str.replace(/<[^>]*>/g, "").trim();
124    }
125
126    const notifications = [];
127
128    for (const recipientId of recipients) {
129      if (summary?.title && summary?.content) {
130        notifications.push({
131          userId: recipientId,
132          rootId,
133          type: "dream-summary",
134          title: stripTags(summary.title),
135          content: stripTags(summary.content),
136          dreamSessionIds,
137        });
138      }
139
140      if (thought?.title && thought?.content) {
141        notifications.push({
142          userId: recipientId,
143          rootId,
144          type: "dream-thought",
145          title: stripTags(thought.title),
146          content: stripTags(thought.content),
147          dreamSessionIds,
148        });
149      }
150    }
151
152    if (notifications.length > 0) {
153      const Notification = getNotificationModel();
154      if (!Notification) {
155        log.warn("Dreams", "Notifications extension not installed, skipping notification save");
156      } else {
157        await Notification.insertMany(notifications);
158        log.verbose("Dreams", `Created ${notifications.length} notification(s) for ${recipients.size} user(s)`);
159      }
160
161      // Dispatch to gateway channels (fire-and-forget)
162      const uniqueNotifs = [];
163      if (summary?.title && summary?.content) {
164        uniqueNotifs.push({ type: "dream-summary", title: summary.title, content: summary.content });
165      }
166      if (thought?.title && thought?.content) {
167        uniqueNotifs.push({ type: "dream-thought", title: thought.title, content: thought.content });
168      }
169      if (uniqueNotifs.length > 0) {
170        const gateway = getExtension("gateway");
171        if (gateway?.exports?.dispatchNotifications) {
172          gateway.exports.dispatchNotifications(rootId, uniqueNotifs)
173            .catch((err) => log.error("Dreams", `Gateway dispatch error for root ${rootId}:`, err.message));
174        }
175      }
176    }
177
178    rt.setResult(
179      `Summary: ${summary?.title || "failed"} | Thought: ${thought?.title || "failed"}`,
180      "dream-notify:complete",
181    );
182  } catch (err) {
183 log.error("Dreams", `Dream notification error for "${treeName}":`, err.message);
184    rt.setError(err.message, "dream-notify:complete");
185  } finally {
186    await rt.cleanup();
187  }
188}
189
1import {
2  startTreeDreamJob,
3  stopTreeDreamJob,
4  runTreeDreamJob,
5  setMetadata as setDreamMetadata,
6} from "./treeDream.js";
7import router from "./routes.js";
8
9import cleanupAnalyze from "./modes/cleanupAnalyze.js";
10import cleanupExpandScan from "./modes/cleanupExpandScan.js";
11import drainCluster from "./modes/drainCluster.js";
12import drainScout from "./modes/drainScout.js";
13import drainPlan from "./modes/drainPlan.js";
14import dreamSummary from "./modes/dreamSummary.js";
15import dreamThought from "./modes/dreamThought.js";
16
17export async function init(core) {
18  setDreamMetadata(core.metadata);
19
20  // Register dream/cleanup/drain modes + LLM slot mappings
21  core.modes.registerMode("tree:cleanup-analyze", cleanupAnalyze, "dreams");
22  core.modes.registerMode("tree:cleanup-expand-scan", cleanupExpandScan, "dreams");
23  core.modes.registerMode("tree:drain-cluster", drainCluster, "dreams");
24  core.modes.registerMode("tree:drain-scout", drainScout, "dreams");
25  core.modes.registerMode("tree:drain-plan", drainPlan, "dreams");
26  core.modes.registerMode("tree:dream-summary", dreamSummary, "dreams");
27  core.modes.registerMode("tree:dream-thought", dreamThought, "dreams");
28  if (core.llm?.registerModeAssignment) {
29    core.llm.registerModeAssignment("tree:cleanup-analyze", "cleanup");
30    core.llm.registerModeAssignment("tree:cleanup-expand-scan", "cleanup");
31    core.llm.registerModeAssignment("tree:drain-cluster", "drain");
32    core.llm.registerModeAssignment("tree:drain-scout", "drain");
33    core.llm.registerModeAssignment("tree:drain-plan", "drain");
34    core.llm.registerModeAssignment("tree:dream-summary", "notification");
35    core.llm.registerModeAssignment("tree:dream-thought", "notification");
36    core.llm.registerRootLlmSlot?.("cleanup");
37    core.llm.registerRootLlmSlot?.("drain");
38    core.llm.registerRootLlmSlot?.("notification");
39  }
40
41  // Register tree overview slots
42  try {
43    const { getExtension } = await import("../loader.js");
44    const base = getExtension("treeos-base");
45    base?.exports?.registerSlot?.("tree-dream", "dreams", ({ rootMeta, nodeId, token }) => {
46      const dreamTime = rootMeta?.metadata?.dreams?.dreamTime || rootMeta?.metadata?.get?.("dreams")?.dreamTime || "";
47      const lastDream = rootMeta?.metadata?.dreams?.lastDreamAt || rootMeta?.metadata?.get?.("dreams")?.lastDreamAt || null;
48      return `<div class="content-card">
49  <div class="section-header"><h2>Tree Dream</h2></div>
50  <p style="color:rgba(255,255,255,0.7);font-size:0.85rem;margin:0 0 12px">
51    Schedule a daily maintenance cycle: cleanup, process deferred items, and update tree understanding.
52  </p>
53  <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
54    <input type="time" id="dreamTimeInput" value="${dreamTime}"
55      style="padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);
56             background:rgba(255,255,255,0.06);color:#fff;font-size:0.95rem" />
57    <button onclick="saveDreamTime()" style="padding:8px 14px;border-radius:8px;
58      border:1px solid rgba(72,187,120,0.4);background:rgba(72,187,120,0.15);
59      color:rgba(72,187,120,0.9);font-weight:600;cursor:pointer">Save</button>
60    <button onclick="clearDreamTime()" style="padding:8px 14px;border-radius:8px;
61      border:1px solid rgba(255,107,107,0.4);background:rgba(255,107,107,0.1);
62      color:rgba(255,107,107,0.8);cursor:pointer">Disable</button>
63    <span id="dreamTimeStatus" style="display:none;font-size:0.85rem"></span>
64  </div>
65  ${lastDream ? `<p style="color:rgba(255,255,255,0.6);font-size:0.8rem;margin:8px 0 0">Last dream: ${new Date(lastDream).toLocaleString()}</p>` : ""}
66</div>`;
67    }, { priority: 20 });
68
69    base?.exports?.registerSlot?.("tree-holdings", "dreams", ({ deferredItems, deferredHtml }) => {
70      return `<div class="content-card">
71  <div class="section-header">
72    <h2>Short-Term Holdings ${(deferredItems?.length || 0) > 0 ? `<span style="font-size:0.7em;color:#ffb347;">(${deferredItems.length})</span>` : ""}</h2>
73  </div>
74  ${deferredHtml || '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.5);font-size:14px;">No short-term items</div>'}
75</div>`;
76    }, { priority: 10 });
77  } catch {}
78
79  return {
80    router,
81    jobs: [
82      {
83        name: "tree-dream",
84        start: () => {
85          startTreeDreamJob({ intervalMs: 30 * 60 * 1000 });
86          runTreeDreamJob();
87        },
88        stop: () => stopTreeDreamJob(),
89      },
90    ],
91  };
92}
93
1export default {
2  name: "dreams",
3  version: "1.0.1",
4  builtFor: "TreeOS",
5  description:
6    "Trees accumulate entropy. Nodes end up under the wrong parent. Dense notes pile up " +
7    "on a single node instead of branching into structure. Deferred items sit in short-term " +
8    "memory waiting to be placed. Compress summaries go stale. Left alone, a tree drifts " +
9    "from organized knowledge into a cluttered attic. Dreams is the nightly maintenance " +
10    "cycle that fights this. " +
11    "\n\n" +
12    "Each tree can set a dream time (HH:MM). A background job checks every 30 minutes " +
13    "whether any tree's dream time has passed and whether it has already dreamed today. " +
14    "When a tree is due, the full pipeline runs. Phase 1: cleanup. The AI analyzes the " +
15    "tree structure and moves misplaced nodes to better parents, deletes empty orphans, " +
16    "and expands dense notes into proper subtree branches. Cleanup runs up to five passes, " +
17    "stopping early when a pass produces zero changes. Phase 2: short-term drain. Pending " +
18    "items in the ShortMemory collection are clustered by theme, scouted for placement " +
19    "locations in the tree, planned with confidence scores, then placed as notes on the " +
20    "correct nodes. Items that fail three drain attempts are escalated to holdings for " +
21    "human review. Drain runs up to five passes. Phase 3: understanding. If the " +
22    "understanding extension is installed, a compression run updates the bottom-up " +
23    "summaries across the tree. Phase 4: notifications. The AI reviews all chat records " +
24    "from the dream's sessions and generates two outputs: a factual summary of what " +
25    "changed and a reflective thought about the tree's direction. Both are saved as " +
26    "notifications and dispatched to gateway channels if the gateway extension is present. " +
27    "\n\n" +
28    "Seven custom AI modes power the pipeline: cleanup-analyze, cleanup-expand-scan, " +
29    "drain-cluster, drain-scout, drain-plan, dream-summary, and dream-thought. Each has " +
30    "its own LLM slot mapping so land operators can assign different models to different " +
31    "phases. The cleanup slot, drain slot, and notification slot are independently " +
32    "configurable per tree. " +
33    "\n\n" +
34    "Concurrency is controlled by the kernel's lock system. Only one dream can run per " +
35    "tree at a time. Trees with no children, no LLM connection, or no configured dream " +
36    "time are skipped. The lastDreamAt timestamp on root metadata prevents double-runs " +
37    "within the same day. Holdings (deferred items) are accessible via CLI and API for " +
38    "manual review, detail inspection, and dismissal.",
39
40  needs: {
41    services: [],
42    models: ["Node", "Contribution"],
43    extensions: ["understanding"],
44  },
45
46  optional: {
47    services: ["energy", "llm"],
48    extensions: ["gateway", "notifications", "treeos-base"],
49  },
50
51  provides: {
52    models: {
53      ShortMemory: "./model.js",
54    },
55    routes: "./routes.js",
56    tools: false,
57    jobs: "./treeDream.js",
58    orchestrator: false,
59    energyActions: {},
60    sessionTypes: {
61      DREAM_ORCHESTRATE: "dream-orchestrate",
62      DREAM_NOTIFY: "dream-notify",
63      SHORT_TERM_DRAIN: "short-term-drain",
64      CLEANUP_REORGANIZE: "cleanup-reorganize",
65      CLEANUP_EXPAND: "cleanup-expand",
66    },
67    cli: [
68      { command: "dream-time <time>", scope: ["tree"], description: "Set daily dream time (HH:MM) for current tree", method: "POST", endpoint: "/root/:rootId/dream-time" },
69      { command: "holdings", scope: ["tree"], description: "List deferred items for current tree", method: "GET", endpoint: "/root/:rootId/holdings" },
70      { command: "holdings-dismiss <id>", scope: ["tree"], description: "Dismiss a deferred item", method: "POST", endpoint: "/root/:rootId/holdings/:id/dismiss" },
71      { command: "holdings-view <id>", scope: ["tree"], description: "View details of a deferred item", method: "GET", endpoint: "/root/:rootId/holdings/:id" },
72    ],
73    hooks: {
74      fires: [],
75      listens: [],
76    },
77  },
78};
79
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4const ShortMemorySchema = new mongoose.Schema({
5  _id: {
6    type: String,
7    default: uuidv4,
8  },
9  rootId: {
10    type: String,
11    ref: "Node",
12    required: true,
13  },
14  userId: {
15    type: String,
16    ref: "User",
17    required: true,
18  },
19  content: {
20    type: String,
21    required: true,
22  },
23  systemResponse: {
24    type: String,
25    default: null,
26  },
27  sessionId: {
28    type: String,
29    default: null,
30  },
31  candidates: [
32    {
33      _id: false,
34      nodeId: { type: String },
35      nodePath: { type: String },
36      confidence: { type: Number },
37      reasoning: { type: String },
38    },
39  ],
40  deferReason: {
41    type: String,
42    default: null,
43  },
44  classificationAxes: {
45    pathConfidence: { type: Number, default: null },
46    domainNovelty: { type: Number, default: null },
47    relationalComplexity: { type: Number, default: null },
48  },
49  sourceType: {
50    type: String,
51    enum: ["tree-chat", "tree-place", "tree-query", "ws-tree-place", "ws-tree-query", "ws-tree-chat", "raw-idea-chat", "raw-idea-place", "gateway-telegram", "gateway-discord"],
52    required: true,
53  },
54  sourceId: {
55    type: String,
56    default: null,
57  },
58  drainAttempts: {
59    type: Number,
60    default: 0,
61  },
62  status: {
63    type: String,
64    enum: ["pending", "placed", "dismissed", "escalated"],
65    default: "pending",
66  },
67  placedAt: {
68    type: Date,
69    default: null,
70  },
71  placedNodeId: {
72    type: String,
73    default: null,
74  },
75  createdAt: {
76    type: Date,
77    default: Date.now,
78  },
79});
80
81ShortMemorySchema.index({ rootId: 1, status: 1 });
82ShortMemorySchema.index({ userId: 1, status: 1 });
83
84const ShortMemory = mongoose.model("ShortMemory", ShortMemorySchema);
85export default ShortMemory;
86
1// extensions/dreams/modes/cleanupAnalyze.js
2// Tool-less analysis mode for tree reorganization.
3// LLM receives the full tree summary and outputs a JSON plan of moves/deletes.
4
5export default {
6  name: "tree:cleanup-analyze",
7  bigMode: "tree",
8  hidden: true,
9  toolNames: [],
10
11  buildSystemPrompt({ treeSummary }) {
12    return `You are a tree structure analyst. Your job is to examine a knowledge tree and identify misplaced nodes.
13
14TREE STRUCTURE
15${treeSummary}
16
17NODE TYPES
18Nodes may have a type: goal, plan, task, knowledge, resource, identity, or custom.
19Use types to evaluate structure quality:
20- A goal without child plans or tasks beneath it is unsupported
21- Tasks scattered outside any plan may belong grouped under one
22- Knowledge nodes buried under task branches may belong in a knowledge section
23- Identity nodes should be near the root, not deep in branches
24- Mistyped nodes (a task that's clearly knowledge) can be flagged
25
26YOUR JOB
27Analyze the tree for:
281. Nodes under wrong parents, considering both topic AND type hierarchy
292. Empty misplaced nodes with no meaningful content
303. Type-structural issues: unsupported goals, orphaned tasks, misplaced identity nodes
31
32OUTPUT FORMAT (STRICT JSON ONLY)
33{
34  "moves": [
35    {
36      "nodeId": "the [id:xxx] from the tree",
37      "nodeName": "human-readable name",
38      "newParentId": "the [id:xxx] of the correct parent",
39      "reason": "why this node belongs there instead"
40    }
41  ],
42  "deletes": [
43    {
44      "nodeId": "the [id:xxx] from the tree",
45      "nodeName": "human-readable name",
46      "reason": "why this empty node should be removed"
47    }
48  ],
49  "typeIssues": [
50    {
51      "nodeId": "the [id:xxx] from the tree",
52      "nodeName": "human-readable name",
53      "currentType": "current type or null",
54      "suggestedType": "what it should be",
55      "reason": "why"
56    }
57  ],
58  "summary": "one-sentence overview of changes"
59}
60
61RULES
62- NEVER move or delete the root node (depth 0)
63- Only delete nodes that are clearly broken hierarchy (duplicates, redundant structure) or completely irrelevant to the tree
64- Empty nodes that fit the tree's structure should be LEFT ALONE — they may be placeholders waiting to be filled
65- Nodes with [N notes] annotations have user content — NEVER delete these, move them instead
66- Prefer moving over deleting — when in doubt, MOVE instead of delete
67- Max 5 moves and 3 deletes per analysis
68- If the tree is well-organized, return empty arrays: { "moves": [], "deletes": [], "summary": "Tree is well-organized" }
69- Use the [id:xxx] values from the tree summary for nodeId and newParentId
70- Do not output anything except the JSON object
71- Be conservative — only flag clear misplacements, not subjective preferences`.trim();
72  },
73};
74
1// extensions/dreams/modes/cleanupExpandScan.js
2// Tool-less analysis mode for note expansion.
3// LLM receives a node's notes and decides if any should be expanded into subtree structure.
4
5export default {
6  name: "tree:cleanup-expand-scan",
7  bigMode: "tree",
8  hidden: true,
9  toolNames: [],
10
11  buildSystemPrompt({ nodeName, nodeId, nodeType, notes, childrenNames }) {
12    const notesBlock = notes
13      .map(
14        (n, i) =>
15          `[${i}] noteId=${n._id}
16  by: ${n.username || "system"}
17  content: ${n.content}`,
18      )
19      .join("\n\n");
20
21    const childrenBlock =
22      childrenNames?.length > 0
23        ? `Existing children: ${childrenNames.join(", ")}`
24        : "No existing children";
25
26    return `You are a note expansion analyst. Your job is to identify notes that are too dense and should be broken into subtree structure.
27
28TARGET NODE: "${nodeName}" [id:${nodeId}]${nodeType ? ` (type: ${nodeType})` : ""}
29${childrenBlock}
30
31NOTES
32${notesBlock}
33
34NODE TYPES
35When expanding notes into branches, assign types to new child nodes:
36goal (desired outcome), plan (strategy), task (completable work),
37knowledge (stored understanding), resource (tools/capabilities/references), identity (values/constraints).
38Match the type to what the extracted content represents.
39
40YOUR JOB
41Evaluate each note. A note needs expansion when:
42- It covers 3+ distinct sub-topics crammed into one note
43- It's longer than ~400 words with clearly separable sections
44- The content would be better organized as a branch of child nodes
45- A note contains a list of trackable items that should be individual nodes with types
46
47OUTPUT FORMAT (STRICT JSON ONLY)
48{
49  "expansions": [
50    {
51      "noteId": "the noteId to expand",
52      "newBranch": {
53        "name": "branch name that captures the theme",
54        "type": "type for the branch node or null",
55        "children": [
56          { "name": "sub-topic name", "type": "type or null", "note": "content extracted from original note" }
57        ]
58      },
59      "deleteOriginalNote": true,
60      "reason": "why this note benefits from expansion"
61    }
62  ]
63}
64
65RULES
66- If notes are fine as-is, return { "expansions": [] }
67- Max 2 expansions per node
68- deleteOriginalNote should be true when ALL content is captured in the new branch
69- Set it to false if the original note has content worth keeping beyond what was extracted
70- New branch names should not duplicate existing children
71- Each child note should contain the relevant extracted content — not a summary, the actual content
72- Do not output anything except the JSON object
73- Be conservative — only expand notes that are clearly too dense`.trim();
74  },
75};
76
1// extensions/dreams/modes/drainCluster.js
2// Groups pending ShortMemory items into placement clusters.
3// Pure reasoning — no tools. All items injected into system prompt.
4
5export default {
6  name: "tree:drain-cluster",
7  bigMode: "tree",
8  hidden: true,
9  toolNames: [],
10
11  buildSystemPrompt({ rootId, items }) {
12    const itemsBlock = items
13      .map(
14        (item, i) =>
15          `[${i}] id=${item._id}
16  content: ${item.content}
17  deferReason: ${item.deferReason || "none"}
18  candidates: ${item.candidates?.length ? item.candidates.map((c) => `${c.nodePath || c.nodeId} (${c.confidence})`).join(", ") : "none"}
19  sessionId: ${item.sessionId || "none"}
20  systemResponse: ${item.systemResponse ? item.systemResponse.slice(0, 200) : "none"}`,
21      )
22      .join("\n\n");
23
24    return `You are a clustering engine for deferred memory items in a knowledge tree.
25
26Tree root: ${rootId}
27
28PENDING ITEMS
29${itemsBlock}
30
31YOUR JOB
32Group these items into placement clusters. Items belong in the same cluster when:
331. They came from the same session (same sessionId) — this is the strongest signal, start here
342. They share overlapping candidate nodes (same node appears in multiple items)
353. Their content is about the same topic and would land in the same area of the tree
364. They have similar deferReasons
37
38Each cluster should be placeable as a unit — all items in a cluster go to the same area.
39Single-item clusters are fine when an item is unrelated to others.
40
41NODE TYPES
42When suggesting structure, consider what type the placement target should be:
43goal, plan, task, knowledge, resource, identity. This helps the placement engine
44assign types when creating new nodes.
45
46OUTPUT FORMAT (STRICT JSON ONLY)
47{
48  "clusters": [
49    {
50      "clusterId": number,
51      "itemIds": [string],
52      "sharedTheme": string,
53      "candidateHints": [string],
54      "needsNewStructure": boolean,
55      "suggestedType": "goal|plan|task|knowledge|resource|identity|null"
56    }
57  ]
58}
59
60RULES:
61- Every item must appear in exactly one cluster
62- candidateHints: node names or paths from candidates that should be checked first
63- needsNewStructure: true if items likely need new branches/nodes, false if existing nodes suffice
64- Do not output anything except the JSON object`.trim();
65  },
66};
67
1// extensions/dreams/modes/drainPlan.js
2// Takes cluster items + scouted pins, proposes concrete build + place steps.
3// Pure reasoning — no tools.
4
5export default {
6  name: "tree:drain-plan",
7  bigMode: "tree",
8  hidden: true,
9  toolNames: [],
10
11  buildSystemPrompt({ rootId, cluster, pins }) {
12    const itemsList = cluster.items
13      .map(
14        (item, i) =>
15          `[${i}] id=${item._id}\n  content: ${item.content}`,
16      )
17      .join("\n\n");
18
19    return `You are a tree structure planner. Given deferred items and scouted locations (pins), create a placement plan.
20
21Tree root: ${rootId}
22
23ITEMS TO PLACE
24${itemsList}
25
26SCOUTED PINS
27${JSON.stringify(pins, null, 2)}
28
29NODE TYPES
30Assign types to new nodes: goal, plan, task, knowledge, resource, identity, or null.
31${cluster.suggestedType ? `The cluster was classified as: ${cluster.suggestedType}` : "Choose based on content."}
32
33YOUR JOB
34Create a placement plan with two phases:
351. BUILD: New nodes/branches to create first (if any), with types
362. PLACE: Where each item's content becomes a note
37
38OUTPUT FORMAT (STRICT JSON ONLY)
39{
40  "buildSteps": [
41    {
42      "parentNodeId": string,
43      "structure": { "name": string, "type": "string or null", "children": [{ "name": string, "type": "string or null" }] },
44      "reason": string
45    }
46  ],
47  "placeSteps": [
48    {
49      "itemId": string,
50      "targetNodeId": string | null,
51      "targetNewNodeName": string | null,
52      "noteContent": string,
53      "confidence": number
54    }
55  ],
56  "overallConfidence": number,
57  "summary": string
58}
59
60RULES:
61- buildSteps run first, creating structure. placeSteps run after.
62- When a placeStep targets a node from buildSteps, set targetNewNodeName to the name and targetNodeId to null
63- noteContent must preserve the user's original words — do not rewrite or formalize
64- If overallConfidence < 0.5, the cluster will be re-queued rather than placed
65- buildSteps can be empty if existing nodes suffice
66- Do not output anything except the JSON object`.trim();
67  },
68};
69
1// extensions/dreams/modes/drainScout.js
2// Scouts the tree for placement locations using navigate-tree tool.
3// Drops "pins" on candidate nodes for a given cluster.
4
5export default {
6  name: "tree:drain-scout",
7  bigMode: "tree",
8  hidden: true,
9
10  maxMessagesBeforeLoop: 15,
11  preserveContextOnLoop: false,
12
13  toolNames: ["navigate-tree"],
14
15  buildSystemPrompt({ rootId, cluster, treeSummary }) {
16    const itemsList = cluster.items
17      .map((item, i) => `  [${i}] ${item.content}`)
18      .join("\n");
19
20    return `You are a navigation engine scouting placement locations for deferred items.
21
22Tree root: ${rootId}
23
24${treeSummary ? `TABLE OF CONTENTS:\n${treeSummary}\n` : ""}
25CLUSTER TO PLACE
26Theme: ${cluster.sharedTheme}
27Needs new structure: ${cluster.needsNewStructure ? "likely yes" : "probably not"}${cluster.suggestedType ? `\nSuggested type: ${cluster.suggestedType}` : ""}
28Items:
29${itemsList}
30
31Candidate hints: ${JSON.stringify(cluster.candidateHints || [])}
32
33NODE TYPES
34The tree uses types: goal, plan, task, knowledge, resource, identity.
35When scouting, consider type compatibility. Tasks belong under plans or goals.
36Knowledge belongs in knowledge sections. Note node types shown in the tree summary.
37
38YOUR JOB
39Find where these items belong in the tree. Drop "pins" on candidate locations.
40
411. Start from the candidate hints — use navigate-tree with search to find them.
422. If hints don't resolve, explore the tree structure from root.
433. Navigate to promising branches, inspect their children.
444. For each viable location, record it as a pin.
45
46OUTPUT FORMAT (STRICT JSON ONLY)
47{
48  "pins": [
49    {
50      "nodeId": string,
51      "nodePath": string,
52      "pinType": "exact" | "parent" | "sibling",
53      "reasoning": string,
54      "confidence": number
55    }
56  ],
57  "needsNewStructure": boolean,
58  "structureHint": string | null,
59  "summary": string
60}
61
62RULES:
63- You MUST call navigate-tree at least once before returning
64- "exact": items can be placed directly on/under this node
65- "parent": new child node(s) needed under this parent
66- "sibling": items belong near this node (same parent area)
67- structureHint: if needsNewStructure=true, describe what to create
68- Pin the deepest relevant node, not high-level branches
69- Return the JSON as your final response, no markdown`.trim();
70  },
71};
72
1// extensions/dreams/modes/dreamSummary.js
2// Tool-less mode for generating a plain English summary of a tree dream.
3// Receives the Chat log from cleanup + drain phases and produces a notification.
4
5export default {
6  name: "tree:dream-summary",
7  bigMode: "tree",
8  hidden: true,
9  toolNames: [],
10
11  buildSystemPrompt({ treeName, dreamLog }) {
12    return `You are summarizing what happened during a tree's nightly dream maintenance.
13
14TREE NAME: "${treeName}"
15
16DREAM ACTIVITY LOG
17${dreamLog}
18
19YOUR JOB
20Write a short, clear summary of what the dream did to the tree. This will be shown as a notification to the tree's users.
21
22OUTPUT FORMAT (STRICT JSON ONLY)
23{
24  "title": "A short title (under 60 chars) like 'Dream complete: 3 nodes reorganized'",
25  "content": "A 2-4 sentence plain English summary of the changes. Mention specific node names or paths if available. Be concrete, not vague."
26}
27
28RULES
29- Never use em dashes (use commas, periods, or "to" instead)
30- Write for humans, not developers. No technical jargon.
31- If no meaningful changes happened, say so honestly.
32- Do not output anything except the JSON object`.trim();
33  },
34};
35
1// extensions/dreams/modes/dreamThought.js
2// Tool-less mode for generating a personalized thought/reminder from a tree dream.
3// Based on the dream's AI chat log, produces an actionable suggestion for the user.
4
5export default {
6  name: "tree:dream-thought",
7  bigMode: "tree",
8  hidden: true,
9  toolNames: [],
10
11  buildSystemPrompt({ treeName, dreamLog, username }) {
12    return `You are a tree that just finished its nightly dream. Based on what you processed and organized, generate a single helpful thought or reminder for the user.
13
14TREE NAME: "${treeName}"
15USER: "${username}"
16
17DREAM ACTIVITY LOG (what was processed, organized, and placed during the dream)
18${dreamLog}
19
20YOUR JOB
21Generate a personalized, actionable thought based on what the tree contains and what was worked on. Think of it like a gentle nudge or reminder.
22
23Examples of good thoughts:
24- "You added three workout entries this week but haven't logged anything for legs. Maybe today is leg day?"
25- "Your project roadmap has two items marked as next steps. The API refactor depends on the auth changes, so that might be worth tackling first."
26- "You've been collecting a lot of reading notes lately. It might be a good time to review and connect them."
27
28OUTPUT FORMAT (STRICT JSON ONLY)
29{
30  "title": "A short label (under 50 chars) like 'Thought from your workout tree'",
31  "content": "1-3 sentences. Specific, actionable, and friendly. Reference actual content from the tree when possible."
32}
33
34RULES
35- Never use em dashes (use commas, periods, or "to" instead)
36- Be specific to the tree's content, not generic advice
37- Write as the tree talking to the user in a friendly tone
38- If there's nothing meaningful to suggest, give a brief status update instead
39- Do not output anything except the JSON object`.trim();
40  },
41};
42
1import express from "express";
2import mongoose from "mongoose";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import authenticate from "../../seed/middleware/authenticate.js";
5import ShortMemory from "./model.js";
6
7const router = express.Router();
8
9/**
10 * Load root and verify the requesting user is owner or contributor.
11 * Returns the root doc or sends an error response.
12 */
13async function loadRootAndAuthorize(req, res, { ownerOnly = false } = {}) {
14  const Node = mongoose.model("Node");
15  const root = await Node.findById(req.params.rootId).lean();
16  if (!root || !root.rootOwner) {
17    sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
18    return null;
19  }
20  const userId = req.userId.toString();
21  const isOwner = root.rootOwner.toString() === userId;
22  const isContributor =
23    !ownerOnly &&
24    Array.isArray(root.contributors) &&
25    root.contributors.some((c) => (c.user || c).toString() === userId);
26  if (!isOwner && !isContributor) {
27    sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
28    return null;
29  }
30  return root;
31}
32
33// GET /root/:rootId/holdings -- list pending + escalated items
34router.get("/root/:rootId/holdings", authenticate, async (req, res) => {
35  try {
36    const root = await loadRootAndAuthorize(req, res);
37    if (!root) return;
38
39    const items = await ShortMemory.find({
40      rootId: req.params.rootId,
41      status: { $in: ["pending", "escalated"] },
42    })
43      .sort({ createdAt: -1 })
44      .lean();
45
46    if (items.length === 0) {
47      return sendOk(res, { answer: "No short term memories right now." });
48    }
49
50    const list = items.map((item) => ({
51      _id: item._id,
52      title:
53        item.content.length > 80
54          ? item.content.slice(0, 80) + "..."
55          : item.content,
56      status: item.status,
57      deferReason: item.deferReason,
58      drainAttempts: item.drainAttempts,
59      sourceType: item.sourceType,
60      createdAt: item.createdAt,
61    }));
62
63    return sendOk(res, { items: list });
64  } catch (err) {
65    sendError(res, 500, ERR.INTERNAL, err.message);
66  }
67});
68
69// GET /root/:rootId/holdings/:itemId -- view full details
70router.get("/root/:rootId/holdings/:itemId", authenticate, async (req, res) => {
71  try {
72    const root = await loadRootAndAuthorize(req, res);
73    if (!root) return;
74
75    const item = await ShortMemory.findById(req.params.itemId).lean();
76    if (!item || item.rootId !== req.params.rootId) {
77      return sendError(res, 404, ERR.NOTE_NOT_FOUND, "Item not found");
78    }
79
80    return sendOk(res, {
81      _id: item._id,
82      content: item.content,
83      status: item.status,
84      deferReason: item.deferReason,
85      candidates: item.candidates,
86      classificationAxes: item.classificationAxes,
87      sourceType: item.sourceType,
88      drainAttempts: item.drainAttempts,
89      systemResponse: item.systemResponse,
90      placedNodeId: item.placedNodeId,
91      placedAt: item.placedAt,
92      createdAt: item.createdAt,
93    });
94  } catch (err) {
95    sendError(res, 500, ERR.INTERNAL, err.message);
96  }
97});
98
99// POST /root/:rootId/holdings/:itemId/dismiss -- dismiss an item
100router.post(
101  "/root/:rootId/holdings/:itemId/dismiss",
102  authenticate,
103  async (req, res) => {
104    try {
105      const root = await loadRootAndAuthorize(req, res, { ownerOnly: true });
106      if (!root) return;
107
108      const item = await ShortMemory.findById(req.params.itemId);
109      if (!item || item.rootId !== req.params.rootId) {
110        return sendError(res, 404, ERR.NOTE_NOT_FOUND, "Item not found");
111      }
112
113      if (item.status === "dismissed") {
114        return sendOk(res, { _id: item._id, status: "dismissed" });
115      }
116
117      item.status = "dismissed";
118      await item.save();
119
120      return sendOk(res, { _id: item._id, status: "dismissed" });
121    } catch (err) {
122      sendError(res, 500, ERR.INTERNAL, err.message);
123    }
124  },
125);
126
127export default router;
128
1// orchestrators/pipelines/shortTermDrain.js
2// Drains pending ShortMemory items into the tree.
3// Pipeline: cluster -> scout -> plan -> build -> place.
4
5import log from "../../seed/log.js";
6import { OrchestratorRuntime, LLM_PRIORITY } from "../../seed/orchestrators/runtime.js";
7import { acquireLock, releaseLock } from "../../seed/orchestrators/locks.js";
8import { SESSION_TYPES } from "../../seed/ws/sessionRegistry.js";
9import { buildDeepTreeSummary } from "../../seed/tree/treeFetch.js";
10import ShortMemory from "./model.js";
11import Node from "../../seed/models/node.js";
12import User from "../../seed/models/user.js";
13
14// ─────────────────────────────────────────────────────────────────────────
15// CONSTANTS
16// ─────────────────────────────────────────────────────────────────────────
17
18const MAX_DRAIN_ATTEMPTS = 3;
19const MIN_CONFIDENCE = 0.5;
20const MAX_ITEMS_PER_RUN = 8;
21
22/**
23 * Increment drainAttempts on items. Escalate any that hit MAX_DRAIN_ATTEMPTS.
24 */
25async function requeueItems(itemIds, reason) {
26  if (!itemIds?.length) return;
27  await ShortMemory.updateMany(
28    { _id: { $in: itemIds } },
29    { $inc: { drainAttempts: 1 } },
30  );
31  await ShortMemory.updateMany(
32    { _id: { $in: itemIds }, drainAttempts: { $gte: MAX_DRAIN_ATTEMPTS } },
33    { status: "escalated" },
34  );
35 log.debug("Dreams", `Re-queued ${itemIds.length} items: ${reason}`);
36}
37
38// ─────────────────────────────────────────────────────────────────────────
39// MAIN ORCHESTRATOR
40// ─────────────────────────────────────────────────────────────────────────
41
42/**
43 * Drain all pending ShortMemory items for a given tree.
44 */
45export async function drainTree(rootId) {
46  if (!acquireLock("drain", rootId)) {
47 log.verbose("Dreams", `Drain already running for tree ${rootId}, skipping`);
48    return { sessionId: null };
49  }
50
51  // Pre-init: load items and resolve user before creating runtime
52  let items, rootNode, userId, username;
53  try {
54    items = await ShortMemory.find({
55      rootId,
56      status: "pending",
57      drainAttempts: { $lt: MAX_DRAIN_ATTEMPTS },
58    })
59      .sort({ createdAt: 1 })
60      .limit(MAX_ITEMS_PER_RUN)
61      .lean();
62
63    if (!items.length) {
64      releaseLock("drain", rootId);
65      return { sessionId: null };
66    }
67
68    rootNode = await Node.findById(rootId).select("rootOwner name").lean();
69    if (!rootNode?.rootOwner) {
70 log.error("Dreams", `Drain: tree ${rootId} has no rootOwner`);
71      releaseLock("drain", rootId);
72      return { sessionId: null };
73    }
74    userId = rootNode.rootOwner;
75    const user = await User.findById(userId).select("username").lean();
76    username = user?.username || "user";
77  } catch (err) {
78    releaseLock("drain", rootId);
79    throw err;
80  }
81
82  // Lock already held, so don't use lockNamespace (we manage it manually)
83  const rt = new OrchestratorRuntime({
84    rootId,
85    userId,
86    username,
87    visitorId: `drain:${rootId}`,
88    sessionType: SESSION_TYPES.SHORT_TERM_DRAIN,
89    description: `Short-term drain: ${rootNode.name || rootId}`,
90    modeKeyForLlm: "tree:drain-cluster",
91    source: "orchestrator",
92    llmPriority: LLM_PRIORITY.BACKGROUND,
93  });
94
95  await rt.init(`Draining ${items.length} short-term items for tree "${rootNode.name}"`);
96
97 log.verbose("Dreams", `Drain started: ${items.length} items for tree "${rootNode.name}" [${rootId.slice(0, 8)}]`);
98
99  try {
100    const treeSummary = await buildDeepTreeSummary(rootId).catch(() => "");
101    const itemMap = new Map(items.map((item) => [item._id, item]));
102
103    // STEP 1: CLUSTER ANALYSIS
104    const { parsed: manifest } = await rt.runStep("tree:drain-cluster", {
105      prompt: `Cluster these ${items.length} deferred items for tree "${rootNode.name}".`,
106      modeCtx: { items },
107      input: `${items.length} items`,
108    });
109
110    if (!manifest?.clusters?.length) {
111 log.error("Dreams", "Drain: cluster analysis returned no clusters");
112      await requeueItems(items.map((i) => i._id), "cluster analysis failed");
113      rt.setResult("Cluster analysis failed", "drain:complete");
114      return { sessionId: rt.sessionId };
115    }
116
117 log.debug("Dreams", `Clustered into ${manifest.clusters.length} cluster(s)`);
118
119    // Attach full item objects to each cluster
120    for (const cluster of manifest.clusters) {
121      cluster.items = (cluster.itemIds || [])
122        .map((id) => itemMap.get(id))
123        .filter(Boolean);
124    }
125
126    let totalPlaced = 0;
127    let totalRequeued = 0;
128
129    // STEP 2-4: PER-CLUSTER PROCESSING
130    for (const cluster of manifest.clusters) {
131      if (rt.aborted) break;
132      if (!cluster.items.length) continue;
133
134      const clusterItemIds = cluster.items.map((i) => i._id);
135
136      try {
137        // SCOUT
138        const { parsed: scoutData } = await rt.runStep("tree:drain-scout", {
139          prompt: `Scout placement locations for ${cluster.items.length} items about: ${cluster.sharedTheme}`,
140          modeCtx: { cluster, treeSummary },
141          input: cluster.sharedTheme,
142        });
143
144        if (!scoutData?.pins?.length) {
145 log.warn("Dreams", `Scout found no pins for cluster "${cluster.sharedTheme}"`);
146          await requeueItems(clusterItemIds, "scout found no locations");
147          totalRequeued += clusterItemIds.length;
148          continue;
149        }
150
151        // PLAN
152        const { parsed: plan } = await rt.runStep("tree:drain-plan", {
153          prompt: `Plan placement for ${cluster.items.length} items using ${scoutData.pins.length} scouted locations.`,
154          modeCtx: { cluster, pins: scoutData.pins },
155          input: cluster.sharedTheme,
156        });
157
158        if (!plan?.placeSteps?.length) {
159 log.warn("Dreams", `Plan returned no place steps for cluster "${cluster.sharedTheme}"`);
160          await requeueItems(clusterItemIds, "plan returned no steps");
161          totalRequeued += clusterItemIds.length;
162          continue;
163        }
164
165        // CONFIDENCE CHECK
166        if ((plan.overallConfidence ?? 1) < MIN_CONFIDENCE) {
167 log.debug("Dreams", `Low confidence (${plan.overallConfidence}) for cluster "${cluster.sharedTheme}", re-queuing`);
168          await requeueItems(clusterItemIds, `low confidence: ${plan.overallConfidence}`);
169          totalRequeued += clusterItemIds.length;
170          continue;
171        }
172
173        // EXECUTE BUILD STEPS
174        const nameToIdMap = new Map();
175
176        if (plan.buildSteps?.length) {
177          for (const buildStep of plan.buildSteps) {
178            if (rt.aborted) break;
179
180            const { parsed: buildData } = await rt.runStep("tree:structure", {
181              prompt: `Create this branch structure under the target node: ${JSON.stringify(buildStep.structure)}. Reason: ${buildStep.reason}`,
182              modeCtx: { targetNodeId: buildStep.parentNodeId },
183              input: JSON.stringify(buildStep.structure),
184              treeContext: (data) => ({
185                targetNodeId: buildStep.parentNodeId,
186                stepResult: data ? "success" : "failed",
187              }),
188            });
189
190            if (buildData?.operations) {
191              for (const op of buildData.operations) {
192                if (op.nodeName && op.nodeId) {
193                  nameToIdMap.set(op.nodeName, op.nodeId);
194                }
195              }
196            }
197          }
198        }
199
200        // EXECUTE PLACE STEPS
201        const placedIds = [];
202
203        for (const placeStep of plan.placeSteps) {
204          if (rt.aborted) break;
205
206          // Resolve target node ID
207          let targetNodeId = placeStep.targetNodeId;
208          if (!targetNodeId && placeStep.targetNewNodeName) {
209            targetNodeId = nameToIdMap.get(placeStep.targetNewNodeName);
210
211            // Fallback: search tree for the node by name
212            if (!targetNodeId) {
213              const { parsed: navData } = await rt.runStep("tree:navigate", {
214                prompt: `Find the node named "${placeStep.targetNewNodeName}" that was just created.`,
215                input: `Navigate to "${placeStep.targetNewNodeName}"`,
216              });
217              if (navData?.targetNodeId) {
218                targetNodeId = navData.targetNodeId;
219              }
220            }
221          }
222
223          if (!targetNodeId) {
224 log.warn("Dreams", `Could not resolve target for item ${placeStep.itemId}, skipping`);
225            continue;
226          }
227
228          // Get current prestige for the target node
229          let prestige = 0;
230          try {
231            const targetNode = await Node.findById(targetNodeId).select("metadata").lean();
232            const pMeta = targetNode?.metadata instanceof Map ? targetNode.metadata.get("prestige") : targetNode?.metadata?.prestige;
233            prestige = pMeta?.current ?? 0;
234          } catch (err) { log.debug("Dreams", "Could not fetch prestige for target node:", err.message); }
235
236          const { parsed: noteData } = await rt.runStep("tree:notes", {
237            prompt: `Create a note with this content: ${placeStep.noteContent}`,
238            modeCtx: { targetNodeId, prestige },
239            input: placeStep.noteContent?.slice(0, 200),
240            treeContext: (data) => ({
241              targetNodeId,
242              stepResult: data ? "success" : "failed",
243            }),
244          });
245
246          if (noteData) {
247            placedIds.push({ itemId: placeStep.itemId, nodeId: targetNodeId });
248          }
249        }
250
251        // MARK ITEMS AS PLACED
252        if (placedIds.length) {
253          const now = new Date();
254          for (const { itemId, nodeId } of placedIds) {
255            await ShortMemory.findByIdAndUpdate(itemId, {
256              status: "placed",
257              placedAt: now,
258              placedNodeId: nodeId,
259            });
260          }
261          totalPlaced += placedIds.length;
262 log.debug("Dreams", `Placed ${placedIds.length}/${cluster.items.length} items for cluster "${cluster.sharedTheme}"`);
263        }
264
265        // Re-queue unplaced items
266        const unplacedIds = clusterItemIds.filter(
267          (id) => !placedIds.some((p) => p.itemId === id),
268        );
269        if (unplacedIds.length) {
270          await requeueItems(unplacedIds, "placement step skipped or failed");
271          totalRequeued += unplacedIds.length;
272        }
273      } catch (clusterErr) {
274 log.error("Dreams", `Cluster "${cluster.sharedTheme}" failed:`, clusterErr.message);
275        await requeueItems(clusterItemIds, clusterErr.message);
276        totalRequeued += clusterItemIds.length;
277      }
278    }
279
280    rt.setResult(`Placed ${totalPlaced}, re-queued ${totalRequeued} of ${items.length} items`, "drain:complete");
281 log.verbose("Dreams", `Drain complete: ${totalPlaced} placed, ${totalRequeued} re-queued`);
282  } catch (err) {
283 log.error("Dreams", `Drain orchestration error for tree ${rootId}:`, err.message);
284    rt.setError(err.message, "drain:complete");
285  } finally {
286    await rt.cleanup();
287    releaseLock("drain", rootId);
288  }
289
290  return { sessionId: rt.sessionId };
291}
292
1// jobs/shortTermDrain.js
2// Periodically drains pending ShortMemory items into their trees.
3// Finds all trees with pending items and processes them sequentially.
4
5import log from "../../seed/log.js";
6import ShortMemory from "./model.js";
7import Node from "../../seed/models/node.js";
8import { drainTree } from "./shortTermDrain.js";
9import { userHasLlm } from "../../seed/llm/conversation.js";
10
11// ─────────────────────────────────────────────────────────────────────────
12// STATE
13// ─────────────────────────────────────────────────────────────────────────
14
15let jobTimer = null;
16
17// ─────────────────────────────────────────────────────────────────────────
18// JOB RUN
19// ─────────────────────────────────────────────────────────────────────────
20
21export async function runShortTermDrain() {
22 log.verbose("Dreams", " Short-term drain job running...");
23
24  try {
25    // Find all distinct trees with pending items that haven't been escalated
26    const rootIds = await ShortMemory.distinct("rootId", {
27      status: "pending",
28      drainAttempts: { $lt: 3 },
29    });
30
31    if (rootIds.length === 0) {
32 log.verbose("Dreams", " No pending short-term items — skipping.");
33      return;
34    }
35
36 log.verbose("Dreams", ` ${rootIds.length} tree(s) with pending short-term items.`);
37
38    // Process each tree sequentially to avoid overloading LLM
39    for (const rootId of rootIds) {
40      // Skip if owner has no LLM and root has no LLM assigned
41      const rootNode = await Node.findById(rootId).select("rootOwner llmDefault metadata").lean();
42      if (rootNode) {
43        const hasRootLlm = !!(rootNode.llmDefault && rootNode.llmDefault !== "none");
44        const ownerId = rootNode.rootOwner?.toString();
45        if (!hasRootLlm && (!ownerId || !await userHasLlm(ownerId))) {
46 log.verbose("Dreams", ` Skipping drain for tree ${rootId} — owner has no LLM connection`);
47          continue;
48        }
49      }
50      await drainTree(rootId).catch((err) =>
51 log.error("Dreams", ` Drain failed for tree ${rootId}:`, err.message),
52      );
53    }
54  } catch (err) {
55 log.error("Dreams", " Short-term drain job error:", err.message);
56  }
57}
58
59// ─────────────────────────────────────────────────────────────────────────
60// START / STOP
61// ─────────────────────────────────────────────────────────────────────────
62
63export function startShortTermDrainJob({ intervalMs = 30 * 60 * 1000 } = {}) {
64  if (jobTimer) clearInterval(jobTimer);
65
66 log.info("Dreams", ` Short-term drain job started (interval: ${intervalMs / 1000}s)`);
67  jobTimer = setInterval(runShortTermDrain, intervalMs);
68  return jobTimer;
69}
70
71export function stopShortTermDrainJob() {
72  if (jobTimer) {
73    clearInterval(jobTimer);
74    jobTimer = null;
75 log.info("Dreams", "⏹ Short-term drain job stopped");
76  }
77}
78
1// jobs/treeDream.js
2// Unified "tree dream" — daily maintenance pipeline per tree.
3// Replaces independent cleanup, shortTermDrain, and understanding jobs.
4// Pipeline: cleanup (multi-pass) → short-term drain (multi-pass) → understanding run.
5// Triggered by user-configured dreamTime on root nodes.
6
7import log from "../../seed/log.js";
8import Node from "../../seed/models/node.js";
9import User from "../../seed/models/user.js";
10import ShortMemory from "./model.js";
11import { orchestrateReorganize } from "./cleanupReorganize.js";
12import { orchestrateExpand } from "./cleanupExpand.js";
13import { drainTree } from "./shortTermDrain.js";
14import { getExtension } from "../loader.js";
15import { orchestrateDreamNotify } from "./dreamNotify.js";
16import { userHasLlm } from "../../seed/llm/conversation.js";
17import { acquireLock, releaseLock } from "../../seed/orchestrators/locks.js";
18let _metadata = null;
19export function setMetadata(metadata) { _metadata = metadata; }
20
21// ─────────────────────────────────────────────────────────────────────────
22// CONFIG
23// ─────────────────────────────────────────────────────────────────────────
24
25const MAX_CLEANUP_PASSES = 5;
26const MAX_DRAIN_PASSES = 5;
27const MIN_TREE_CHILDREN = 2;
28
29const NAV_PERSPECTIVE =
30  "Summarize this section as if it is a node inside a larger knowledge tree. " +
31  "Write from a perspective that understands this content will sit between a parent above and possible branches below. " +
32  "Compress the meaning upward (what this contributes to the bigger picture) while preserving clarity downward " +
33  "(what direction this section points toward). Emphasize the core idea, remove detail noise.";
34
35let jobTimer = null;
36
37// ─────────────────────────────────────────────────────────────────────────
38// SINGLE TREE DREAM
39// ─────────────────────────────────────────────────────────────────────────
40
41async function runTreeDream(rootNode) {
42  const rootId = rootNode._id.toString();
43  const userId = rootNode.rootOwner.toString();
44
45  if (!acquireLock("dream", rootId)) {
46    log.verbose("Dreams", ` Dream already running for "${rootNode.name}", skipping`);
47    return;
48  }
49
50  // Skip empty trees (no children at all)
51  if (!rootNode.children || rootNode.children.length === 0) {
52    log.verbose("Dreams", ` Skipping "${rootNode.name}" — no children`);
53    releaseLock("dream", rootId);
54    return;
55  }
56
57  // Resolve username
58  const user = await User.findById(userId).select("username").lean();
59  if (!user) {
60    log.warn("Dreams", ` Dream: no user for tree ${rootId}`);
61    releaseLock("dream", rootId);
62    return;
63  }
64  const username = user.username;
65
66  // Skip if no LLM available (root assignment or user connection)
67  const rootFull = await Node.findById(rootId).select("llmDefault metadata").lean();
68  const treeLlmOff = !rootFull?.llmDefault || rootFull.llmDefault === "none";
69  if (treeLlmOff && !(await userHasLlm(userId))) {
70    log.verbose("Dreams", ` Skipping "${rootNode.name}" — owner has no LLM connection`);
71    releaseLock("dream", rootId);
72    return;
73  }
74
75    log.verbose("Dreams", 
76    `💤 Dream starting for "${rootNode.name}" [${rootId.slice(0, 8)}]`,
77  );
78
79  const dreamSessionIds = [];
80
81  try {
82    // ════════════════════════════════════════════════════════════════
83    // PHASE 1: CLEANUP (multi-pass)
84    // ════════════════════════════════════════════════════════════════
85
86    for (let pass = 1; pass <= MAX_CLEANUP_PASSES; pass++) {
87    log.verbose("Dreams", 
88        `💤 Cleanup pass ${pass}/${MAX_CLEANUP_PASSES} for "${rootNode.name}"`,
89      );
90
91      let totalChanges = 0;
92
93      try {
94        const reorgResult = await orchestrateReorganize({
95          rootId,
96          userId,
97          username,
98          source: "background",
99        });
100        if (reorgResult?.sessionId) dreamSessionIds.push(reorgResult.sessionId);
101        totalChanges += (reorgResult?.moves || 0) + (reorgResult?.deletes || 0);
102      } catch (err) {
103    log.error("Dreams", 
104          `❌ Dream cleanup reorganize pass ${pass} failed:`,
105          err.message,
106        );
107      }
108
109      try {
110        const expandResult = await orchestrateExpand({
111          rootId,
112          userId,
113          username,
114          source: "background",
115        });
116        if (expandResult?.sessionId)
117          dreamSessionIds.push(expandResult.sessionId);
118        totalChanges += expandResult?.expanded || 0;
119      } catch (err) {
120    log.error("Dreams", 
121          `❌ Dream cleanup expand pass ${pass} failed:`,
122          err.message,
123        );
124      }
125
126      if (totalChanges === 0) {
127    log.verbose("Dreams", ` Cleanup stable after pass ${pass} — no more changes`);
128        break;
129      }
130
131    log.verbose("Dreams", 
132        `💤 Cleanup pass ${pass}: ${totalChanges} change(s) — continuing`,
133      );
134    }
135
136    // ════════════════════════════════════════════════════════════════
137    // PHASE 2: SHORT-TERM DRAIN (multi-pass)
138    // ════════════════════════════════════════════════════════════════
139
140    for (let pass = 1; pass <= MAX_DRAIN_PASSES; pass++) {
141      // Check for remaining pending items
142      const pendingCount = await ShortMemory.countDocuments({
143        rootId,
144        status: "pending",
145        drainAttempts: { $lt: 3 },
146      });
147
148      if (pendingCount === 0) {
149    log.verbose("Dreams", ` No pending short-term items — drain complete`);
150        break;
151      }
152
153    log.verbose("Dreams", 
154        `💤 Drain pass ${pass}/${MAX_DRAIN_PASSES}: ${pendingCount} pending item(s)`,
155      );
156
157      try {
158        const drainResult = await drainTree(rootId);
159        if (drainResult?.sessionId) dreamSessionIds.push(drainResult.sessionId);
160      } catch (err) {
161    log.error("Dreams", ` Dream drain pass ${pass} failed:`, err.message);
162        break;
163      }
164    }
165
166    // ════════════════════════════════════════════════════════════════
167    // PHASE 3: UNDERSTANDING RUN
168    // ════════════════════════════════════════════════════════════════
169
170    try {
171      const understandingExt = getExtension("understanding");
172      if (!understandingExt?.exports) {
173        log.verbose("Dreams", ` Understanding extension not loaded, skipping`);
174      } else {
175        log.verbose("Dreams", ` Starting understanding run for "${rootNode.name}"`);
176
177        const run = await understandingExt.exports.findOrCreateUnderstandingRun(
178          rootId,
179          userId,
180          NAV_PERSPECTIVE,
181          true,
182        );
183
184        await understandingExt.exports.orchestrateUnderstanding({
185          rootId,
186          userId,
187          username,
188          runId: run.understandingRunId,
189          source: "background",
190        });
191
192        log.verbose("Dreams", ` Understanding run complete`);
193      }
194    } catch (err) {
195      log.error("Dreams", ` Dream understanding failed:`, err.message);
196    }
197
198    // ════════════════════════════════════════════════════════════════
199    // PHASE 4: DREAM NOTIFICATIONS
200    // ════════════════════════════════════════════════════════════════
201
202    if (dreamSessionIds.length > 0) {
203      try {
204    log.verbose("Dreams", ` Generating dream notifications for "${rootNode.name}"`);
205        await orchestrateDreamNotify({
206          rootId,
207          userId,
208          username,
209          treeName: rootNode.name,
210          dreamSessionIds,
211          source: "background",
212        });
213      } catch (err) {
214    log.error("Dreams", ` Dream notifications failed:`, err.message);
215      }
216    }
217
218    // ════════════════════════════════════════════════════════════════
219    // MARK COMPLETE
220    // ════════════════════════════════════════════════════════════════
221
222    const rootDoc = await Node.findById(rootId);
223    if (rootDoc) {
224      const dreamMeta = rootDoc.metadata?.get?.("dreams") || rootDoc.metadata?.dreams || {};
225      dreamMeta.lastDreamAt = new Date();
226      await _metadata.setExtMeta(rootDoc, "dreams", dreamMeta);
227    }
228    log.verbose("Dreams", ` Dream complete for "${rootNode.name}"`);
229  } catch (err) {
230    log.error("Dreams", ` Dream failed for "${rootNode.name}":`, err.message);
231  } finally {
232    releaseLock("dream", rootId);
233  }
234}
235
236// ─────────────────────────────────────────────────────────────────────────
237// SCHEDULER — checks which trees need to dream
238// ─────────────────────────────────────────────────────────────────────────
239
240export async function runTreeDreamJob() {
241  try {
242    // Find all root nodes with a dreamTime configured (stored in metadata.dreams.dreamTime)
243    const rootNodes = await Node.find({
244      rootOwner: { $nin: [null, "SYSTEM"] },
245      "metadata.dreams.dreamTime": { $ne: null },
246    })
247      .select("_id name rootOwner children metadata")
248      .lean();
249
250    if (rootNodes.length === 0) return;
251
252    const now = new Date();
253    const startOfDay = new Date();
254    startOfDay.setHours(0, 0, 0, 0);
255
256    const currentMinutes = now.getHours() * 60 + now.getMinutes();
257
258    for (const rootNode of rootNodes) {
259      // Parse dreamTime "HH:MM" → minutes since midnight
260      const [hours, minutes] = (rootNode.metadata?.dreams?.dreamTime || "")
261        .split(":")
262        .map(Number);
263      if (isNaN(hours) || isNaN(minutes)) {
264    log.warn("Dreams", 
265          `⚠️ Invalid dreamTime "${rootNode.metadata?.dreams?.dreamTime}" for "${rootNode.name}"`,
266        );
267        continue;
268      }
269      const dreamMinutes = hours * 60 + minutes;
270
271      // Check if it's time to dream: current time >= dreamTime AND haven't dreamed today
272      const alreadyDreamedToday =
273        rootNode.metadata?.dreams?.lastDreamAt && rootNode.metadata?.dreams?.lastDreamAt >= startOfDay;
274      if (currentMinutes >= dreamMinutes && !alreadyDreamedToday) {
275    log.verbose("Dreams", 
276          `💤 Dream time reached for "${rootNode.name}" (${rootNode.metadata?.dreams?.dreamTime})`,
277        );
278        await runTreeDream(rootNode);
279      }
280    }
281  } catch (err) {
282    log.error("Dreams", " Tree dream job error:", err.message);
283  }
284}
285
286// ─────────────────────────────────────────────────────────────────────────
287// START / STOP
288// ─────────────────────────────────────────────────────────────────────────
289
290export function startTreeDreamJob({ intervalMs = 30 * 60 * 1000 } = {}) {
291  if (jobTimer) clearInterval(jobTimer);
292  jobTimer = setInterval(runTreeDreamJob, intervalMs);
293    log.info("Dreams", `💤 Tree dream job started (checking every ${intervalMs / 1000}s)`,
294  );
295}
296
297export function stopTreeDreamJob() {
298  if (jobTimer) {
299    clearInterval(jobTimer);
300    jobTimer = null;
301    log.info("Dreams", "⏹ Tree dream job stopped");
302  }
303}
304

Versions

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

Comments

Loading comments...

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