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}">← 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
Loading comments...