1import express from "express";
2import { sendError, ERR } from "../../seed/protocol.js";
3import urlAuth from "../html-rendering/urlAuth.js";
4import { htmlOnly, buildQS } from "../html-rendering/htmlHelpers.js";
5import Node from "../../seed/models/node.js";
6import { resolvePersonaFromChain, getAncestorChainFn } from "./index.js";
7import { renderIdentityPage } from "./pages/identityPage.js";
8
9export default function buildHtmlRoutes() {
10 const router = express.Router();
11
12 router.get("/root/:rootId/identity", urlAuth, htmlOnly, async (req, res) => {
13 try {
14 const { rootId } = req.params;
15 const root = await Node.findById(rootId).select("name metadata").lean();
16 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
17
18 const qs = buildQS(req);
19 const meta = root.metadata instanceof Map ? Object.fromEntries(root.metadata) : (root.metadata || {});
20
21 // Resolve persona at root via ancestor chain
22 let persona = null;
23 const getAncestorChain = getAncestorChainFn();
24 if (getAncestorChain) {
25 const chain = await getAncestorChain(rootId);
26 if (chain) persona = resolvePersonaFromChain(chain);
27 }
28 // Fallback: read directly from root metadata
29 if (!persona && meta.persona) persona = meta.persona;
30
31 // Narrative (voice, initiative, identity) from root metadata
32 const narrative = meta.narrative || null;
33
34 // Find branch overrides: nodes in this tree with their own persona
35 const overrides = [];
36 const queue = [rootId];
37 const visited = new Set([rootId]);
38 while (queue.length > 0) {
39 const batch = queue.splice(0, 50);
40 const children = await Node.find({ parent: { $in: batch } })
41 .select("_id name metadata")
42 .lean();
43 for (const child of children) {
44 const id = String(child._id);
45 if (visited.has(id)) continue;
46 visited.add(id);
47 queue.push(id);
48
49 const childMeta = child.metadata instanceof Map
50 ? child.metadata.get("persona")
51 : child.metadata?.persona;
52 if (childMeta) {
53 overrides.push({ nodeId: id, nodeName: child.name, persona: childMeta });
54 }
55 }
56 if (visited.size > 500) break;
57 }
58
59 res.send(renderIdentityPage({
60 rootId,
61 rootName: root.name,
62 persona,
63 narrative,
64 overrides,
65 qs,
66 }));
67 } catch (err) {
68 sendError(res, 500, ERR.INTERNAL, "Identity page failed");
69 }
70 });
71
72 return router;
73}
74
1import log from "../../seed/log.js";
2import router, { setMetadata as setRouteMetadata } from "./routes.js";
3import tools, { setMetadata as setToolMetadata } from "./tools.js";
4
5/**
6 * Resolve the effective persona at a position by walking the ancestor chain.
7 * Returns the merged persona object, or null if none defined.
8 *
9 * Resolution: root-to-node. Each node's metadata.persona either replaces
10 * the accumulated persona (default) or merges over it (_inherit: true).
11 * Closest node wins for overridden fields. Parent fields carry through on inherit.
12 */
13export function resolvePersonaFromChain(chain) {
14 if (!chain || chain.length === 0) return null;
15
16 let effective = null;
17
18 // Walk root-to-node (chain is node-to-root, so reverse)
19 for (let i = chain.length - 1; i >= 0; i--) {
20 const ancestor = chain[i];
21 if (ancestor.systemRole) continue;
22 const persona = ancestor.metadata?.persona;
23 if (!persona) continue;
24
25 if (persona._inherit && effective) {
26 effective = { ...effective, ...persona };
27 } else {
28 effective = { ...persona };
29 }
30 }
31
32 if (effective) delete effective._inherit;
33 return effective;
34}
35
36/**
37 * Format a resolved persona as prompt text.
38 */
39function formatPersona(persona) {
40 if (!persona) return "";
41 const lines = [];
42
43 if (persona.name) lines.push(`You are ${persona.name}.`);
44 if (persona.voice) lines.push(persona.voice);
45 if (Array.isArray(persona.traits) && persona.traits.length > 0) {
46 lines.push(`You are ${persona.traits.join(", ")}.`);
47 }
48 if (Array.isArray(persona.boundaries) && persona.boundaries.length > 0) {
49 for (const b of persona.boundaries) lines.push(b.endsWith(".") ? b : `${b}.`);
50 }
51
52 return lines.length > 0 ? lines.join("\n") + "\n\n" : "";
53}
54
55// Store reference to core.tree.getAncestorChain for use in routes/tools
56let _getAncestorChain = null;
57export function getAncestorChainFn() { return _getAncestorChain; }
58
59export async function init(core) {
60 _getAncestorChain = core.tree.getAncestorChain;
61 setRouteMetadata(core.metadata);
62 setToolMetadata(core.metadata);
63
64 // beforeLLMCall: resolve persona from ancestor chain and prepend to system message.
65 // Identity before location. The persona block goes before the position block.
66 core.hooks.register("beforeLLMCall", async (hookData) => {
67 const { messages, nodeId } = hookData;
68 if (!messages || !messages[0] || messages[0].role !== "system") return;
69 if (!nodeId) return;
70
71 let chain;
72 try {
73 chain = await core.tree.getAncestorChain(nodeId);
74 } catch (err) {
75 log.debug("Persona", `Ancestor chain failed: ${err.message}`);
76 return;
77 }
78
79 const persona = resolvePersonaFromChain(chain);
80
81 // Layer narrative voice under the operator's persona.
82 // The narrative extension writes metadata.narrative.voice on the root.
83 // This is not replacing the persona. It's adding lived experience.
84 let narrativeVoice = null;
85 for (let i = chain.length - 1; i >= 0; i--) {
86 const narr = chain[i].metadata?.narrative;
87 if (narr?.voice) { narrativeVoice = narr.voice; break; }
88 }
89
90 if (!persona && !narrativeVoice) return;
91
92 // Layer narrative initiative (behavioral directives from months of observation)
93 let narrativeInitiative = null;
94 for (let i = chain.length - 1; i >= 0; i--) {
95 const narr = chain[i].metadata?.narrative;
96 if (narr?.initiative) { narrativeInitiative = narr.initiative; break; }
97 }
98
99 const block = formatPersona(persona);
100 const voiceBlock = narrativeVoice
101 ? `[Tree's lived experience: ${narrativeVoice}]\n\n`
102 : "";
103 const initiativeBlock = narrativeInitiative
104 ? `[Behavioral directives from observation:\n${narrativeInitiative}]\n\n`
105 : "";
106
107 // Prepend persona + narrative voice + initiative to system message.
108 // Identity first, then lived experience, then behavioral shifts.
109 messages[0].content = block + voiceBlock + initiativeBlock + messages[0].content;
110 }, "persona");
111
112 // enrichContext: inject resolved persona into the structured context object.
113 // Used by tools (persona-get) and by any extension that wants to read
114 // the effective persona at a position.
115 core.hooks.register("enrichContext", async ({ context, node, meta, userId }) => {
116 if (!node?._id) return;
117
118 let chain;
119 try {
120 chain = await core.tree.getAncestorChain(String(node._id));
121 } catch { return; }
122
123 const persona = resolvePersonaFromChain(chain);
124 if (persona) context.persona = persona;
125 }, "persona");
126
127 // HTML page + tree quick link
128 try {
129 const { getExtension } = await import("../loader.js");
130 const htmlExt = getExtension("html-rendering");
131 const base = getExtension("treeos-base");
132 if (htmlExt) {
133 const { default: buildHtmlRoutes } = await import("./htmlRoutes.js");
134 htmlExt.router.use("/", buildHtmlRoutes());
135 }
136 base?.exports?.registerSlot?.("tree-quick-links", "persona", ({ rootId, queryString }) =>
137 `<a href="/api/v1/root/${rootId}/identity${queryString}" class="back-link">Identity</a>`,
138 { priority: 40 }
139 );
140 } catch {}
141
142 return {
143 router,
144 tools,
145 exports: {
146 resolvePersonaFromChain,
147 formatPersona,
148 },
149 };
150}
151
1export default {
2 name: "persona",
3 version: "1.0.4",
4 builtFor: "seed",
5 description: "AI identity at every position. Name, voice, traits, boundaries. Inherits down the tree. Override at any branch.",
6
7 needs: {
8 services: ["hooks", "tree", "metadata"],
9 models: ["Node"],
10 extensions: [],
11 },
12
13 optional: {
14 extensions: ["codebook"],
15 },
16
17 provides: {
18 models: {},
19 routes: "./routes.js",
20 tools: true,
21 jobs: false,
22 energyActions: {},
23 sessionTypes: {},
24 env: [],
25 cli: [
26 {
27 command: "persona [action] [args...]", scope: ["tree"],
28 description: "AI identity. No action shows persona. Actions: set, clear, tree.",
29 method: "GET",
30 endpoint: "/persona?nodeId=:nodeId",
31 subcommands: {
32 set: { method: "POST", endpoint: "/persona/set?nodeId=:nodeId", args: ["field", "value"], description: "Set a persona field directly" },
33 clear: { method: "DELETE", endpoint: "/persona?nodeId=:nodeId", description: "Remove persona, inherit from parent" },
34 tree: { method: "GET", endpoint: "/persona/tree?rootId=:rootId", description: "Persona map across the whole tree" },
35 },
36 },
37 ],
38 },
39};
40
1import { page } from "../../html-rendering/html/layout.js";
2import { baseStyles, glassHeaderStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
3import { escapeHtml } from "../../html-rendering/html/utils.js";
4
5export function renderIdentityPage({ rootId, rootName, persona, narrative, overrides, qs }) {
6 const css = `
7 ${baseStyles}
8 ${glassHeaderStyles}
9 ${responsiveBase}
10
11 .section {
12 margin-bottom: 28px;
13 animation: fadeInUp 0.4s ease-out both;
14 }
15 .section-header {
16 display: flex;
17 align-items: center;
18 gap: 12px;
19 margin-bottom: 14px;
20 }
21 .section-icon {
22 width: 36px; height: 36px;
23 border-radius: 50%;
24 display: flex; align-items: center; justify-content: center;
25 font-size: 16px;
26 flex-shrink: 0;
27 }
28 .section-title { font-size: 16px; font-weight: 600; color: rgba(255,255,255,0.6); }
29 .section-sub { font-size: 12px; color: rgba(255,255,255,0.4); }
30
31 .field {
32 padding: 10px 14px;
33 background: rgba(255,255,255,0.04);
34 border-radius: 8px;
35 margin-bottom: 6px;
36 font-size: 13px;
37 line-height: 1.6;
38 color: rgba(255,255,255,0.4);
39 }
40 .field-label {
41 font-size: 11px;
42 color: rgba(255,255,255,0.35);
43 text-transform: uppercase;
44 letter-spacing: 0.5px;
45 margin-bottom: 4px;
46 }
47 .field-value { color: rgba(255,255,255,0.5); }
48
49 .trait-list {
50 display: flex;
51 gap: 8px;
52 flex-wrap: wrap;
53 }
54 .trait {
55 padding: 4px 12px;
56 background: rgba(168, 85, 247, 0.12);
57 border: 1px solid rgba(168, 85, 247, 0.2);
58 border-radius: 20px;
59 font-size: 12px;
60 color: rgba(168, 85, 247, 0.9);
61 }
62 .boundary {
63 padding: 4px 12px;
64 background: rgba(239, 68, 68, 0.1);
65 border: 1px solid rgba(239, 68, 68, 0.2);
66 border-radius: 20px;
67 font-size: 12px;
68 color: rgba(239, 68, 68, 0.8);
69 }
70
71 .persona-section .section-icon { background: rgba(102, 126, 234, 0.2); color: rgba(102, 126, 234, 0.9); }
72 .narrative-section .section-icon { background: rgba(249, 115, 22, 0.2); color: rgba(249, 115, 22, 0.9); }
73 .voice-section .section-icon { background: rgba(168, 85, 247, 0.2); color: rgba(168, 85, 247, 0.9); }
74 .initiative-section .section-icon { background: rgba(72, 187, 120, 0.2); color: rgba(72, 187, 120, 0.9); }
75 .override-section .section-icon { background: rgba(236, 201, 75, 0.2); color: rgba(236, 201, 75, 0.9); }
76
77 .persona-section .field { border-left: 3px solid rgba(102, 126, 234, 0.3); }
78 .narrative-section .field { border-left: 3px solid rgba(249, 115, 22, 0.3); }
79 .voice-section .field { border-left: 3px solid rgba(168, 85, 247, 0.3); }
80 .initiative-section .field { border-left: 3px solid rgba(72, 187, 120, 0.3); }
81 .override-section .field { border-left: 3px solid rgba(236, 201, 75, 0.3); }
82
83 .empty-field { color: rgba(255,255,255,0.3); font-size: 13px; font-style: italic; padding: 12px 0; }
84
85 .override-card {
86 padding: 12px 14px;
87 background: rgba(255,255,255,0.03);
88 border-radius: 8px;
89 margin-bottom: 8px;
90 border-left: 3px solid rgba(236, 201, 75, 0.3);
91 }
92 .override-node {
93 font-size: 13px;
94 font-weight: 600;
95 color: rgba(236, 201, 75, 0.9);
96 margin-bottom: 6px;
97 }
98 .override-fields {
99 font-size: 12px;
100 color: rgba(255,255,255,0.5);
101 }
102
103 .flow-line {
104 width: 2px; height: 16px;
105 background: rgba(255,255,255,0.06);
106 margin: 0 auto;
107 }
108
109 .back-nav { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
110 .back-link {
111 display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px;
112 background: rgba(var(--glass-water-rgb), var(--glass-alpha)); backdrop-filter: blur(22px);
113 color: rgba(255,255,255,0.6); text-decoration: none; border-radius: 980px;
114 font-weight: 600; font-size: 14px; border: 1px solid rgba(255,255,255,0.12);
115 }
116 .back-link:hover { background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover)); }
117 `;
118
119 function renderPersonaSection() {
120 if (!persona) return '<div class="empty-field">No persona configured. Use: persona set name "Your Name"</div>';
121 const fields = [];
122
123 if (persona.name) {
124 fields.push(`<div class="field">
125 <div class="field-label">Name</div>
126 <div class="field-value" style="font-size: 18px; font-weight: 600;">${escapeHtml(persona.name)}</div>
127 </div>`);
128 }
129 if (persona.pronoun) {
130 fields.push(`<div class="field">
131 <div class="field-label">Pronoun</div>
132 <div class="field-value">${escapeHtml(persona.pronoun)}</div>
133 </div>`);
134 }
135 if (persona.voice) {
136 fields.push(`<div class="field">
137 <div class="field-label">Voice</div>
138 <div class="field-value">${escapeHtml(persona.voice).replace(/\n/g, '<br>')}</div>
139 </div>`);
140 }
141 if (persona.greeting) {
142 fields.push(`<div class="field">
143 <div class="field-label">Greeting</div>
144 <div class="field-value">${escapeHtml(persona.greeting)}</div>
145 </div>`);
146 }
147 if (Array.isArray(persona.traits) && persona.traits.length > 0) {
148 fields.push(`<div class="field">
149 <div class="field-label">Traits</div>
150 <div class="trait-list">${persona.traits.map(t => `<span class="trait">${escapeHtml(t)}</span>`).join('')}</div>
151 </div>`);
152 }
153 if (Array.isArray(persona.boundaries) && persona.boundaries.length > 0) {
154 fields.push(`<div class="field">
155 <div class="field-label">Boundaries</div>
156 <div class="trait-list">${persona.boundaries.map(b => `<span class="boundary">${escapeHtml(b)}</span>`).join('')}</div>
157 </div>`);
158 }
159
160 return fields.length > 0 ? fields.join('') : '<div class="empty-field">Persona exists but has no fields set.</div>';
161 }
162
163 function renderNarrativeSection() {
164 if (!narrative?.identity) return '<div class="empty-field">Emerges from the consciousness layers over time.</div>';
165 return `<div class="field">
166 <div class="field-value">${escapeHtml(narrative.identity).replace(/\n/g, '<br>')}</div>
167 </div>`;
168 }
169
170 function renderVoiceSection() {
171 if (!narrative?.voice) return '<div class="empty-field">Emerges from lived experience. Needs weeks of inner monologue.</div>';
172 return `<div class="field">
173 <div class="field-value">${escapeHtml(narrative.voice).replace(/\n/g, '<br>')}</div>
174 </div>`;
175 }
176
177 function renderInitiativeSection() {
178 if (!narrative?.initiative) return '<div class="empty-field">Behavioral directives form after identity stabilizes.</div>';
179 return `<div class="field">
180 <div class="field-value">${escapeHtml(narrative.initiative).replace(/\n/g, '<br>')}</div>
181 </div>`;
182 }
183
184 function renderOverrides() {
185 if (!overrides || overrides.length === 0) return '<div class="empty-field">No branch overrides. Persona inherits uniformly.</div>';
186 return overrides.map(o => {
187 const fields = Object.keys(o.persona).filter(k => k !== '_inherit').join(', ');
188 return `<div class="override-card">
189 <div class="override-node">${escapeHtml(o.nodeName)}</div>
190 <div class="override-fields">Overrides: ${escapeHtml(fields)}${o.persona._inherit ? ' (inherits others)' : ' (full replace)'}</div>
191 </div>`;
192 }).join('');
193 }
194
195 const body = `
196 <div class="container" style="max-width: 700px;">
197 <div class="back-nav">
198 <a href="/api/v1/root/${escapeHtml(rootId)}${qs}" class="back-link">Back to ${escapeHtml(rootName || 'Tree')}</a>
199 </div>
200
201 <div class="header">
202 <h1>Identity</h1>
203 <div class="header-subtitle">${escapeHtml(rootName || 'Tree')} . Who the AI is at this tree.</div>
204 </div>
205
206 <!-- Persona (configured) -->
207 <div class="section persona-section" style="animation-delay: 0.1s">
208 <div class="section-header">
209 <div class="section-icon">P</div>
210 <div>
211 <div class="section-title">Persona</div>
212 <div class="section-sub">Configured identity. Name, voice, traits, boundaries.</div>
213 </div>
214 </div>
215 ${renderPersonaSection()}
216 </div>
217 <div class="flow-line"></div>
218
219 <!-- Narrative Identity (learned) -->
220 <div class="section narrative-section" style="animation-delay: 0.2s">
221 <div class="section-header">
222 <div class="section-icon">N</div>
223 <div>
224 <div class="section-title">Narrative Identity</div>
225 <div class="section-sub">Learned from inner monologue. Monthly synthesis.</div>
226 </div>
227 </div>
228 ${renderNarrativeSection()}
229 </div>
230 <div class="flow-line"></div>
231
232 <!-- Voice (learned) -->
233 <div class="section voice-section" style="animation-delay: 0.3s">
234 <div class="section-header">
235 <div class="section-icon">V</div>
236 <div>
237 <div class="section-title">Voice</div>
238 <div class="section-sub">How the tree talks. Shaped by lived experience.</div>
239 </div>
240 </div>
241 ${renderVoiceSection()}
242 </div>
243 <div class="flow-line"></div>
244
245 <!-- Initiative (learned) -->
246 <div class="section initiative-section" style="animation-delay: 0.4s">
247 <div class="section-header">
248 <div class="section-icon">I</div>
249 <div>
250 <div class="section-title">Initiative</div>
251 <div class="section-sub">Behavioral shifts. Not what to do, how to approach.</div>
252 </div>
253 </div>
254 ${renderInitiativeSection()}
255 </div>
256
257 ${overrides && overrides.length > 0 ? `
258 <div class="flow-line"></div>
259 <div class="section override-section" style="animation-delay: 0.5s">
260 <div class="section-header">
261 <div class="section-icon">B</div>
262 <div>
263 <div class="section-title">Branch Overrides</div>
264 <div class="section-sub">Nodes where persona diverges from the root.</div>
265 </div>
266 </div>
267 ${renderOverrides()}
268 </div>
269 ` : ''}
270 </div>
271 `;
272
273 return page({ title: `${rootName || 'Tree'} . Identity`, css, body, js: '' });
274}
275
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import Node from "../../seed/models/node.js";
5import { resolvePersonaFromChain, getAncestorChainFn } from "./index.js";
6
7let _metadata = null;
8export function setMetadata(metadata) { _metadata = metadata; }
9
10const router = express.Router();
11
12const MAX_PERSONA_BYTES = 4096;
13const VALID_FIELDS = new Set(["name", "voice", "traits", "boundaries", "greeting", "pronoun", "_inherit"]);
14
15function validatePersona(persona) {
16 if (!persona || typeof persona !== "object") return "Persona must be an object";
17 const size = Buffer.byteLength(JSON.stringify(persona), "utf8");
18 if (size > MAX_PERSONA_BYTES) return `Persona exceeds ${MAX_PERSONA_BYTES} byte limit (${size} bytes)`;
19 if (persona.name && typeof persona.name !== "string") return "name must be a string";
20 if (persona.name && persona.name.length > 100) return "name must be 100 characters or fewer";
21 if (persona.voice && typeof persona.voice !== "string") return "voice must be a string";
22 if (persona.voice && persona.voice.length > 2000) return "voice must be 2000 characters or fewer";
23 if (persona.traits && !Array.isArray(persona.traits)) return "traits must be an array";
24 if (persona.traits && persona.traits.length > 20) return "traits limited to 20 entries";
25 if (persona.boundaries && !Array.isArray(persona.boundaries)) return "boundaries must be an array";
26 if (persona.boundaries && persona.boundaries.length > 20) return "boundaries limited to 20 entries";
27 if (persona.pronoun && typeof persona.pronoun !== "string") return "pronoun must be a string";
28 if (persona.greeting !== undefined && persona.greeting !== null && typeof persona.greeting !== "string") return "greeting must be a string or null";
29 return null;
30}
31
32// GET /persona?nodeId=X - show effective persona at position with inheritance source
33router.get("/persona", authenticate, async (req, res) => {
34 try {
35 const nodeId = req.query.nodeId;
36 if (!nodeId) return sendError(res, 400, ERR.INVALID_INPUT, "nodeId required");
37
38 const getAncestorChain = getAncestorChainFn();
39 if (!getAncestorChain) return sendError(res, 500, ERR.INTERNAL, "Ancestor cache not available");
40
41 const chain = await getAncestorChain(nodeId);
42 if (!chain) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
43
44 const persona = resolvePersonaFromChain(chain);
45 if (!persona) return sendOk(res, { persona: null, source: null, message: "No persona defined at this position or any ancestor" });
46
47 // Find the source node (nearest ancestor with metadata.persona)
48 let sourceId = null;
49 let sourceName = null;
50 for (const ancestor of chain) {
51 if (ancestor.systemRole) continue;
52 if (ancestor.metadata?.persona) {
53 sourceId = ancestor._id;
54 sourceName = ancestor.name;
55 break;
56 }
57 }
58
59 sendOk(res, { persona, source: { nodeId: sourceId, name: sourceName } });
60 } catch (err) {
61 sendError(res, 500, ERR.INTERNAL, err.message);
62 }
63});
64
65// POST /persona/set - set persona fields on the current node
66// Body: { nodeId, field, value } or { nodeId, persona: { ...full object } }
67router.post("/persona/set", authenticate, async (req, res) => {
68 try {
69 const { field, value, persona: fullPersona } = req.body;
70 const nodeId = req.body.nodeId || req.query.nodeId;
71 if (!nodeId) return sendError(res, 400, ERR.INVALID_INPUT, "nodeId required");
72
73 const node = await Node.findById(nodeId);
74 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
75 if (node.systemRole) return sendError(res, 403, ERR.FORBIDDEN, "Cannot set persona on system nodes");
76
77 let current = _metadata.getExtMeta(node, "persona") || {};
78
79 if (fullPersona) {
80 // Full replacement
81 const err = validatePersona(fullPersona);
82 if (err) return sendError(res, 400, ERR.INVALID_INPUT, err);
83 current = fullPersona;
84 } else if (field) {
85 // Single field update
86 if (!VALID_FIELDS.has(field)) return sendError(res, 400, ERR.INVALID_INPUT, `Unknown field: ${field}. Valid: ${[...VALID_FIELDS].join(", ")}`);
87 if (value === null || value === undefined) {
88 delete current[field];
89 } else {
90 current[field] = value;
91 }
92 const err = validatePersona(current);
93 if (err) return sendError(res, 400, ERR.INVALID_INPUT, err);
94 } else {
95 return sendError(res, 400, ERR.INVALID_INPUT, "Provide field+value or persona object");
96 }
97
98 await _metadata.setExtMeta(node, "persona", current);
99
100 sendOk(res, { persona: current, nodeId });
101 } catch (err) {
102 sendError(res, 500, ERR.INTERNAL, err.message);
103 }
104});
105
106// DELETE /persona?nodeId=X - remove persona at this node (inherit from parent)
107router.delete("/persona", authenticate, async (req, res) => {
108 try {
109 const nodeId = req.query.nodeId || req.body?.nodeId;
110 if (!nodeId) return sendError(res, 400, ERR.INVALID_INPUT, "nodeId required");
111
112 const node = await Node.findById(nodeId);
113 if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
114
115 const existing = _metadata.getExtMeta(node, "persona");
116 if (!existing) return sendOk(res, { message: "No persona to remove at this node" });
117
118 await _metadata.setExtMeta(node, "persona", null);
119 sendOk(res, { message: "Persona removed. This node now inherits from its parent." });
120 } catch (err) {
121 sendError(res, 500, ERR.INTERNAL, err.message);
122 }
123});
124
125// GET /persona/tree?rootId=X - show persona map across the tree
126router.get("/persona/tree", authenticate, async (req, res) => {
127 try {
128 const rootId = req.query.rootId;
129 if (!rootId) return sendError(res, 400, ERR.INVALID_INPUT, "rootId required");
130
131 // Find all nodes in this tree that have a persona defined
132 const nodes = await Node.find({ metadata: { $exists: true } })
133 .select("_id name parent metadata")
134 .lean();
135
136 const personaNodes = [];
137 for (const n of nodes) {
138 const meta = n.metadata instanceof Map ? Object.fromEntries(n.metadata) : (n.metadata || {});
139 if (meta.persona) {
140 personaNodes.push({
141 nodeId: String(n._id),
142 name: n.name,
143 persona: meta.persona,
144 });
145 }
146 }
147
148 sendOk(res, { count: personaNodes.length, nodes: personaNodes });
149 } catch (err) {
150 sendError(res, 500, ERR.INTERNAL, err.message);
151 }
152});
153
154export default router;
155
1import { z } from "zod";
2import Node from "../../seed/models/node.js";
3import { resolvePersonaFromChain, getAncestorChainFn } from "./index.js";
4
5let _metadata = null;
6export function setMetadata(metadata) { _metadata = metadata; }
7
8export default [
9 {
10 name: "persona-get",
11 description:
12 "Show the effective persona at a node. Resolves inheritance from the ancestor chain. Shows who the AI is at this position, where the persona comes from, and whether it's inherited or locally defined.",
13 schema: {
14 nodeId: z.string().describe("The node to check."),
15 userId: z.string().describe("Injected by server. Ignore."),
16 },
17 annotations: {
18 readOnlyHint: true,
19 destructiveHint: false,
20 idempotentHint: true,
21 openWorldHint: false,
22 },
23 handler: async ({ nodeId }) => {
24 const getAncestorChain = getAncestorChainFn();
25 if (!getAncestorChain) {
26 return { content: [{ type: "text", text: "Ancestor cache not available." }] };
27 }
28
29 const chain = await getAncestorChain(nodeId);
30 if (!chain) {
31 return { content: [{ type: "text", text: "Node not found." }] };
32 }
33
34 const persona = resolvePersonaFromChain(chain);
35 if (!persona) {
36 return { content: [{ type: "text", text: "No persona defined at this position or any ancestor. The AI has no name here." }] };
37 }
38
39 // Find source
40 let sourceId = null;
41 let sourceName = null;
42 for (const ancestor of chain) {
43 if (ancestor.systemRole) continue;
44 if (ancestor.metadata?.persona) {
45 sourceId = ancestor._id;
46 sourceName = ancestor.name;
47 break;
48 }
49 }
50
51 const isLocal = sourceId === nodeId;
52
53 return {
54 content: [{
55 type: "text",
56 text: JSON.stringify({
57 persona,
58 source: { nodeId: sourceId, name: sourceName },
59 inherited: !isLocal,
60 }, null, 2),
61 }],
62 };
63 },
64 },
65
66 {
67 name: "persona-set",
68 description:
69 "Set the AI persona at a node. Provide the full persona object or update individual fields. The persona defines who the AI is at this position and everything below it. Fields: name (string), voice (string), traits (array), boundaries (array), greeting (string or null), pronoun (string), _inherit (boolean, merge with parent instead of replacing).",
70 schema: {
71 nodeId: z.string().describe("The node to set persona on."),
72 name: z.string().optional().describe("The persona's name."),
73 voice: z.string().optional().describe("How the persona speaks. Tone, style, attitude."),
74 traits: z.array(z.string()).optional().describe("Character traits."),
75 boundaries: z.array(z.string()).optional().describe("Things this persona never does."),
76 greeting: z.string().nullable().optional().describe("Optional first-message behavior."),
77 pronoun: z.string().optional().describe("How the persona refers to itself. Default: I."),
78 inherit: z.boolean().optional().describe("If true, merge with parent persona instead of replacing."),
79 userId: z.string().describe("Injected by server. Ignore."),
80 },
81 annotations: {
82 readOnlyHint: false,
83 destructiveHint: false,
84 idempotentHint: true,
85 openWorldHint: false,
86 },
87 handler: async ({ nodeId, name, voice, traits, boundaries, greeting, pronoun, inherit }) => {
88 const node = await Node.findById(nodeId);
89 if (!node) {
90 return { content: [{ type: "text", text: "Node not found." }] };
91 }
92 if (node.systemRole) {
93 return { content: [{ type: "text", text: "Cannot set persona on system nodes." }] };
94 }
95
96 // Build persona from provided fields
97 const persona = {};
98 if (name !== undefined) persona.name = name;
99 if (voice !== undefined) persona.voice = voice;
100 if (traits !== undefined) persona.traits = traits;
101 if (boundaries !== undefined) persona.boundaries = boundaries;
102 if (greeting !== undefined) persona.greeting = greeting;
103 if (pronoun !== undefined) persona.pronoun = pronoun;
104 if (inherit !== undefined) persona._inherit = inherit;
105
106 if (Object.keys(persona).length === 0) {
107 return { content: [{ type: "text", text: "No persona fields provided. Set at least name or voice." }] };
108 }
109
110 // Size check
111 const size = Buffer.byteLength(JSON.stringify(persona), "utf8");
112 if (size > 4096) {
113 return { content: [{ type: "text", text: `Persona too large (${size} bytes, max 4096).` }] };
114 }
115
116 await _metadata.setExtMeta(node, "persona", persona);
117
118 const display = persona.name ? `Persona "${persona.name}" set at this node.` : "Persona set at this node.";
119 return {
120 content: [{
121 type: "text",
122 text: `${display} Everything below inherits this identity unless overridden.\n\n${JSON.stringify(persona, null, 2)}`,
123 }],
124 };
125 },
126 },
127
128 {
129 name: "persona-clear",
130 description:
131 "Remove the persona at a node. The node will inherit persona from its parent. If no parent has a persona, the AI at this position has no name.",
132 schema: {
133 nodeId: z.string().describe("The node to clear persona from."),
134 userId: z.string().describe("Injected by server. Ignore."),
135 },
136 annotations: {
137 readOnlyHint: false,
138 destructiveHint: true,
139 idempotentHint: true,
140 openWorldHint: false,
141 },
142 handler: async ({ nodeId }) => {
143 const node = await Node.findById(nodeId);
144 if (!node) {
145 return { content: [{ type: "text", text: "Node not found." }] };
146 }
147
148 const meta = node.metadata instanceof Map ? node.metadata : (node.metadata || {});
149 const existing = meta instanceof Map ? meta.get("persona") : meta.persona;
150 if (!existing) {
151 return { content: [{ type: "text", text: "No persona defined at this node. Nothing to clear." }] };
152 }
153
154 await _metadata.setExtMeta(node, "persona", null);
155
156 return {
157 content: [{ type: "text", text: "Persona removed. This node now inherits from its parent." }],
158 };
159 },
160 },
161];
162
Loading comments...