1/**
2 * Life Core
3 *
4 * Scaffold domains. Wire channels. Hand off.
5 * Life doesn't manage extensions after setup. It plants seeds.
6 */
7
8import log from "../../seed/log.js";
9import { getExtension } from "../loader.js";
10
11// Domains that go under Health vs Learning vs Work
12const DOMAIN_GROUPS = {
13 food: "Health",
14 fitness: "Health",
15 recovery: "Health",
16 study: "Learning",
17 kb: "Work",
18 relationships: "Social",
19 finance: "Finance",
20 investor: "Finance",
21 "market-researcher": "Finance",
22};
23
24// Auto-wire channels between related domains
25const CHANNEL_MAP = [
26 ["food", "fitness"],
27 ["fitness", "recovery"],
28 ["food", "recovery"],
29 ["relationships", "recovery"],
30 ["finance", "food"],
31 ["finance", "fitness"],
32 ["investor", "finance"],
33 ["market-researcher", "investor"],
34 ["market-researcher", "finance"],
35];
36
37import { DELETED } from "../../seed/protocol.js";
38
39/**
40 * Create the Life root node for a user. No groups or domains yet.
41 */
42export async function scaffoldRoot(userId) {
43 const { createNode } = await import("../../seed/tree/treeManagement.js");
44 const { setExtMeta } = await import("../../seed/tree/extensionMetadata.js");
45 const root = await createNode({ name: "Life", isRoot: true, userId });
46 const rootId = String(root._id);
47 await setExtMeta(root, "life", { initialized: true });
48 log.info("Life", `Scaffolded Life root for user ${userId}: ${rootId}`);
49 return { rootId };
50}
51
52/**
53 * Find the Life root node for a user. Returns the ID string or null.
54 */
55export async function findLifeRoot(userId) {
56 const Node = (await import("../../seed/models/node.js")).default;
57 // Check by metadata first, then fall back to name match
58 const root = await Node.findOne({
59 rootOwner: userId,
60 parent: { $nin: [DELETED, null] },
61 "metadata.life.initialized": true,
62 }).select("_id").lean()
63 || await Node.findOne({
64 rootOwner: userId,
65 name: "Life",
66 parent: { $nin: [DELETED, null] },
67 }).select("_id").lean();
68 return root ? String(root._id) : null;
69}
70
71/**
72 * Get domain nodes under a Life root. Walks groups and their children.
73 * Returns { fitness: { id, name, ready }, food: { id, name, ready }, ... }
74 */
75export async function getDomainNodes(rootId) {
76 const Node = (await import("../../seed/models/node.js")).default;
77 const result = {};
78 const domains = ["food", "fitness", "study", "recovery", "kb", "relationships", "finance", "investor", "market-researcher"];
79
80 const children = await Node.find({ parent: rootId }).select("_id name metadata").lean();
81 for (const child of children) {
82 // Check if child itself is a domain
83 for (const d of domains) {
84 const meta = child.metadata instanceof Map ? child.metadata.get(d) : child.metadata?.[d];
85 if (meta?.initialized) {
86 result[d] = { id: String(child._id), name: child.name, ready: meta.setupPhase !== "base", treeRootId: String(rootId) };
87 }
88 }
89 // Check grandchildren (domains under group nodes)
90 const grandchildren = await Node.find({ parent: child._id }).select("_id name metadata").lean();
91 for (const gc of grandchildren) {
92 for (const d of domains) {
93 const meta = gc.metadata instanceof Map ? gc.metadata.get(d) : gc.metadata?.[d];
94 if (meta?.initialized) {
95 result[d] = { id: String(gc._id), name: gc.name, ready: meta.setupPhase !== "base", treeRootId: String(rootId) };
96 }
97 }
98 }
99 }
100 return result;
101}
102
103/**
104 * Get available domains (installed extensions that have scaffold capability).
105 */
106export function getAvailableDomains() {
107 const domains = [];
108 for (const name of ["food", "fitness", "study", "recovery", "kb", "relationships", "finance", "investor", "market-researcher"]) {
109 const ext = getExtension(name);
110 if (ext?.exports?.scaffold || ext?.exports?.isInitialized) {
111 domains.push(name);
112 }
113 }
114 return domains;
115}
116
117/**
118 * Scaffold selected domains under a single tree or as separate trees.
119 */
120export async function scaffold({ selections, singleTree, userId, username }) {
121 const { createNode } = await import("../../seed/tree/treeManagement.js");
122 const Node = (await import("../../seed/models/node.js")).default;
123 const results = [];
124
125 if (singleTree) {
126 // Find existing Life root or create one
127 let rootId = await findLifeRoot(userId);
128 let root;
129 if (rootId) {
130 root = await Node.findById(rootId);
131 // Ensure rootOwner is set (fixes manually created Life nodes)
132 if (root && !root.rootOwner) {
133 root.rootOwner = userId;
134 await root.save();
135 }
136 root = root?.toObject ? root.toObject() : root;
137 log.verbose("Life", `Found existing Life root: ${rootId}`);
138 } else {
139 root = await createNode({ name: "Life", isRoot: true, userId });
140 rootId = String(root._id);
141 const { setExtMeta } = await import("../../seed/tree/extensionMetadata.js");
142 await setExtMeta(root, "life", { initialized: true });
143 }
144
145 // Find or create group nodes
146 const groups = new Set(selections.map(s => DOMAIN_GROUPS[s]).filter(Boolean));
147 const groupNodes = {};
148
149 for (const group of groups) {
150 // Check if group already exists
151 const existing = await Node.findOne({ parent: rootId, name: group }).select("_id").lean();
152 if (existing) {
153 groupNodes[group] = String(existing._id);
154 } else {
155 const node = await createNode({ name: group, parentId: rootId, userId });
156 groupNodes[group] = String(node._id);
157 }
158 }
159
160 // Scaffold each domain under its group (skip if already exists)
161 for (const sel of selections) {
162 const group = DOMAIN_GROUPS[sel];
163 const parentId = groupNodes[group] || rootId;
164 const domainName = sel.charAt(0).toUpperCase() + sel.slice(1);
165
166 // Check if domain node already exists
167 const existing = await Node.findOne({ parent: parentId, name: domainName }).select("_id metadata").lean();
168 if (existing) {
169 const meta = existing.metadata instanceof Map ? existing.metadata.get(sel) : existing.metadata?.[sel];
170 if (meta?.initialized) {
171 log.verbose("Life", `Domain ${sel} already scaffolded, skipping`);
172 results.push({ name: sel, id: String(existing._id), status: "exists" });
173 continue;
174 }
175 }
176
177 try {
178 // Create the domain node (or use existing uninitialized one)
179 let domainId;
180 if (existing) {
181 domainId = String(existing._id);
182 } else {
183 const domainNode = await createNode({ name: domainName, parentId, userId });
184 domainId = String(domainNode._id);
185 }
186
187 // Call the extension's scaffold
188 const ext = getExtension(sel);
189 if (ext?.exports?.scaffold) {
190 await ext.exports.scaffold(domainId, userId);
191
192 // Set modes.respond so the routing index finds this node
193 const DOMAIN_MODES = {
194 food: "tree:food-coach", fitness: "tree:fitness-plan",
195 recovery: "tree:recovery-plan", study: "tree:study-coach", kb: "tree:kb-tell",
196 relationships: "tree:relationships-coach",
197 finance: "tree:finance-coach",
198 investor: "tree:investor-coach",
199 "market-researcher": "tree:market-coach",
200 };
201 if (DOMAIN_MODES[sel]) {
202 const { setNodeMode } = await import("../../seed/modes/registry.js");
203 await setNodeMode(domainId, "respond", DOMAIN_MODES[sel], userId);
204 }
205
206 results.push({ name: sel, id: domainId, status: "ok" });
207 } else {
208 results.push({ name: sel, id: domainId, status: "no-scaffold" });
209 }
210 } catch (err) {
211 log.warn("Life", `Failed to scaffold ${sel}: ${err.message}`);
212 results.push({ name: sel, status: "error", error: err.message });
213 }
214 }
215
216 // Wire channels between related domains
217 await wireChannels(selections, rootId, userId);
218
219 // Rebuild routing index so new domain nodes are immediately routable
220 try {
221 const { rebuildIndexForRoot } = await import("../tree-orchestrator/routingIndex.js");
222 await rebuildIndexForRoot(rootId);
223 } catch {}
224
225 return { rootId, type: "single", results };
226
227 } else {
228 // Separate trees
229 for (const sel of selections) {
230 try {
231 const root = await createNode({
232 name: sel.charAt(0).toUpperCase() + sel.slice(1),
233 isRoot: true,
234 userId,
235 });
236 const rootId = String(root._id);
237
238 const ext = getExtension(sel);
239 if (ext?.exports?.scaffold) {
240 await ext.exports.scaffold(rootId, userId);
241 results.push({ name: sel, rootId, status: "ok" });
242 } else {
243 results.push({ name: sel, rootId, status: "no-scaffold" });
244 }
245 } catch (err) {
246 log.warn("Life", `Failed to scaffold ${sel}: ${err.message}`);
247 results.push({ name: sel, status: "error", error: err.message });
248 }
249 }
250
251 // Wire channels between related separate trees
252 await wireChannelsSeparate(selections, results, userId);
253
254 return { type: "separate", results };
255 }
256}
257
258/**
259 * Add a domain to an existing Life tree.
260 */
261export async function addDomain({ rootId, domain, userId }) {
262 const { createNode } = await import("../../seed/tree/treeManagement.js");
263 const Node = (await import("../../seed/models/node.js")).default;
264
265 // Find the right group node
266 const group = DOMAIN_GROUPS[domain];
267 const children = await Node.find({ parent: rootId }).select("_id name").lean();
268 let groupNode = children.find(c => c.name === group);
269
270 if (!groupNode && group) {
271 const node = await createNode({ name: group, parentId: rootId, userId });
272 groupNode = node;
273 }
274
275 const parentId = groupNode ? String(groupNode._id) : rootId;
276 const domainName = domain.charAt(0).toUpperCase() + domain.slice(1);
277
278 // Check if domain already exists
279 const existing = await Node.findOne({ parent: parentId, name: domainName }).select("_id metadata").lean();
280 if (existing) {
281 const meta = existing.metadata instanceof Map ? existing.metadata.get(domain) : existing.metadata?.[domain];
282 if (meta?.initialized) {
283 return { name: domain, id: String(existing._id), status: "exists" };
284 }
285 }
286
287 let domainId;
288 if (existing) {
289 domainId = String(existing._id);
290 } else {
291 const domainNode = await createNode({ name: domainName, parentId, userId });
292 domainId = String(domainNode._id);
293 }
294
295 const ext = getExtension(domain);
296 if (ext?.exports?.scaffold) {
297 await ext.exports.scaffold(domainId, userId);
298
299 const DOMAIN_MODES = {
300 food: "tree:food-coach", fitness: "tree:fitness-plan",
301 recovery: "tree:recovery-plan", study: "tree:study-coach", kb: "tree:kb-tell",
302 };
303 if (DOMAIN_MODES[domain]) {
304 const { setNodeMode } = await import("../../seed/modes/registry.js");
305 await setNodeMode(domainId, "respond", DOMAIN_MODES[domain], userId);
306 }
307 }
308
309 // Wire any new channels
310 const existingDomains = await getInstalledDomains(rootId);
311 existingDomains.push(domain);
312 await wireChannels(existingDomains, rootId, userId);
313
314 // Ensure Life root is in user's nav list
315 try {
316 const nav = getExtension("navigation");
317 if (nav?.exports?.addRoot) await nav.exports.addRoot(userId, rootId);
318 } catch {}
319
320 return { name: domain, id: domainId, status: "ok" };
321}
322
323/**
324 * Get which domains are already scaffolded under a root.
325 */
326async function getInstalledDomains(rootId) {
327 const Node = (await import("../../seed/models/node.js")).default;
328 const installed = [];
329
330 // Walk one level of children (group nodes) and their children (domain nodes)
331 const children = await Node.find({ parent: rootId }).select("_id name metadata").lean();
332 for (const child of children) {
333 // Check if this child itself is a domain
334 for (const domain of ["food", "fitness", "study", "recovery", "kb", "relationships"]) {
335 if (child.name.toLowerCase() === domain) {
336 const meta = child.metadata instanceof Map ? child.metadata.get(domain) : child.metadata?.[domain];
337 if (meta?.initialized) installed.push(domain);
338 }
339 }
340 // Check grandchildren (under group nodes like Health, Learning)
341 const grandchildren = await Node.find({ parent: child._id }).select("name metadata").lean();
342 for (const gc of grandchildren) {
343 for (const domain of ["food", "fitness", "study", "recovery", "kb", "relationships"]) {
344 if (gc.name.toLowerCase() === domain) {
345 const meta = gc.metadata instanceof Map ? gc.metadata.get(domain) : gc.metadata?.[domain];
346 if (meta?.initialized) installed.push(domain);
347 }
348 }
349 }
350 }
351
352 return installed;
353}
354
355/**
356 * Wire channels between related domains in a single tree.
357 */
358async function wireChannels(selections, rootId, userId) {
359 const ch = getExtension("channels");
360 if (!ch?.exports?.createChannel) return;
361
362 const Node = (await import("../../seed/models/node.js")).default;
363
364 // Find domain nodes by walking the tree
365 const domainNodes = {};
366 const children = await Node.find({ parent: rootId }).select("_id name").lean();
367 for (const child of children) {
368 const name = child.name.toLowerCase();
369 if (selections.includes(name)) {
370 domainNodes[name] = String(child._id);
371 }
372 const grandchildren = await Node.find({ parent: child._id }).select("_id name").lean();
373 for (const gc of grandchildren) {
374 const gcName = gc.name.toLowerCase();
375 if (selections.includes(gcName)) {
376 domainNodes[gcName] = String(gc._id);
377 }
378 }
379 }
380
381 for (const [a, b] of CHANNEL_MAP) {
382 if (domainNodes[a] && domainNodes[b]) {
383 try {
384 await ch.exports.createChannel({
385 sourceNodeId: domainNodes[a],
386 targetNodeId: domainNodes[b],
387 channelName: `${a}-${b}`,
388 direction: "bidirectional",
389 filter: { tags: [a, b] },
390 userId,
391 });
392 log.info("Life", `Channel: ${a} <-> ${b}`);
393 } catch (err) {
394 log.verbose("Life", `Channel ${a}-${b} failed or exists: ${err.message}`);
395 }
396 }
397 }
398}
399
400/**
401 * Wire channels between separate tree roots.
402 */
403async function wireChannelsSeparate(selections, results, userId) {
404 const ch = getExtension("channels");
405 if (!ch?.exports?.createChannel) return;
406
407 const rootMap = {};
408 for (const r of results) {
409 if (r.rootId) rootMap[r.name] = r.rootId;
410 }
411
412 for (const [a, b] of CHANNEL_MAP) {
413 if (rootMap[a] && rootMap[b]) {
414 try {
415 await ch.exports.createChannel({
416 sourceNodeId: rootMap[a],
417 targetNodeId: rootMap[b],
418 channelName: `${a}-${b}`,
419 direction: "bidirectional",
420 filter: { tags: [a, b] },
421 userId,
422 });
423 log.info("Life", `Channel: ${a} <-> ${b} (cross-tree)`);
424 } catch (err) {
425 log.verbose("Life", `Channel ${a}-${b} failed or exists: ${err.message}`);
426 }
427 }
428 }
429}
430
1/**
2 * Life
3 *
4 * Scaffolding library for domain trees. Pure machinery.
5 * Sprout is the user-facing entry point. Life just builds what it's told.
6 * Operators can use `life add <domain>` as an admin shortcut.
7 */
8
9import log from "../../seed/log.js";
10import {
11 scaffoldRoot,
12 findLifeRoot,
13 getDomainNodes,
14 addDomain,
15 getAvailableDomains,
16} from "./core.js";
17
18export async function init(core) {
19 const { default: router } = await import("./routes.js");
20
21 log.info("Life", "Loaded. Scaffolding library ready.");
22
23 return {
24 router,
25 exports: {
26 scaffoldRoot,
27 findLifeRoot,
28 getDomainNodes,
29 addDomain,
30 getAvailableDomains,
31 },
32 };
33}
34
1export default {
2 name: "life",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Scaffolding library for domain trees. Creates Life roots, group nodes, " +
7 "and domain scaffolds. Pure machinery. Sprout is the user-facing entry point. " +
8 "Operators can use `life add <domain>` as an admin shortcut.",
9
10 needs: {
11 models: ["Node"],
12 services: ["hooks", "metadata"],
13 },
14
15 optional: {
16 extensions: ["food", "fitness", "study", "recovery", "kb", "channels"],
17 },
18
19 provides: {
20 models: {},
21 routes: "./routes.js",
22 tools: false,
23 jobs: false,
24
25 cli: [
26 {
27 command: "life",
28 scope: ["home"],
29 description: "Life tree management. Use: life add <domain>, life domains",
30 method: "GET",
31 endpoint: "/life/domains",
32 subcommands: {
33 add: {
34 method: "POST",
35 endpoint: "/life/add",
36 description: "Add a domain to your Life tree. e.g. life add food",
37 args: ["domain"],
38 },
39 domains: {
40 method: "GET",
41 endpoint: "/life/domains",
42 description: "List available and scaffolded domains",
43 },
44 },
45 },
46 ],
47 },
48};
49
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import log from "../../seed/log.js";
5import { getAvailableDomains, addDomain, findLifeRoot, getDomainNodes } from "./core.js";
6
7const router = express.Router();
8
9/**
10 * POST /life/add
11 * Operator shortcut: add a single domain to the user's Life tree.
12 * Body: { domain: "food" }
13 */
14router.post("/life/add", authenticate, async (req, res) => {
15 try {
16 const userId = req.userId;
17 const { domain } = req.body;
18
19 if (!domain) {
20 return sendError(res, 400, ERR.INVALID_INPUT, "domain required");
21 }
22
23 const available = new Set(getAvailableDomains());
24 const normalized = domain.toLowerCase();
25 if (!available.has(normalized)) {
26 return sendError(res, 400, ERR.INVALID_INPUT, `Unknown domain "${domain}". Available: ${[...available].join(", ")}`);
27 }
28
29 // Find Life root or create one
30 let rootId = await findLifeRoot(userId);
31 if (!rootId) {
32 const { scaffoldRoot } = await import("./core.js");
33 const result = await scaffoldRoot(userId);
34 rootId = result.rootId;
35 }
36
37 const result = await addDomain({ rootId, domain: normalized, userId });
38
39 // Rebuild routing index
40 try {
41 const { rebuildIndexForRoot } = await import("../tree-orchestrator/routingIndex.js");
42 await rebuildIndexForRoot(rootId);
43 } catch {}
44
45 sendOk(res, result);
46 } catch (err) {
47 log.error("Life", "Add domain error:", err.message);
48 sendError(res, 500, ERR.INTERNAL, err.message);
49 }
50});
51
52/**
53 * GET /life/domains
54 * List available domains (installed extensions with scaffold support).
55 */
56router.get("/life/domains", authenticate, async (req, res) => {
57 const available = getAvailableDomains();
58 const rootId = await findLifeRoot(req.userId);
59 let scaffolded = {};
60 if (rootId) {
61 scaffolded = await getDomainNodes(rootId);
62 }
63 sendOk(res, {
64 available,
65 scaffolded: Object.keys(scaffolded),
66 rootId: rootId || null,
67 });
68});
69
70export default router;
71
Loading comments...