1// Seed Export Core
2//
3// Three operations:
4// 1. exportTreeSeed: walk a tree, extract structural metadata, produce a seed file
5// 2. plantTreeSeed: read a seed file, create the node hierarchy, apply metadata
6// 3. analyzeSeed: read a seed file without planting, report requirements
7
8import log from "../../seed/log.js";
9import { getDescendantIds } from "../../seed/tree/treeFetch.js";
10import { createNode } from "../../seed/tree/treeManagement.js";
11import { getExtension } from "../loader.js";
12
13let Node = null;
14let logContribution = async () => {};
15let useEnergy = async () => ({ energyUsed: 0 });
16let _metadata = null;
17
18export function setServices({ models, contributions, energy, metadata }) {
19 Node = models.Node;
20 logContribution = contributions.logContribution;
21 if (energy?.useEnergy) useEnergy = energy.useEnergy;
22 if (metadata) _metadata = metadata;
23}
24
25// ─────────────────────────────────────────────────────────────────────────
26// STRUCTURAL METADATA WHITELIST
27// ─────────────────────────────────────────────────────────────────────────
28
29// These namespaces define behavior. They get exported.
30// Everything else is accumulated data generated through use. Excluded.
31const STRUCTURAL_NAMESPACES = new Set([
32 "cascade",
33 "extensions",
34 "tools",
35 "modes",
36 "persona",
37 "perspective",
38 "purpose",
39]);
40
41function extractStructuralMetadata(node) {
42 const meta = node.metadata instanceof Map
43 ? Object.fromEntries(node.metadata)
44 : (node.metadata || {});
45
46 const structural = {};
47 for (const ns of STRUCTURAL_NAMESPACES) {
48 if (meta[ns] && Object.keys(meta[ns]).length > 0) {
49 structural[ns] = meta[ns];
50 }
51 }
52 return structural;
53}
54
55function getReferencedExtensions(structural) {
56 const refs = new Set();
57
58 // Namespaces themselves reference their extension
59 for (const ns of Object.keys(structural)) {
60 if (ns !== "extensions" && ns !== "tools" && ns !== "modes" && ns !== "cascade") {
61 refs.add(ns);
62 }
63 }
64
65 // extensions.blocked[] and extensions.allowed[] reference extension names
66 if (structural.extensions) {
67 for (const name of structural.extensions.blocked || []) refs.add(name);
68 for (const name of structural.extensions.allowed || []) refs.add(name);
69 }
70
71 // modes values reference extension-registered modes (e.g. "tree:fitness")
72 if (structural.modes) {
73 for (const modeKey of Object.values(structural.modes)) {
74 const parts = String(modeKey).split(":");
75 if (parts.length >= 2 && parts[0] !== "tree" && parts[0] !== "home" && parts[0] !== "land") {
76 refs.add(parts[0]);
77 }
78 }
79 }
80
81 return refs;
82}
83
84// ─────────────────────────────────────────────────────────────────────────
85// EXPORT
86// ─────────────────────────────────────────────────────────────────────────
87
88const SEED_EXPORT_VERSION = "1.0.0";
89
90export async function exportTreeSeed(rootId, userId, opts = {}) {
91 await useEnergy({ userId, action: "seedExport" });
92
93 const maxNodes = opts.maxExportNodes || 5000;
94 const maxDepth = opts.maxExportDepth || 20;
95
96 // Get all node IDs in this tree via children[] walk
97 const descendantIds = await getDescendantIds(rootId, { maxResults: maxNodes });
98
99 // Fetch all nodes
100 const nodes = await Node.find({
101 _id: { $in: descendantIds },
102 systemRole: { $eq: null },
103 })
104 .select("_id name type status parent children metadata rootOwner contributors")
105 .lean();
106
107 if (nodes.length === 0) throw new Error("Tree has no nodes to export");
108
109 const nodeMap = new Map();
110 for (const n of nodes) nodeMap.set(n._id.toString(), n);
111
112 const root = nodeMap.get(rootId.toString());
113 if (!root) throw new Error("Root node not found");
114
115 // Collect all referenced extensions across the tree
116 const allExtRefs = new Set();
117
118 // Recursive tree builder
119 function buildNode(nodeId, depth) {
120 const node = nodeMap.get(nodeId.toString());
121 if (!node || depth > maxDepth) return null;
122
123 const structural = extractStructuralMetadata(node);
124 for (const ext of getReferencedExtensions(structural)) allExtRefs.add(ext);
125
126 // Delegation boundary detection
127 const isRoot = nodeId.toString() === rootId.toString();
128 const hasDelegation = !isRoot && !!node.rootOwner;
129 const hasContributors = (node.contributors || []).length > 0;
130
131 const children = (node.children || [])
132 .map(childId => buildNode(childId.toString(), depth + 1))
133 .filter(Boolean);
134
135 const exported = {
136 name: node.name,
137 type: node.type || null,
138 status: node.status || "active",
139 children,
140 };
141
142 if (Object.keys(structural).length > 0) {
143 exported.metadata = structural;
144 }
145
146 if (hasDelegation) {
147 exported.delegated = true;
148 }
149
150 if (hasContributors) {
151 exported.contributorCount = node.contributors.length;
152 }
153
154 return exported;
155 }
156
157 const tree = buildNode(rootId.toString(), 0);
158 if (!tree) throw new Error("Failed to build tree skeleton");
159
160 // Calculate stats
161 let nodeCount = 0;
162 let maxTreeDepth = 0;
163 let cascadeNodeCount = 0;
164 let personaCount = 0;
165
166 function countStats(node, depth) {
167 nodeCount++;
168 if (depth > maxTreeDepth) maxTreeDepth = depth;
169 if (node.metadata?.cascade?.enabled) cascadeNodeCount++;
170 if (node.metadata?.persona?.name) personaCount++;
171 for (const child of node.children || []) {
172 countStats(child, depth + 1);
173 }
174 }
175 countStats(tree, 0);
176
177 // Build cascade topology if requested
178 let cascadeTopology = undefined;
179 if (opts.cascade) {
180 cascadeTopology = buildCascadeTopology(nodes, nodeMap);
181 }
182
183 // Determine which extensions are structurally required vs optional
184 const installedExts = new Set();
185 try {
186 const { getLoadedExtensionNames } = await import("../loader.js");
187 for (const name of getLoadedExtensionNames()) installedExts.add(name);
188 } catch {}
189
190 const requiredExtensions = [...allExtRefs].sort();
191 const optionalExtensions = requiredExtensions.filter(e => !installedExts.has(e));
192
193 // Look up username and land info
194 let exportedBy = "unknown";
195 let sourceLand = "unknown";
196 try {
197 const user = await (await import("../../seed/models/user.js")).default
198 .findById(userId).select("username").lean();
199 if (user) exportedBy = user.username;
200 } catch {}
201 try {
202 const { getLandIdentity } = await import("../../canopy/identity.js");
203 const identity = getLandIdentity();
204 if (identity?.domain) sourceLand = identity.domain;
205 } catch {}
206
207 const seedData = {
208 seedExportVersion: SEED_EXPORT_VERSION,
209 exportedAt: new Date().toISOString(),
210 sourceLand,
211 sourceTreeName: root.name,
212 sourceTreeId: rootId.toString(),
213 exportedBy,
214 tree,
215 requiredExtensions,
216 optionalExtensions,
217 stats: {
218 nodeCount,
219 maxDepth: maxTreeDepth,
220 extensionsReferenced: allExtRefs.size,
221 personasIncluded: personaCount,
222 cascadeNodesEnabled: cascadeNodeCount,
223 },
224 };
225
226 if (cascadeTopology) {
227 seedData.cascadeTopology = cascadeTopology;
228 }
229
230 // Log contribution
231 await logContribution({
232 userId,
233 nodeId: rootId.toString(),
234 wasAi: false,
235 action: "seed-export:exported",
236 extensionData: {
237 "seed-export": {
238 nodeCount,
239 maxDepth: maxTreeDepth,
240 requiredExtensions,
241 },
242 },
243 });
244
245 log.info("SeedExport", `Exported tree "${root.name}" (${nodeCount} nodes, depth ${maxTreeDepth})`);
246
247 return seedData;
248}
249
250function buildCascadeTopology(nodes, nodeMap) {
251 const topology = [];
252 const cascadeNodes = nodes.filter(n => {
253 const meta = n.metadata instanceof Map
254 ? n.metadata.get("cascade")
255 : n.metadata?.cascade;
256 return meta?.enabled;
257 });
258
259 for (const node of cascadeNodes) {
260 const nodeName = node.name;
261 for (const childId of node.children || []) {
262 const child = nodeMap.get(childId.toString());
263 if (!child) continue;
264 const childMeta = child.metadata instanceof Map
265 ? child.metadata.get("cascade")
266 : child.metadata?.cascade;
267 if (childMeta?.enabled) {
268 topology.push({
269 from: nodeName,
270 to: child.name,
271 direction: "outbound",
272 });
273 }
274 }
275 }
276
277 return topology;
278}
279
280// ─────────────────────────────────────────────────────────────────────────
281// PLANT
282// ─────────────────────────────────────────────────────────────────────────
283
284export async function plantTreeSeed(seedData, userId, username) {
285 if (!seedData?.seedExportVersion) {
286 throw new Error("Invalid seed file: missing seedExportVersion");
287 }
288 if (!seedData.tree) {
289 throw new Error("Invalid seed file: missing tree data");
290 }
291
292 await useEnergy({ userId, action: "seedPlant" });
293
294 const warnings = [];
295
296 // Check which required extensions are installed
297 let installedExts = new Set();
298 try {
299 const { getLoadedExtensionNames } = await import("../loader.js");
300 for (const name of getLoadedExtensionNames()) installedExts.add(name);
301 } catch {}
302
303 for (const ext of seedData.requiredExtensions || []) {
304 if (!installedExts.has(ext)) {
305 warnings.push(`Extension "${ext}" is not installed. Related metadata preserved but inactive.`);
306 }
307 }
308
309 let nodeCount = 0;
310 const maxPlantNodes = 5000;
311
312 async function plantNode(nodeData, parentId, isRoot) {
313 if (nodeCount >= maxPlantNodes) {
314 warnings.push(`Node cap reached (${maxPlantNodes}). Some branches were not planted.`);
315 return null;
316 }
317
318 // Create the node
319 const newNode = await createNode({
320 name: nodeData.name,
321 parentId,
322 isRoot,
323 userId,
324 type: nodeData.type || null,
325 });
326
327 nodeCount++;
328
329 // Set status if not active (prestige trimmed branches, completed nodes)
330 if (nodeData.status && nodeData.status !== "active") {
331 await Node.updateOne({ _id: newNode._id }, { $set: { status: nodeData.status } });
332 }
333
334 // Write structural metadata
335 if (nodeData.metadata) {
336 const nodeDoc = await Node.findById(newNode._id);
337 if (nodeDoc) {
338 for (const [ns, data] of Object.entries(nodeData.metadata)) {
339 try {
340 await _metadata.setExtMeta(nodeDoc, ns, data);
341 } catch (err) {
342 log.debug("SeedExport", `Failed to write metadata namespace "${ns}": ${err.message}`);
343 }
344 }
345 }
346 }
347
348 // Recurse for children
349 for (const childData of nodeData.children || []) {
350 await plantNode(childData, newNode._id.toString(), false);
351 }
352
353 return newNode;
354 }
355
356 const rootNode = await plantNode(seedData.tree, null, true);
357 if (!rootNode) throw new Error("Failed to create root node from seed");
358
359 // Log contribution
360 await logContribution({
361 userId,
362 nodeId: rootNode._id.toString(),
363 wasAi: false,
364 action: "seed-export:planted",
365 extensionData: {
366 "seed-export": {
367 source: seedData.sourceLand,
368 sourceTree: seedData.sourceTreeName,
369 nodeCount,
370 warnings: warnings.length,
371 },
372 },
373 });
374
375 log.info("SeedExport", `Planted seed "${seedData.sourceTreeName}" from ${seedData.sourceLand} (${nodeCount} nodes, ${warnings.length} warnings)`);
376
377 return {
378 rootId: rootNode._id.toString(),
379 rootName: rootNode.name,
380 nodeCount,
381 warnings,
382 };
383}
384
385// ─────────────────────────────────────────────────────────────────────────
386// ANALYZE
387// ─────────────────────────────────────────────────────────────────────────
388
389export async function analyzeSeed(seedData) {
390 if (!seedData?.seedExportVersion) {
391 throw new Error("Invalid seed file: missing seedExportVersion");
392 }
393 if (!seedData.tree) {
394 throw new Error("Invalid seed file: missing tree data");
395 }
396
397 // Check installed extensions
398 let installedExts = new Set();
399 try {
400 const { getLoadedExtensionNames } = await import("../loader.js");
401 for (const name of getLoadedExtensionNames()) installedExts.add(name);
402 } catch {}
403
404 const required = seedData.requiredExtensions || [];
405 const missing = required.filter(e => !installedExts.has(e));
406 const installed = required.filter(e => installedExts.has(e));
407
408 // Count tree stats from seed data
409 let nodeCount = 0;
410 let maxDepth = 0;
411 let delegatedBranches = 0;
412
413 function walk(node, depth) {
414 nodeCount++;
415 if (depth > maxDepth) maxDepth = depth;
416 if (node.delegated) delegatedBranches++;
417 for (const child of node.children || []) {
418 walk(child, depth + 1);
419 }
420 }
421 walk(seedData.tree, 0);
422
423 return {
424 seedExportVersion: seedData.seedExportVersion,
425 sourceLand: seedData.sourceLand,
426 sourceTreeName: seedData.sourceTreeName,
427 exportedAt: seedData.exportedAt,
428 exportedBy: seedData.exportedBy,
429 nodeCount,
430 maxDepth,
431 delegatedBranches,
432 extensions: {
433 required,
434 installed,
435 missing,
436 },
437 ready: missing.length === 0,
438 cascadeTopology: seedData.cascadeTopology ? seedData.cascadeTopology.length + " connections" : "not included",
439 stats: seedData.stats || {},
440 };
441}
442
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setServices, exportTreeSeed, plantTreeSeed, analyzeSeed } from "./core.js";
4
5export async function init(core) {
6 setServices({
7 models: core.models,
8 contributions: core.contributions,
9 energy: core.energy || null,
10 metadata: core.metadata,
11 });
12
13 const { default: router } = await import("./routes.js");
14
15 log.info("SeedExport", "Tree seed export and plant loaded");
16
17 return {
18 router,
19 tools,
20 exports: {
21 exportTreeSeed,
22 plantTreeSeed,
23 analyzeSeed,
24 },
25 };
26}
27
1export default {
2 name: "seed-export",
3 version: "1.0.1",
4 builtFor: "seed",
5 description:
6 "Export a tree's form without its content. The skeleton: node hierarchy, cascade " +
7 "configuration, extension scoping, persona definitions, perspective filters, mode " +
8 "overrides, tool configs. Everything that defines HOW the tree works. Not the notes. " +
9 "Not the conversations. Not the contribution history. The DNA." +
10 "\n\n" +
11 "Another land imports the seed file and grows a replica with the same shape, same " +
12 "cascade topology, same scoping rules, same personas. But empty. Ready for new " +
13 "content. Knowledge transfer through structure replay, not content copy." +
14 "\n\n" +
15 "The rule: structural metadata that defines behavior is exported. Accumulated data " +
16 "metadata that was generated through use is not. If removing it breaks nothing about " +
17 "how the tree operates, it is accumulated data. If removing it changes what the AI " +
18 "can do or how signals flow, it is structural. Seven namespaces pass the filter: " +
19 "cascade, extensions, tools, modes, persona, perspective, purpose. Everything else " +
20 "is excluded: codebook dictionaries, evolution metrics, long-memory connections, " +
21 "embed vectors, compress essences, inverse-tree profiles, contradiction state, " +
22 "prune candidates, explore maps, scout history." +
23 "\n\n" +
24 "Delegation boundaries are preserved. If a branch had a delegated owner, the seed " +
25 "records that structure without carrying the actual userIds (those users do not exist " +
26 "on the target land). The new operator fills the delegation placeholders after planting." +
27 "\n\n" +
28 "Optional cascade topology export summarizes signal flow patterns: which nodes " +
29 "originated signals, which received them, through which paths. Not the actual .flow " +
30 "results. A structural summary so the planting land understands the intended " +
31 "information flow." +
32 "\n\n" +
33 "Three operations. Export walks the tree and produces a JSON seed file. Analyze reads " +
34 "a seed file without planting and reports node count, depth, required extensions, and " +
35 "which are missing on this land. Plant creates the full node hierarchy with structural " +
36 "metadata applied, rootOwner set to the planting user, and warnings for any missing " +
37 "extensions whose metadata is preserved but inactive until installed.",
38
39 needs: {
40 services: ["hooks", "contributions"],
41 models: ["Node"],
42 },
43
44 optional: {
45 services: ["energy"],
46 extensions: ["propagation"],
47 },
48
49 provides: {
50 models: {},
51 routes: "./routes.js",
52 tools: true,
53 jobs: false,
54 orchestrator: false,
55 energyActions: {
56 seedExport: { cost: 1 },
57 seedPlant: { cost: 2 },
58 },
59 sessionTypes: {},
60
61 hooks: {
62 fires: [],
63 listens: [],
64 },
65
66 cli: [
67 {
68 command: "seed [action] [args...]", scope: ["tree"],
69 description: "Tree skeleton export and import. Actions: export, plant, analyze.",
70 method: "POST",
71 endpoint: "/node/:nodeId/seed-export",
72 subcommands: {
73 export: { method: "POST", endpoint: "/node/:nodeId/seed-export", description: "Export current tree as seed file" },
74 plant: { method: "POST", endpoint: "/node/:nodeId/seed-plant", args: ["file"], description: "Plant a seed file at current position" },
75 analyze: { method: "POST", endpoint: "/seed/analyze", args: ["file"], description: "Analyze a seed file before planting" },
76 },
77 },
78 ],
79 },
80};
81
1import express from "express";
2import { sendOk, sendError, ERR } from "../../seed/protocol.js";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { exportTreeSeed, plantTreeSeed, analyzeSeed } from "./core.js";
5
6const router = express.Router();
7
8// GET /node/:nodeId/seed-export - Export subtree as a seed file
9router.get("/node/:nodeId/seed-export", authenticate, async (req, res) => {
10 try {
11 const { nodeId } = req.params;
12 const cascade = req.query.cascade === "true";
13 const seed = await exportTreeSeed(nodeId, req.userId, { cascade });
14 sendOk(res, seed);
15 } catch (err) {
16 sendError(res, 400, ERR.INVALID_INPUT, err.message);
17 }
18});
19
20// POST /seed/plant - Plant a seed file
21router.post("/seed/plant", authenticate, async (req, res) => {
22 try {
23 const seedData = req.body;
24 if (!seedData?.tree) {
25 return sendError(res, 400, ERR.INVALID_INPUT, "Request body must contain a seed file with a tree field");
26 }
27 const result = await plantTreeSeed(seedData, req.userId, req.username);
28 sendOk(res, result, 201);
29 } catch (err) {
30 sendError(res, 400, ERR.INVALID_INPUT, err.message);
31 }
32});
33
34// POST /seed/analyze - Analyze a seed file without planting
35router.post("/seed/analyze", authenticate, async (req, res) => {
36 try {
37 const seedData = req.body;
38 if (!seedData?.tree) {
39 return sendError(res, 400, ERR.INVALID_INPUT, "Request body must contain a seed file with a tree field");
40 }
41 const analysis = await analyzeSeed(seedData);
42 sendOk(res, analysis);
43 } catch (err) {
44 sendError(res, 400, ERR.INVALID_INPUT, err.message);
45 }
46});
47
48export default router;
49
1import { z } from "zod";
2import { exportTreeSeed, plantTreeSeed, analyzeSeed } from "./core.js";
3
4export default [
5 {
6 name: "seed-export",
7 description:
8 "Export the current tree as a seed file. Captures the node hierarchy and structural " +
9 "metadata (cascade, scoping, tools, modes, personas, perspectives, purpose) without " +
10 "any content. The DNA of the tree.",
11 schema: {
12 rootId: z.string().describe("Tree root to export."),
13 cascade: z.boolean().optional().default(false).describe("Include cascade topology summary."),
14 userId: z.string().describe("Injected by server. Ignore."),
15 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
16 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
17 },
18 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
19 handler: async ({ rootId, cascade, userId }) => {
20 try {
21 const seed = await exportTreeSeed(rootId, userId, { cascade });
22 return {
23 content: [{
24 type: "text",
25 text: JSON.stringify({
26 sourceTreeName: seed.sourceTreeName,
27 nodeCount: seed.stats.nodeCount,
28 maxDepth: seed.stats.maxDepth,
29 requiredExtensions: seed.requiredExtensions,
30 cascadeTopology: seed.cascadeTopology?.length || 0,
31 message: "Seed exported. Use seed-plant to plant it on another tree, or seed-analyze to inspect it.",
32 }, null, 2),
33 }],
34 };
35 } catch (err) {
36 return { content: [{ type: "text", text: `Export failed: ${err.message}` }] };
37 }
38 },
39 },
40 {
41 name: "seed-analyze",
42 description:
43 "Analyze a seed file before planting. Reports node count, depth, required extensions, " +
44 "and which are missing on this land.",
45 schema: {
46 seedJson: z.string().describe("The seed file JSON as a string."),
47 userId: z.string().describe("Injected by server. Ignore."),
48 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
49 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
50 },
51 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
52 handler: async ({ seedJson }) => {
53 try {
54 const seedData = JSON.parse(seedJson);
55 const analysis = await analyzeSeed(seedData);
56 return {
57 content: [{
58 type: "text",
59 text: JSON.stringify(analysis, null, 2),
60 }],
61 };
62 } catch (err) {
63 return { content: [{ type: "text", text: `Analysis failed: ${err.message}` }] };
64 }
65 },
66 },
67 {
68 name: "seed-plant",
69 description:
70 "Plant a seed file to create a new tree with the exported structure. " +
71 "Creates the full node hierarchy with structural metadata applied.",
72 schema: {
73 seedJson: z.string().describe("The seed file JSON as a string."),
74 userId: z.string().describe("Injected by server. Ignore."),
75 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
76 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
77 },
78 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
79 handler: async ({ seedJson, userId }) => {
80 try {
81 const User = (await import("../../seed/models/user.js")).default;
82 const user = await User.findById(userId).select("username").lean();
83 const seedData = JSON.parse(seedJson);
84 const result = await plantTreeSeed(seedData, userId, user?.username || "system");
85 return {
86 content: [{
87 type: "text",
88 text: JSON.stringify({
89 rootId: result.rootId,
90 rootName: result.rootName,
91 nodeCount: result.nodeCount,
92 warnings: result.warnings,
93 }, null, 2),
94 }],
95 };
96 } catch (err) {
97 return { content: [{ type: "text", text: `Plant failed: ${err.message}` }] };
98 }
99 },
100 },
101];
102
Loading comments...