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
Loading comments...