EXTENSION for TreeOS
instructions
AI behavioral constraints at two layers. Per-node: set metadata.llm.instructions on any node, walks the ancestor chain, prepends to the system prompt. Inherits down the tree. Per-user: set instructions on the user that follow them across every tree, every position, every extension. Two scopes: 'global' applies everywhere; per-extension applies only when that extension's mode is active. Both layers stack: user instructions appear before node instructions in the system prompt, broadest scope first, narrowest last. Capture is conversational. The add-instruction tool is injected into every conversation mode. The AI calls it when the user says 'remember to...', 'I'm vegetarian', 'always use kg', 'from now on...'. The user never thinks about scoping. The AI picks the right scope from context.
v1.1.1 by TreeOS Site 0 downloads 3 files 732 lines 30.1 KB published 38d ago
treeos ext install instructions
View changelog

Manifest

Provides

  • tools
  • 6 CLI commands

Requires

  • services: hooks, tree, metadata
  • models: Node, User
SHA256: 07057ff7fe3451cef2b37eb021160b8ce4acfb72010d8627b394779a76d1ccf5

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

CLI Commands

CommandMethodDescription
instruct <text...>POSTSet AI instructions at current node
instruct-clearDELETEClear instructions at current node
instruct-showGETShow instructions at current node (including inherited)
instruct-me <text...>POSTAdd a personal instruction the AI follows everywhere
instruct-me-showGETShow all your personal instructions
instruct-me-remove <id>DELETERemove a personal instruction by id

Source Code

