EXTENSION seed
persona
AI identity at every position. Name, voice, traits, boundaries. Inherits down the tree. Override at any branch.
v1.0.4 by TreeOS Site 0 downloads 6 files 857 lines 31.2 KB published 38d ago
treeos ext install persona
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • services: hooks, tree, metadata
  • models: Node

Optional

  • extensions: codebook
SHA256: 105f50d9c010386d3b7cfcaa1d34be551d21fe0eaea582d2c8488c8e5bb023dd

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

CLI Commands

CommandMethodDescription
personaGETAI identity. No action shows persona. Actions: set, clear, tree.
persona setPOSTSet a persona field directly
persona clearDELETERemove persona, inherit from parent
persona treeGETPersona map across the whole tree

Source Code

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

Versions

Version Published Downloads
1.0.4 38d ago 0
1.0.3 46d ago 0
1.0.2 47d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star persona

Comments

Loading comments...

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