1/**
2 * Instructions Extension
3 *
4 * Two layers of AI behavioral constraints, both injected via beforeLLMCall:
5 *
6 *   [User Instructions]   <- user-level, follows them across every tree
7 *   [Instructions]        <- node-level, walks ancestor chain at current position
8 *   <mode prompt>         <- the actual mode's system prompt
9 *
10 * Broadest scope first, narrowest last. Same pattern as the position/time
11 * injection in modes/registry.js.
12 *
13 * User instructions are stored in user.metadata.instructions:
14 *   {
15 *     global: [{ id, text, addedAt }, ...],
16 *     byExtension: {
17 *       food: [{ id, text, addedAt }, ...],
18 *       fitness: [{ id, text, addedAt }, ...],
19 *     }
20 *   }
21 *
22 * Node instructions are stored in node.metadata.llm.instructions (string).
23 *
24 * Capture happens via the add-instruction tool, which the AI calls when the
25 * user says "remember to...", "I'm vegetarian", "always use kg", etc. The
26 * tool is injected into the converse modes, the home modes, and every
27 * domain coach mode so the AI can capture instructions from any context.
28 */
29
30import express from "express";
31import { v4 as uuidv4 } from "uuid";
32import { z } from "zod";
33import { sendOk, sendError, ERR } from "../../seed/protocol.js";
34import authenticate from "../../seed/middleware/authenticate.js";
35import User from "../../seed/models/user.js";
36import { getUserMeta, setUserMeta } from "../../seed/tree/userMetadata.js";
37import { getModeOwner } from "../../seed/tree/extensionScope.js";
38import log from "../../seed/log.js";
39
40// ─────────────────────────────────────────────────────────────────────────
41// READ helper: load both layers for a given userId + nodeId + mode
42// ─────────────────────────────────────────────────────────────────────────
43
44async function loadUserInstructions(userId) {
45  if (!userId) return { global: [], byExtension: {} };
46  const user = await User.findById(userId).select("metadata").lean();
47  if (!user) return { global: [], byExtension: {} };
48  const inst = user.metadata instanceof Map
49    ? user.metadata.get("instructions")
50    : user.metadata?.instructions;
51  return {
52    global: Array.isArray(inst?.global) ? inst.global : [],
53    byExtension: (inst?.byExtension && typeof inst.byExtension === "object") ? inst.byExtension : {},
54  };
55}
56
57// Build the [User Instructions] block for the current mode.
58function buildUserInstructionsBlock(userInst, mode) {
59  const lines = [];
60
61  for (const i of userInst.global) {
62    if (i?.text) lines.push(i.text);
63  }
64
65  if (mode) {
66    const owner = getModeOwner(mode);
67    if (owner && Array.isArray(userInst.byExtension[owner])) {
68      for (const i of userInst.byExtension[owner]) {
69        if (i?.text) lines.push(i.text);
70      }
71    }
72  }
73
74  if (lines.length === 0) return "";
75  return `[User Instructions]\n${lines.join("\n")}\n\n`;
76}
77
78export async function init(core) {
79  // ─────────────────────────────────────────────────────────────────────
80  // beforeLLMCall: stack user-level + node-level instructions
81  // ONE handler from this extension. The hook registry keys by
82  // ${hookName}:${extName}, so re-registering would replace this.
83  // ─────────────────────────────────────────────────────────────────────
84  core.hooks.register("beforeLLMCall", async (hookData) => {
85    const { messages, mode, userId, nodeId } = hookData;
86    if (!messages?.[0] || messages[0].role !== "system") return;
87
88    // ── User-level layer (broadest) ──
89    let userBlock = "";
90    if (userId) {
91      try {
92        const userInst = await loadUserInstructions(userId);
93        userBlock = buildUserInstructionsBlock(userInst, mode);
94      } catch (err) {
95        log.debug("Instructions", `Failed to load user instructions: ${err.message}`);
96      }
97    }
98
99    // ── Node-level layer (narrowest) ──
100    let nodeBlock = "";
101    if (nodeId) {
102      try {
103        const chain = await core.tree.getAncestorChain(nodeId);
104        if (chain && chain.length > 0) {
105          const lines = [];
106          // chain is current-to-root; walk root-to-current so closest wins last
107          for (let i = chain.length - 1; i >= 0; i--) {
108            const inst = chain[i].metadata?.llm?.instructions;
109            if (inst && typeof inst === "string" && inst.trim()) {
110              lines.push(inst.trim());
111            }
112          }
113          if (lines.length > 0) {
114            nodeBlock = `[Instructions]\n${lines.join("\n")}\n\n`;
115          }
116        }
117      } catch (err) {
118        log.debug("Instructions", `Failed to walk ancestor chain: ${err.message}`);
119      }
120    }
121
122    // Order: user (broadest) -> node (narrowest) -> existing system prompt.
123    // Guard against double-injection in chain steps (same session, multiple LLM calls).
124    if (userBlock || nodeBlock) {
125      const alreadyInjected = messages[0].content.startsWith("[User Instructions]") || messages[0].content.startsWith("[Instructions]");
126      if (!alreadyInjected) {
127        messages[0].content = userBlock + nodeBlock + messages[0].content;
128        log.verbose("Instructions", `beforeLLMCall injected: ${userBlock ? "[User Instructions]" : ""}${nodeBlock ? " [Node Instructions]" : ""} for mode ${mode || "?"}`);
129      }
130    }
131  }, "instructions");
132
133  // ─────────────────────────────────────────────────────────────────────
134  // MCP tools for conversational capture / read / remove
135  // ─────────────────────────────────────────────────────────────────────
136
137  const tools = [
138    {
139      name: "add-instruction",
140      description:
141        "Save a user instruction that will apply to future conversations forever. " +
142        "Call this when the user says something like 'remember to...', 'always...', " +
143        "'I'm <something>', 'never...', 'from now on...', or otherwise expresses a " +
144        "lasting preference. Pick the right scope: 'global' for things that apply " +
145        "everywhere (tone, language, identity, units), or an extension name (food, " +
146        "fitness, study, recovery, kb, finance, investor, market-researcher, " +
147        "relationships) for things specific to that domain. If you're already inside " +
148        "a domain conversation (food coach, fitness coach, etc.), prefer that " +
149        "domain's name as the scope unless the instruction is clearly cross-cutting.",
150      schema: {
151        text: z.string().describe("The instruction in second person, e.g. 'use kg for weights' or 'never suggest meat'. Be brief and direct."),
152        scope: z.string().describe("'global' for everywhere, or an extension name like 'food', 'fitness', 'study'."),
153        userId: z.string().describe("Injected by server. Ignore."),
154        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
155        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
156      },
157      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
158      handler: async ({ text, scope, userId }) => {
159        if (!userId) return { content: [{ type: "text", text: "No user context." }] };
160        if (!text || typeof text !== "string" || !text.trim()) {
161          return { content: [{ type: "text", text: "text is required." }] };
162        }
163        const cleanText = text.trim().slice(0, 500);
164        const cleanScope = (scope || "global").trim().toLowerCase();
165
166        try {
167          const user = await User.findById(userId);
168          if (!user) return { content: [{ type: "text", text: "User not found." }] };
169
170          const current = getUserMeta(user, "instructions") || {};
171          if (!Array.isArray(current.global)) current.global = [];
172          if (!current.byExtension || typeof current.byExtension !== "object") current.byExtension = {};
173
174          const entry = { id: uuidv4(), text: cleanText, addedAt: new Date().toISOString() };
175
176          if (cleanScope === "global" || cleanScope === "*") {
177            current.global.push(entry);
178          } else {
179            if (!Array.isArray(current.byExtension[cleanScope])) current.byExtension[cleanScope] = [];
180            current.byExtension[cleanScope].push(entry);
181          }
182
183          setUserMeta(user, "instructions", current);
184          await user.save();
185
186          log.info("Instructions", `Added [${cleanScope}] for user ${String(userId).slice(0, 8)}: "${cleanText.slice(0, 60)}"`);
187          return {
188            content: [{
189              type: "text",
190              text: `Saved (${cleanScope}): "${cleanText}"`,
191            }],
192          };
193        } catch (err) {
194          log.warn("Instructions", `add-instruction failed: ${err.message}`);
195          return { content: [{ type: "text", text: `Failed to save: ${err.message}` }] };
196        }
197      },
198    },
199
200    {
201      name: "list-instructions",
202      description:
203        "Show all of the user's saved personal instructions. Use this when the user " +
204        "asks 'what do you remember about me' or 'what are my instructions' or wants " +
205        "to review what's been saved.",
206      schema: {
207        userId: z.string().describe("Injected by server. Ignore."),
208        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
209        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
210      },
211      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
212      handler: async ({ userId }) => {
213        if (!userId) return { content: [{ type: "text", text: "No user context." }] };
214        try {
215          const userInst = await loadUserInstructions(userId);
216          const lines = [];
217          if (userInst.global.length > 0) {
218            lines.push("Global:");
219            for (const i of userInst.global) lines.push(`  [${i.id?.slice(0, 8) || "?"}] ${i.text}`);
220          }
221          for (const [ext, items] of Object.entries(userInst.byExtension)) {
222            if (!Array.isArray(items) || items.length === 0) continue;
223            lines.push(`${ext}:`);
224            for (const i of items) lines.push(`  [${i.id?.slice(0, 8) || "?"}] ${i.text}`);
225          }
226          if (lines.length === 0) {
227            return { content: [{ type: "text", text: "No personal instructions saved yet." }] };
228          }
229          return { content: [{ type: "text", text: lines.join("\n") }] };
230        } catch (err) {
231          return { content: [{ type: "text", text: `Failed to read instructions: ${err.message}` }] };
232        }
233      },
234    },
235
236    {
237      name: "remove-instruction",
238      description:
239        "Remove a saved personal instruction by id. Use this when the user says " +
240        "'forget that' or 'never mind that one' or asks to remove a specific " +
241        "instruction. The id is shown when listing instructions; users can refer " +
242        "to it by the short prefix (first 8 chars).",
243      schema: {
244        id: z.string().describe("The instruction id (or its first 8 chars) to remove."),
245        userId: z.string().describe("Injected by server. Ignore."),
246        chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
247        sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
248      },
249      annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
250      handler: async ({ id, userId }) => {
251        if (!userId) return { content: [{ type: "text", text: "No user context." }] };
252        if (!id) return { content: [{ type: "text", text: "id is required." }] };
253        try {
254          const user = await User.findById(userId);
255          if (!user) return { content: [{ type: "text", text: "User not found." }] };
256
257          const current = getUserMeta(user, "instructions") || {};
258          let removed = null;
259          const matches = (entryId) => entryId === id || entryId?.startsWith(id);
260
261          if (Array.isArray(current.global)) {
262            const idx = current.global.findIndex(i => matches(i.id));
263            if (idx >= 0) {
264              removed = current.global[idx];
265              current.global.splice(idx, 1);
266            }
267          }
268          if (!removed && current.byExtension) {
269            for (const [ext, items] of Object.entries(current.byExtension)) {
270              if (!Array.isArray(items)) continue;
271              const idx = items.findIndex(i => matches(i.id));
272              if (idx >= 0) {
273                removed = items[idx];
274                items.splice(idx, 1);
275                if (items.length === 0) delete current.byExtension[ext];
276                break;
277              }
278            }
279          }
280
281          if (!removed) {
282            return { content: [{ type: "text", text: `No instruction found matching "${id}".` }] };
283          }
284
285          setUserMeta(user, "instructions", current);
286          await user.save();
287
288          log.info("Instructions", `Removed for user ${String(userId).slice(0, 8)}: "${removed.text?.slice(0, 60)}"`);
289          return { content: [{ type: "text", text: `Removed: "${removed.text}"` }] };
290        } catch (err) {
291          return { content: [{ type: "text", text: `Failed to remove: ${err.message}` }] };
292        }
293      },
294    },
295  ];
296
297  // ─────────────────────────────────────────────────────────────────────
298  // Tool injection: every mode where the user might talk freely
299  // ─────────────────────────────────────────────────────────────────────
300  const modeTools = [
301    // Top-level conversation modes get all three (capture + read + remove)
302    { modeKey: "tree:converse", toolNames: ["add-instruction", "list-instructions", "remove-instruction"] },
303    { modeKey: "home:default",  toolNames: ["add-instruction", "list-instructions", "remove-instruction"] },
304    { modeKey: "home:fallback", toolNames: ["add-instruction", "list-instructions", "remove-instruction"] },
305    // Domain coaches get add only (the user can list/remove from converse/home)
306    { modeKey: "tree:food-coach",         toolNames: ["add-instruction"] },
307    { modeKey: "tree:fitness-coach",      toolNames: ["add-instruction"] },
308    { modeKey: "tree:fitness-plan",       toolNames: ["add-instruction"] },
309    { modeKey: "tree:study-coach",        toolNames: ["add-instruction"] },
310    { modeKey: "tree:study-plan",         toolNames: ["add-instruction"] },
311    { modeKey: "tree:recovery-plan",      toolNames: ["add-instruction"] },
312    { modeKey: "tree:relationships-coach", toolNames: ["add-instruction"] },
313    { modeKey: "tree:finance-coach",      toolNames: ["add-instruction"] },
314    { modeKey: "tree:investor-coach",     toolNames: ["add-instruction"] },
315    { modeKey: "tree:market-coach",       toolNames: ["add-instruction"] },
316  ];
317
318  // ─────────────────────────────────────────────────────────────────────
319  // HTTP routes
320  // ─────────────────────────────────────────────────────────────────────
321  const router = express.Router();
322
323  // ── Node-level (existing) ────────────────────────────────────────────
324
325  router.get("/node/:nodeId/instructions", authenticate, async (req, res) => {
326    try {
327      const chain = await core.tree.getAncestorChain(req.params.nodeId);
328      if (!chain) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
329
330      const layers = [];
331      for (let i = chain.length - 1; i >= 0; i--) {
332        const inst = chain[i].metadata?.llm?.instructions;
333        if (inst && typeof inst === "string" && inst.trim()) {
334          layers.push({ nodeId: chain[i]._id, name: chain[i].name, instructions: inst.trim() });
335        }
336      }
337
338      const local = chain[0]?.metadata?.llm?.instructions || null;
339      sendOk(res, { local, inherited: layers, effective: layers.map(l => l.instructions).join("\n") || null });
340    } catch (err) {
341      sendError(res, 500, ERR.INTERNAL, err.message);
342    }
343  });
344
345  router.post("/node/:nodeId/instructions", authenticate, async (req, res) => {
346    try {
347      const { nodeId } = req.params;
348      const text = req.body.instructions;
349      if (!text || typeof text !== "string" || !text.trim()) {
350        return sendError(res, 400, ERR.INVALID_INPUT, "instructions must be a non-empty string");
351      }
352
353      const node = await core.models.Node.findById(nodeId);
354      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
355
356      const llmMeta = core.metadata.getExtMeta(node, "llm");
357      llmMeta.instructions = text.trim();
358      await core.metadata.setExtMeta(node, "llm", llmMeta);
359
360      sendOk(res, { nodeId, instructions: llmMeta.instructions });
361    } catch (err) {
362      sendError(res, 500, ERR.INTERNAL, err.message);
363    }
364  });
365
366  router.delete("/node/:nodeId/instructions", authenticate, async (req, res) => {
367    try {
368      const { nodeId } = req.params;
369      const node = await core.models.Node.findById(nodeId);
370      if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
371
372      const llmMeta = core.metadata.getExtMeta(node, "llm");
373      delete llmMeta.instructions;
374      if (Object.keys(llmMeta).length > 0) {
375        await core.metadata.setExtMeta(node, "llm", llmMeta);
376      } else {
377        await core.metadata.unsetExtMeta(nodeId, "llm");
378      }
379
380      sendOk(res, { nodeId, cleared: true });
381    } catch (err) {
382      sendError(res, 500, ERR.INTERNAL, err.message);
383    }
384  });
385
386  // ── User-level (new) ─────────────────────────────────────────────────
387
388  // HTML page (must be registered BEFORE the JSON handler on the same path
389  // so ?html requests get the rendered page instead of raw JSON).
390  router.get("/user/:userId/instructions", authenticate, async (req, res, next) => {
391    if (!("html" in req.query)) return next();
392    try {
393      const { renderInstructionsPage } = await import("./pages/instructionsPage.js");
394      const { userId } = req.params;
395      const user = await User.findById(userId).select("username metadata").lean();
396      if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
397
398      const inst = user.metadata instanceof Map
399        ? user.metadata.get("instructions")
400        : user.metadata?.instructions;
401
402      res.send(renderInstructionsPage({
403        userId,
404        username: user.username,
405        instructions: inst || { global: [], byExtension: {} },
406        token: req.query.token || null,
407        inApp: !!req.query.inApp,
408      }));
409    } catch (err) {
410      log.warn("Instructions", `HTML page error: ${err.message}`);
411      sendError(res, 500, ERR.INTERNAL, "Failed to load instructions page");
412    }
413  });
414
415  // JSON API
416  router.get("/user/:userId/instructions", authenticate, async (req, res) => {
417    try {
418      if (req.userId !== req.params.userId) {
419        return sendError(res, 403, ERR.FORBIDDEN, "Not your account");
420      }
421      const userInst = await loadUserInstructions(req.params.userId);
422      sendOk(res, userInst);
423    } catch (err) {
424      sendError(res, 500, ERR.INTERNAL, err.message);
425    }
426  });
427
428  router.post("/user/:userId/instructions", authenticate, async (req, res) => {
429    try {
430      if (req.userId !== req.params.userId) {
431        return sendError(res, 403, ERR.FORBIDDEN, "Not your account");
432      }
433      const { text, scope } = req.body;
434      if (!text || typeof text !== "string" || !text.trim()) {
435        return sendError(res, 400, ERR.INVALID_INPUT, "text must be a non-empty string");
436      }
437      const cleanText = text.trim().slice(0, 500);
438      const cleanScope = ((scope || "global") + "").trim().toLowerCase();
439
440      const user = await User.findById(req.params.userId);
441      if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
442
443      const current = getUserMeta(user, "instructions") || {};
444      if (!Array.isArray(current.global)) current.global = [];
445      if (!current.byExtension || typeof current.byExtension !== "object") current.byExtension = {};
446
447      const entry = { id: uuidv4(), text: cleanText, addedAt: new Date().toISOString() };
448      if (cleanScope === "global" || cleanScope === "*") {
449        current.global.push(entry);
450      } else {
451        if (!Array.isArray(current.byExtension[cleanScope])) current.byExtension[cleanScope] = [];
452        current.byExtension[cleanScope].push(entry);
453      }
454
455      setUserMeta(user, "instructions", current);
456      await user.save();
457
458      sendOk(res, { added: entry, scope: cleanScope });
459    } catch (err) {
460      sendError(res, 500, ERR.INTERNAL, err.message);
461    }
462  });
463
464  router.delete("/user/:userId/instructions/:id", authenticate, async (req, res) => {
465    try {
466      if (req.userId !== req.params.userId) {
467        return sendError(res, 403, ERR.FORBIDDEN, "Not your account");
468      }
469      const { id } = req.params;
470      const user = await User.findById(req.params.userId);
471      if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
472
473      const current = getUserMeta(user, "instructions") || {};
474      let removed = null;
475      const matches = (entryId) => entryId === id || entryId?.startsWith(id);
476
477      if (Array.isArray(current.global)) {
478        const idx = current.global.findIndex(i => matches(i.id));
479        if (idx >= 0) {
480          removed = current.global[idx];
481          current.global.splice(idx, 1);
482        }
483      }
484      if (!removed && current.byExtension) {
485        for (const [ext, items] of Object.entries(current.byExtension)) {
486          if (!Array.isArray(items)) continue;
487          const idx = items.findIndex(i => matches(i.id));
488          if (idx >= 0) {
489            removed = items[idx];
490            items.splice(idx, 1);
491            if (items.length === 0) delete current.byExtension[ext];
492            break;
493          }
494        }
495      }
496
497      if (!removed) {
498        return sendError(res, 404, ERR.INVALID_INPUT, `No instruction found matching "${id}"`);
499      }
500
501      setUserMeta(user, "instructions", current);
502      await user.save();
503
504      sendOk(res, { removed });
505    } catch (err) {
506      sendError(res, 500, ERR.INTERNAL, err.message);
507    }
508  });
509
510  // ─────────────────────────────────────────────────────────────────────
511  // Quick-link slot on profile page (optional, depends on treeos-base)
512  // ─────────────────────────────────────────────────────────────────────
513  try {
514    const { getExtension } = await import("../loader.js");
515    const base = getExtension("treeos-base");
516    base?.exports?.registerSlot?.("user-quick-links", "instructions", ({ userId, queryString }) =>
517      `<li><a href="/api/v1/user/${userId}/instructions${queryString}">Instructions</a></li>`,
518      { priority: 55 }
519    );
520  } catch {}
521
522  log.info("Instructions", "Loaded. Two layers: per-node and per-user.");
523
524  return { router, tools, modeTools };
525}
526
1export default {
2  name: "instructions",
3  version: "1.1.1",
4  builtFor: "TreeOS",
5  description:
6    "AI behavioral constraints at two layers. Per-node: set metadata.llm.instructions " +
7    "on any node, walks the ancestor chain, prepends to the system prompt. Inherits " +
8    "down the tree. Per-user: set instructions on the user that follow them across " +
9    "every tree, every position, every extension. Two scopes: 'global' applies " +
10    "everywhere; per-extension applies only when that extension's mode is active. " +
11    "Both layers stack: user instructions appear before node instructions in the " +
12    "system prompt, broadest scope first, narrowest last.\n\n" +
13    "Capture is conversational. The add-instruction tool is injected into every " +
14    "conversation mode. The AI calls it when the user says 'remember to...', " +
15    "'I'm vegetarian', 'always use kg', 'from now on...'. The user never thinks " +
16    "about scoping. The AI picks the right scope from context.",
17
18  needs: {
19    services: ["hooks", "tree", "metadata"],
20    models: ["Node", "User"],
21  },
22
23  provides: {
24    tools: true,
25    cli: [
26      // Node-level (existing)
27      { command: "instruct <text...>", scope: ["tree"], description: "Set AI instructions at current node", method: "POST", endpoint: "/node/:nodeId/instructions", bodyMap: { instructions: 0 } },
28      { command: "instruct-clear", scope: ["tree"], description: "Clear instructions at current node", method: "DELETE", endpoint: "/node/:nodeId/instructions" },
29      { command: "instruct-show", scope: ["tree"], description: "Show instructions at current node (including inherited)", method: "GET", endpoint: "/node/:nodeId/instructions" },
30      // User-level (new)
31      { command: "instruct-me <text...>", scope: ["home", "tree"], description: "Add a personal instruction the AI follows everywhere", method: "POST", endpoint: "/user/:userId/instructions", bodyMap: { text: 0 } },
32      { command: "instruct-me-show", scope: ["home", "tree"], description: "Show all your personal instructions", method: "GET", endpoint: "/user/:userId/instructions" },
33      { command: "instruct-me-remove <id>", scope: ["home", "tree"], description: "Remove a personal instruction by id", method: "DELETE", endpoint: "/user/:userId/instructions/:id" },
34    ],
35  },
36};
37
1/**
2 * Instructions page. Shows all user-level instructions grouped by scope
3 * (global + per-extension) with remove buttons.
4 */
5
6import { page } from "../../html-rendering/html/layout.js";
7import { esc, timeAgo } from "../../html-rendering/html/utils.js";
8import { glassCardStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
9
10export function renderInstructionsPage({ userId, username, instructions, token, inApp }) {
11  const tokenParam = token ? `&token=${esc(token)}` : "";
12  const queryString = `?html${tokenParam}`;
13
14  const css = `
15    ${glassCardStyles}
16    ${responsiveBase}
17
18    .inst-container { max-width: 700px; margin: 0 auto; padding: 12px 20px 60px; }
19
20    .page-header { text-align: center; padding: 32px 20px 12px; }
21    .page-title { font-size: 1.4rem; color: #e6e8eb; margin-bottom: 6px; }
22    .page-subtitle { color: #9ba1ad; font-size: 0.85rem; }
23
24    .section-title { font-size: 1rem; font-weight: 600; color: #c4c8d0; margin: 24px 0 10px; }
25
26    .inst-card {
27      background: rgba(255,255,255,0.03);
28      border: 1px solid rgba(255,255,255,0.07);
29      border-radius: 10px;
30      padding: 12px 16px;
31      margin-bottom: 8px;
32      display: flex;
33      align-items: flex-start;
34      justify-content: space-between;
35      gap: 12px;
36    }
37    .inst-card:hover { border-color: rgba(255,255,255,0.12); }
38
39    .inst-text { color: #e6e8eb; font-size: 0.9rem; line-height: 1.5; flex: 1; }
40    .inst-meta { color: #666; font-size: 0.7rem; margin-top: 4px; }
41
42    .inst-remove {
43      background: none;
44      border: 1px solid rgba(200,100,100,0.25);
45      color: rgba(200,100,100,0.7);
46      border-radius: 6px;
47      padding: 4px 10px;
48      font-size: 0.75rem;
49      cursor: pointer;
50      flex-shrink: 0;
51      font-family: inherit;
52    }
53    .inst-remove:hover { background: rgba(200,100,100,0.1); color: #c97e6a; border-color: rgba(200,100,100,0.4); }
54
55    .inst-empty { color: #666; font-size: 0.85rem; padding: 16px 0; }
56
57    .inst-badge {
58      display: inline-block;
59      padding: 2px 8px;
60      background: rgba(123,160,116,0.12);
61      border: 1px solid rgba(123,160,116,0.25);
62      color: #7ba074;
63      border-radius: 4px;
64      font-size: 0.7rem;
65      font-weight: 500;
66      margin-right: 6px;
67    }
68
69    .back-link {
70      display: inline-block;
71      color: #9ba1ad;
72      text-decoration: none;
73      font-size: 0.85rem;
74      margin-bottom: 8px;
75    }
76    .back-link:hover { color: #e6e8eb; }
77
78    .status-msg {
79      padding: 10px 14px;
80      border-radius: 8px;
81      font-size: 0.85rem;
82      margin-bottom: 12px;
83      display: none;
84    }
85    .status-msg.success { display: block; background: rgba(123,160,116,0.12); border: 1px solid rgba(123,160,116,0.25); color: #7ba074; }
86    .status-msg.error { display: block; background: rgba(200,100,100,0.12); border: 1px solid rgba(200,100,100,0.25); color: #c97e6a; }
87  `;
88
89  const global = Array.isArray(instructions?.global) ? instructions.global : [];
90  const byExtension = (instructions?.byExtension && typeof instructions.byExtension === "object")
91    ? instructions.byExtension : {};
92  const extKeys = Object.keys(byExtension).filter(k => Array.isArray(byExtension[k]) && byExtension[k].length > 0);
93  const hasAny = global.length > 0 || extKeys.length > 0;
94
95  function renderList(items) {
96    if (!items || items.length === 0) return `<div class="inst-empty">None.</div>`;
97    return items.map(i => `
98      <div class="inst-card" data-id="${esc(i.id)}">
99        <div>
100          <div class="inst-text">${esc(i.text)}</div>
101          <div class="inst-meta">${i.addedAt ? timeAgo(new Date(i.addedAt)) : ""}</div>
102        </div>
103        <button class="inst-remove" onclick="removeInstruction('${esc(i.id)}')">remove</button>
104      </div>
105    `).join("");
106  }
107
108  const body = `
109    <div class="inst-container">
110      ${!inApp ? `<a class="back-link" href="/api/v1/user/${userId}/profile${queryString}">&larr; Profile</a>` : ""}
111      <div class="page-header">
112        <div class="page-title">Instructions</div>
113        <div class="page-subtitle">${esc(username || "")}'s personal AI customization</div>
114      </div>
115
116      <div id="statusMsg" class="status-msg"></div>
117
118      ${!hasAny ? `
119        <div class="inst-empty" style="text-align:center;padding:32px 0;">
120          No personal instructions yet. Just tell the AI something like "remember to always weigh me in kg"
121          or "I'm vegetarian" and it will save it automatically.
122        </div>
123      ` : ""}
124
125      ${global.length > 0 ? `
126        <div class="section-title"><span class="inst-badge">global</span> Everywhere</div>
127        ${renderList(global)}
128      ` : ""}
129
130      ${extKeys.map(ext => `
131        <div class="section-title"><span class="inst-badge">${esc(ext)}</span> ${esc(ext.charAt(0).toUpperCase() + ext.slice(1))} only</div>
132        ${renderList(byExtension[ext])}
133      `).join("")}
134    </div>
135  `;
136
137  const js = `
138    async function removeInstruction(id) {
139      if (!confirm("Remove this instruction?")) return;
140      try {
141        const res = await fetch("/api/v1/user/${userId}/instructions/" + id, {
142          method: "DELETE",
143          credentials: "include",
144          headers: { "Content-Type": "application/json" },
145        });
146        const data = await res.json();
147        if (data.status === "ok") {
148          const card = document.querySelector('[data-id="' + id + '"]');
149          if (card) card.remove();
150          showStatus("Removed.", "success");
151        } else {
152          showStatus((data.error && data.error.message) || "Failed to remove.", "error");
153        }
154      } catch (err) {
155        showStatus("Network error: " + err.message, "error");
156      }
157    }
158
159    function showStatus(msg, type) {
160      var el = document.getElementById("statusMsg");
161      el.textContent = msg;
162      el.className = "status-msg " + type;
163      setTimeout(function() { el.className = "status-msg"; }, 3000);
164    }
165  `;
166
167  return page({ title: "Instructions", css, body, js });
168}
169

Versions

Version Published Downloads
1.1.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star instructions

Comments

Loading comments...

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