EXTENSION for treeos-intelligence
inverse-tree
The AI builds a tree OF the user. Not the trees the user built. A tree the AI constructs from observing the user across every interaction on every tree on the land. Listens to afterNote, afterLLMCall, and afterToolCall. Does not store raw messages. Extracts signals. What topics does this user return to? What questions do they ask repeatedly? What tools do they use most? What time of day are they active? What trees do they spend time in versus pass through? What language patterns do they use? What do they correct the AI on, revealing actual preferences versus the AI assumptions? Maintains a hidden tree structure in user metadata. Root branches are categories the AI discovers: values, knowledge, habits, communication style, unresolved questions, recurring frustrations, goals stated versus goals acted on. Every 50 interactions, runs a compression pass. The AI reads accumulated signals and updates the model. enrichContext injects the compressed profile into every prompt. The AI at every position on every tree knows who it is talking to. Not because the mode prompt says be a fitness coach. Because the inverse tree says this user responds better to direct feedback, cares about progressive overload, gets frustrated when the AI asks clarifying questions instead of acting, and is most productive between 10pm and 2am. The user built trees. The AI built a tree of the user. Both grow. Both compress. Both inform each other.
v1.0.3 by TreeOS Site 0 downloads 7 files 968 lines 34.0 KB published 38d ago
treeos ext install inverse-tree
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • services: llm, hooks
  • models: Node, User

Optional

  • extensions: html-rendering
SHA256: fc58bcfea32a3aeb903839e8c2f95ab4c790f68f9b9cafcb85d0c43948a7218d

Dependents

1 package depend on this

PackageTypeRelationship
treeos-intelligence v1.0.2bundleincludes

CLI Commands

CommandMethodDescription
inverseGETYour profile as the AI sees it. Actions: correct, reset.
inverse correctPOSTOverride AI inference
inverse resetPOSTWipe profile, start fresh

Hooks

Listens To

  • afterNote
  • afterLLMCall
  • afterToolCall
  • afterNavigate
  • enrichContext

Source Code

1/**
2 * Inverse Tree Core
3 *
4 * Builds a model of the user from observing their behavior.
5 * Stores in user metadata["inverse-tree"]. Compressed every N interactions.
6 */
7
8import log from "../../seed/log.js";
9import User from "../../seed/models/user.js";
10import { getUserMeta, setUserMeta, batchSetUserMeta } from "../../seed/tree/userMetadata.js";
11import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
12
13let _runChat = null;
14export function setRunChat(fn) { _runChat = fn; }
15
16// ─────────────────────────────────────────────────────────────────────────
17// CONFIG
18// ─────────────────────────────────────────────────────────────────────────
19
20const DEFAULTS = {
21  compressionInterval: 200,    // interactions between compression passes
22  maxSignals: 200,             // rolling buffer cap before forced compression
23  maxProfileBytes: 8192,       // cap on the compressed profile
24  profileZones: ["home", "tree"],  // zones where profile injects. "land" excluded by default.
25};
26
27export function getInverseConfig(landConfig) {
28  return { ...DEFAULTS, ...(landConfig || {}) };
29}
30
31// ─────────────────────────────────────────────────────────────────────────
32// STATE ACCESS
33// ─────────────────────────────────────────────────────────────────────────
34
35const META_KEY = "inverse-tree";
36
37async function loadUser(userId) {
38  return User.findById(userId);
39}
40
41export function getInverseData(user) {
42  return getUserMeta(user, META_KEY);
43}
44
45function emptyState() {
46  return {
47    profile: {},
48    signals: [],
49    stats: {
50      totalInteractions: 0,
51      interactionsSinceCompression: 0,
52      lastCompressed: null,
53      activeHours: {},
54      topTrees: {},
55      topTools: {},
56    },
57    corrections: [],
58    lastUpdated: null,
59  };
60}
61
62function ensureState(data) {
63  if (!data || typeof data !== "object") return emptyState();
64  if (!data.profile) data.profile = {};
65  if (!Array.isArray(data.signals)) data.signals = [];
66  if (!data.stats) data.stats = { totalInteractions: 0, interactionsSinceCompression: 0, lastCompressed: null, activeHours: {}, topTrees: {}, topTools: {} };
67  if (!Array.isArray(data.corrections)) data.corrections = [];
68  return data;
69}
70
71async function saveState(userId, data) {
72  data.lastUpdated = new Date().toISOString();
73  await batchSetUserMeta(userId, META_KEY, data);
74}
75
76// ─────────────────────────────────────────────────────────────────────────
77// SIGNAL RECORDING
78// ─────────────────────────────────────────────────────────────────────────
79
80/**
81 * Record a signal from user behavior. Lightweight. No AI call.
82 * Returns true if compression threshold was reached.
83 */
84export async function recordSignal(userId, signal, config) {
85  const user = await loadUser(userId);
86  if (!user) return false;
87
88  const data = ensureState(getInverseData(user));
89
90  // Add signal to rolling buffer
91  data.signals.push({ ...signal, timestamp: new Date().toISOString() });
92
93  // Cap buffer
94  if (data.signals.length > config.maxSignals) {
95    data.signals = data.signals.slice(-config.maxSignals);
96  }
97
98  // Update stats
99  data.stats.totalInteractions++;
100  data.stats.interactionsSinceCompression++;
101
102  // Track active hour
103  const hour = new Date().getHours();
104  data.stats.activeHours[hour] = (data.stats.activeHours[hour] || 0) + 1;
105
106  // Track tree usage
107  if (signal.rootId) {
108    data.stats.topTrees[signal.rootId] = (data.stats.topTrees[signal.rootId] || 0) + 1;
109  }
110
111  // Track tool usage
112  if (signal.type === "tool" && signal.toolName) {
113    data.stats.topTools[signal.toolName] = (data.stats.topTools[signal.toolName] || 0) + 1;
114  }
115
116  data.lastUpdated = new Date().toISOString();
117  await batchSetUserMeta(userId, META_KEY, data);
118
119  return data.stats.interactionsSinceCompression >= config.compressionInterval;
120}
121
122// ─────────────────────────────────────────────────────────────────────────
123// COMPRESSION
124// ─────────────────────────────────────────────────────────────────────────
125
126// In-flight guard
127const _compressing = new Set();
128
129/**
130 * Run a compression pass. AI reads accumulated signals + current profile
131 * and produces an updated user model.
132 */
133export async function compress(userId) {
134  if (_compressing.has(userId)) return null;
135  _compressing.add(userId);
136
137  try {
138    if (!_runChat) return null;
139
140    const user = await loadUser(userId);
141    if (!user) return null;
142
143    const data = ensureState(getInverseData(user));
144    if (data.signals.length === 0 && Object.keys(data.profile).length === 0) return null;
145
146    // Build stats summary
147    const hourEntries = Object.entries(data.stats.activeHours).sort((a, b) => b[1] - a[1]);
148    const peakHours = hourEntries.slice(0, 3).map(([h, c]) => `${h}:00 (${c})`).join(", ");
149
150    const toolEntries = Object.entries(data.stats.topTools).sort((a, b) => b[1] - a[1]);
151    const topTools = toolEntries.slice(0, 5).map(([t, c]) => `${t} (${c})`).join(", ");
152
153    // Separate intentions from other signals for the goalsVsActions analysis
154    const intentions = data.signals.filter((s) => s.type === "intention");
155    const otherSignals = data.signals.filter((s) => s.type !== "intention");
156
157    const signalSummary = otherSignals
158      .map((s) => `[${s.type}] ${s.value || s.toolName || s.topic || JSON.stringify(s)}`)
159      .join("\n");
160
161    const intentionSummary = intentions.length > 0
162      ? `\n\nStated intentions (${intentions.length}):\n` +
163        intentions.map((s) => `- "${s.value}" (at ${s.topic}, ${s.timestamp})`).join("\n")
164      : "";
165
166    const existingProfile = Object.keys(data.profile).length > 0
167      ? `\nExisting profile (update, refine, do not discard valid observations):\n${JSON.stringify(data.profile, null, 2)}`
168      : "";
169
170    const corrections = data.corrections.length > 0
171      ? `\nUser corrections (these are ground truth, override inferences):\n${data.corrections.map((c) => `- "${c.text}"`).join("\n")}`
172      : "";
173
174    const prompt =
175      `You are building a behavioral model of a user from observed signals.\n\n` +
176      `Username: ${user.username}\n` +
177      `Total interactions: ${data.stats.totalInteractions}\n` +
178      `Peak activity hours: ${peakHours || "unknown"}\n` +
179      `Most used tools: ${topTools || "none"}\n` +
180      existingProfile +
181      corrections +
182      `\n\nRecent activity signals (${otherSignals.length}):\n${signalSummary}` +
183      intentionSummary +
184      `\n\nUpdate the user profile. Return JSON with these category keys (add only categories you have evidence for):\n` +
185      `{\n` +
186      `  "values": "what this user cares about, stated and inferred",\n` +
187      `  "knowledge": "domains of expertise and learning edges",\n` +
188      `  "habits": "behavioral patterns, when/how they work",\n` +
189      `  "communicationStyle": "how they prefer to interact with AI",\n` +
190      `  "unresolvedQuestions": "topics they keep returning to without resolution",\n` +
191      `  "recurringFrustrations": "what triggers negative reactions",\n` +
192      `  "goalsVsActions": "stated intentions versus observed behavior"\n` +
193      `}\n\n` +
194      `Each value should be a concise string (1-3 sentences). Only include categories with evidence.\n` +
195      `User corrections are ground truth. If the user said "I prefer direct feedback", that overrides ` +
196      `any inference about communication style.\n\n` +
197      `For goalsVsActions: compare the stated intentions above against the actual activity signals. ` +
198      `If the user said "I will start running three times a week" but the activity shows no fitness-related ` +
199      `notes or tool usage in the weeks since, that is an action gap. If they followed through, note that too. ` +
200      `Be specific about which intentions have evidence of follow-through and which do not.`;
201
202    const { answer } = await _runChat({
203      userId,
204      username: user.username,
205      message: prompt,
206      mode: "home:default",
207      slot: "inverseTree",
208    });
209
210    if (!answer) return null;
211
212    const newProfile = parseJsonSafe(answer);
213    if (!newProfile || typeof newProfile !== "object" || Array.isArray(newProfile)) return null;
214
215    // Cap profile size
216    const profileStr = JSON.stringify(newProfile);
217    if (Buffer.byteLength(profileStr, "utf8") > 8192) {
218      // Trim to fit: keep only the categories with the most content
219      const entries = Object.entries(newProfile).sort((a, b) =>
220        String(b[1]).length - String(a[1]).length,
221      );
222      while (entries.length > 0 && Buffer.byteLength(JSON.stringify(Object.fromEntries(entries)), "utf8") > 8192) {
223        entries.pop();
224      }
225    }
226
227    // Update state
228    data.profile = newProfile;
229    data.signals = [];  // Clear buffer after compression
230    data.stats.interactionsSinceCompression = 0;
231    data.stats.lastCompressed = new Date().toISOString();
232
233    data.lastUpdated = new Date().toISOString();
234    await batchSetUserMeta(userId, META_KEY, data);
235
236    log.verbose("InverseTree", `Compressed profile for ${user.username}: ${Object.keys(newProfile).length} categories`);
237    return newProfile;
238  } catch (err) {
239    log.error("InverseTree", `Compression failed for ${userId}: ${err.message}`);
240    return null;
241  } finally {
242    _compressing.delete(userId);
243  }
244}
245
246// ─────────────────────────────────────────────────────────────────────────
247// USER CORRECTIONS
248// ─────────────────────────────────────────────────────────────────────────
249
250export async function addCorrection(userId, text) {
251  const user = await loadUser(userId);
252  if (!user) throw new Error("User not found");
253  const data = ensureState(getInverseData(user));
254  data.corrections.push({ text, timestamp: new Date().toISOString() });
255  // Cap corrections
256  if (data.corrections.length > 50) data.corrections = data.corrections.slice(-50);
257  data.lastUpdated = new Date().toISOString();
258  await batchSetUserMeta(userId, META_KEY, data);
259  return data.corrections;
260}
261
262// ─────────────────────────────────────────────────────────────────────────
263// PROFILE ACCESS
264// ─────────────────────────────────────────────────────────────────────────
265
266export async function getProfile(userId) {
267  const user = await User.findById(userId).lean();
268  if (!user) return null;
269  const meta = user.metadata instanceof Map
270    ? user.metadata.get(META_KEY) || {}
271    : user.metadata?.[META_KEY] || {};
272  return {
273    profile: meta.profile || {},
274    stats: meta.stats || {},
275    corrections: meta.corrections || [],
276    lastUpdated: meta.lastUpdated,
277  };
278}
279
280export async function resetProfile(userId) {
281  await batchSetUserMeta(userId, META_KEY, emptyState());
282}
283
1import express from "express";
2import { sendError, ERR } from "../../seed/protocol.js";
3import urlAuth from "../html-rendering/urlAuth.js";
4import { htmlOnly } from "../html-rendering/htmlHelpers.js";
5import { getProfile } from "./core.js";
6import { renderInverseProfile } from "./pages/profile.js";
7
8export default function buildHtmlRoutes() {
9  const router = express.Router();
10
11  router.get("/user/:userId/inverse", urlAuth, htmlOnly, async (req, res) => {
12    try {
13      const userId = req.params.userId;
14      if (!req.userId || String(req.userId) !== String(userId)) {
15        return sendError(res, 403, ERR.FORBIDDEN, "Can only view your own inverse profile");
16      }
17
18      const data = await getProfile(userId);
19      const token = req.query.token ? `token=${encodeURIComponent(req.query.token)}` : "";
20      const qs = token ? `?${token}&html` : "?html";
21
22      res.send(renderInverseProfile({
23        userId,
24        username: req.username || "unknown",
25        profile: data?.profile || {},
26        stats: data?.stats || {},
27        corrections: data?.corrections || [],
28        lastUpdated: data?.lastUpdated || null,
29        queryString: qs,
30      }));
31    } catch (err) {
32      sendError(res, 500, ERR.INTERNAL, "Inverse profile page failed");
33    }
34  });
35
36  return router;
37}
38
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setRunChat, recordSignal, compress, getInverseConfig, getInverseData, getProfile } from "./core.js";
4import { SYSTEM_ROLE } from "../../seed/protocol.js";
5import Node from "../../seed/models/node.js";
6import { getLandConfigValue } from "../../seed/landConfig.js";
7
8export async function init(core) {
9  const BG = core.llm.LLM_PRIORITY.BACKGROUND;
10
11  core.llm.registerRootLlmSlot?.("inverseTree");
12
13  setRunChat(async (opts) => {
14    if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
15    return core.llm.runChat({ ...opts, llmPriority: BG });
16  });
17
18  // Read config from .config metadata
19  const configNode = await Node.findOne({ systemRole: SYSTEM_ROLE.CONFIG }).select("metadata").lean();
20  const rawConfig = configNode?.metadata instanceof Map
21    ? configNode.metadata.get("inverse-tree") || {}
22    : configNode?.metadata?.["inverse-tree"] || {};
23  const config = getInverseConfig(rawConfig);
24
25  // ── afterNote: extract topic signals + detect intentions ─────────────
26  const INTENTION_PATTERNS = [
27    /\bi will\b/i, /\bi'm going to\b/i, /\bstarting tomorrow\b/i,
28    /\bmy goal is\b/i, /\bi plan to\b/i, /\bi want to\b/i,
29    /\bi need to\b/i, /\bi should\b/i, /\bgoing to start\b/i,
30    /\bcommit to\b/i, /\baiming for\b/i, /\btarget(ing)?\b/i,
31  ];
32
33  core.hooks.register("afterNote", async ({ note, nodeId, userId, contentType, action }) => {
34    if (contentType !== "text") return;
35    if (action !== "create") return;
36    if (!userId || userId === "SYSTEM") return;
37
38    // Don't track notes on system nodes
39    try {
40      const node = await Node.findById(nodeId).select("systemRole name").lean();
41      if (node?.systemRole) return;
42
43      const content = note.content || "";
44
45      // Check for future-tense commitments
46      const isIntention = INTENTION_PATTERNS.some((p) => p.test(content));
47
48      const signal = isIntention
49        ? { type: "intention", topic: node?.name || nodeId, value: content.slice(0, 200), rootId: null }
50        : { type: "note", topic: node?.name || nodeId, rootId: null };
51
52      const shouldCompress = await recordSignal(userId, signal, config);
53
54      if (shouldCompress) {
55        compress(userId).catch((err) =>
56          log.debug("InverseTree", `Background compression failed: ${err.message}`),
57        );
58      }
59    } catch (err) {
60      log.debug("InverseTree", "afterNote signal recording failed:", err.message);
61    }
62  }, "inverse-tree");
63
64  // ── afterLLMCall: track activity patterns ──────────────────────────
65  core.hooks.register("afterLLMCall", async ({ userId, rootId, mode, model, usage }) => {
66    if (!userId || userId === "SYSTEM") return;
67
68    try {
69      const shouldCompress = await recordSignal(userId, {
70        type: "llm",
71        mode: mode || "unknown",
72        rootId: rootId || null,
73        tokens: usage?.total_tokens || 0,
74      }, config);
75
76      if (shouldCompress) {
77        compress(userId).catch((err) =>
78          log.debug("InverseTree", `Background compression failed: ${err.message}`),
79        );
80      }
81    } catch (err) {
82      log.debug("InverseTree", "afterLLMCall signal recording failed:", err.message);
83    }
84  }, "inverse-tree");
85
86  // ── afterToolCall: track tool usage patterns ───────────────────────
87  core.hooks.register("afterToolCall", async ({ toolName, args, success, userId, rootId, mode }) => {
88    if (!userId || userId === "SYSTEM") return;
89
90    try {
91      const shouldCompress = await recordSignal(userId, {
92        type: "tool",
93        toolName,
94        success,
95        rootId: rootId || null,
96      }, config);
97
98      if (shouldCompress) {
99        compress(userId).catch((err) =>
100          log.debug("InverseTree", `Background compression failed: ${err.message}`),
101        );
102      }
103    } catch (err) {
104      log.debug("InverseTree", "afterToolCall signal recording failed:", err.message);
105    }
106  }, "inverse-tree");
107
108  // ── afterNavigate: track spatial patterns ─────────────────────────────
109  // Which trees the user spends time in vs passes through. Which branches
110  // they visit repeatedly. This is the data that lets the profile say
111  // "tabor is most active 10pm-2am in /Health/Fitness" rather than just
112  // "tabor writes notes about fitness."
113  core.hooks.register("afterNavigate", async ({ userId, rootId, nodeId }) => {
114    if (!userId || userId === "SYSTEM") return;
115    if (!rootId) return;
116
117    try {
118      const shouldCompress = await recordSignal(userId, {
119        type: "navigation",
120        rootId,
121        nodeId: nodeId || rootId,
122      }, config);
123
124      if (shouldCompress) {
125        compress(userId).catch((err) =>
126          log.debug("InverseTree", `Background compression failed: ${err.message}`),
127        );
128      }
129    } catch (err) {
130      log.debug("InverseTree", "afterNavigate signal recording failed:", err.message);
131    }
132  }, "inverse-tree");
133
134  // ── enrichContext: inject profile scoped by zone ─────────────────────
135  // Home and tree zones get the profile. Land zone does not. A land-manager
136  // AI diagnosing federation issues doesn't need to know the user works out
137  // on weekends. profileZones config controls this. Default: ["home", "tree"].
138  const profileZones = config.profileZones || ["home", "tree"];
139
140  core.hooks.register("enrichContext", async ({ context, node, meta, userId }) => {
141    if (!userId) return;
142
143    // Determine zone from node: system nodes = land zone, rootOwner = tree zone root,
144    // no rootOwner + no systemRole = somewhere in a tree or home
145    const isLandZone = !!node.systemRole;
146    const isTreeZone = !node.systemRole && (!!node.rootOwner || !!node.parent);
147    const isHomeZone = !node.systemRole && !node.parent; // orphan = home context
148
149    let zone = "tree"; // default
150    if (isLandZone) zone = "land";
151    else if (isHomeZone) zone = "home";
152
153    if (!profileZones.includes(zone)) return;
154
155    try {
156      const User = core.models.User;
157      const user = await User.findById(userId).select("metadata").lean();
158      if (!user) return;
159
160      const inverseData = user.metadata instanceof Map
161        ? user.metadata.get("inverse-tree") || {}
162        : user.metadata?.["inverse-tree"] || {};
163
164      const profile = inverseData.profile;
165      if (profile && Object.keys(profile).length > 0) {
166        context.userProfile = profile;
167      }
168    } catch (err) {
169      log.debug("InverseTree", "enrichContext profile lookup failed:", err.message);
170    }
171  }, "inverse-tree");
172
173  const { default: router } = await import("./routes.js");
174
175  // Register HTML page with html-rendering
176  try {
177    const { getExtension } = await import("../loader.js");
178    const htmlExt = getExtension("html-rendering");
179    if (htmlExt) {
180      const { default: buildHtmlRoutes } = await import("./htmlRoutes.js");
181      htmlExt.router.use("/", buildHtmlRoutes());
182    }
183  } catch {}
184
185  return {
186    router,
187    tools,
188    exports: {
189      compress,
190      recordSignal,
191      getProfile,
192    },
193  };
194}
195
1export default {
2  name: "inverse-tree",
3  version: "1.0.3",
4  builtFor: "treeos-intelligence",
5  description:
6    "The AI builds a tree OF the user. Not the trees the user built. A tree the AI constructs " +
7    "from observing the user across every interaction on every tree on the land. Listens to " +
8    "afterNote, afterLLMCall, and afterToolCall. Does not store raw messages. Extracts signals. " +
9    "What topics does this user return to? What questions do they ask repeatedly? What tools do " +
10    "they use most? What time of day are they active? What trees do they spend time in versus " +
11    "pass through? What language patterns do they use? What do they correct the AI on, revealing " +
12    "actual preferences versus the AI assumptions? Maintains a hidden tree structure in user " +
13    "metadata. Root branches are categories the AI discovers: values, knowledge, habits, " +
14    "communication style, unresolved questions, recurring frustrations, goals stated versus goals " +
15    "acted on. Every 50 interactions, runs a compression pass. The AI reads accumulated signals " +
16    "and updates the model. enrichContext injects the compressed profile into every prompt. The " +
17    "AI at every position on every tree knows who it is talking to. Not because the mode prompt " +
18    "says be a fitness coach. Because the inverse tree says this user responds better to direct " +
19    "feedback, cares about progressive overload, gets frustrated when the AI asks clarifying " +
20    "questions instead of acting, and is most productive between 10pm and 2am. The user built " +
21    "trees. The AI built a tree of the user. Both grow. Both compress. Both inform each other.",
22
23  needs: {
24    services: ["llm", "hooks"],
25    models: ["Node", "User"],
26  },
27
28  optional: {
29    extensions: ["html-rendering"],
30  },
31
32  provides: {
33    models: {},
34    routes: "./routes.js",
35    tools: true,
36    jobs: false,
37    orchestrator: false,
38    energyActions: {},
39    sessionTypes: {},
40    env: [],
41
42    cli: [
43      {
44        command: "inverse [action] [args...]", scope: ["tree"],
45        description: "Your profile as the AI sees it. Actions: correct, reset.",
46        method: "GET",
47        endpoint: "/user/:userId/inverse",
48        subcommands: {
49          "correct": { method: "POST", endpoint: "/user/:userId/inverse/correct", args: ["text"], description: "Override AI inference" },
50          "reset": { method: "POST", endpoint: "/user/:userId/inverse/reset", description: "Wipe profile, start fresh" },
51        },
52      },
53    ],
54
55    hooks: {
56      fires: [],
57      listens: ["afterNote", "afterLLMCall", "afterToolCall", "afterNavigate", "enrichContext"],
58    },
59  },
60};
61
1import { page } from "../../html-rendering/html/layout.js";
2import { escapeHtml } from "../../html-rendering/html/utils.js";
3import { glassCardStyles, glassHeaderStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
4
5export function renderInverseProfile({ userId, username, profile, stats, corrections, lastUpdated, queryString }) {
6  const safeUsername = escapeHtml(username);
7
8  const categories = Object.entries(profile || {});
9  const hasProfile = categories.length > 0;
10
11  const categoryIcons = {
12    values: "\u2764\uFE0F",
13    knowledge: "\uD83D\uDCDA",
14    habits: "\uD83D\uDD04",
15    communicationStyle: "\uD83D\uDCAC",
16    unresolvedQuestions: "\u2753",
17    recurringFrustrations: "\u26A1",
18    goalsVsActions: "\uD83C\uDFAF",
19  };
20
21  const categoryLabels = {
22    values: "Values",
23    knowledge: "Knowledge",
24    habits: "Habits",
25    communicationStyle: "Communication Style",
26    unresolvedQuestions: "Unresolved Questions",
27    recurringFrustrations: "Recurring Frustrations",
28    goalsVsActions: "Goals vs Actions",
29  };
30
31  // Peak hours
32  const hours = Object.entries(stats?.activeHours || {}).sort((a, b) => b[1] - a[1]);
33  const peakHours = hours.slice(0, 3).map(([h]) => {
34    const hr = parseInt(h);
35    const ampm = hr >= 12 ? "pm" : "am";
36    const display = hr === 0 ? 12 : hr > 12 ? hr - 12 : hr;
37    return `${display}${ampm}`;
38  });
39
40  // Top tools
41  const tools = Object.entries(stats?.topTools || {}).sort((a, b) => b[1] - a[1]).slice(0, 5);
42
43  const css = `
44    ${responsiveBase}
45    ${glassCardStyles}
46    ${glassHeaderStyles}
47    html { overflow-y: auto; height: 100%; }
48
49    .inverse-header {
50      text-align: center;
51      padding: 40px 24px;
52      animation: fadeInUp 0.6s ease-out both;
53    }
54
55    .inverse-header h1 {
56      font-size: 34px;
57      font-weight: 700;
58      color: white;
59      margin: 0 0 8px;
60    }
61
62    .inverse-header .sub {
63      color: rgba(255,255,255,0.5);
64      font-size: 17px;
65    }
66
67    .inverse-header .back-link {
68      display: inline-block;
69      margin-top: 12px;
70      color: rgba(255,255,255,0.4);
71      text-decoration: none;
72      font-size: 15px;
73    }
74
75    .inverse-header .back-link:hover {
76      color: rgba(255,255,255,0.7);
77    }
78
79    .stats-row {
80      display: flex;
81      gap: 12px;
82      flex-wrap: wrap;
83      justify-content: center;
84      margin-bottom: 24px;
85      animation: fadeInUp 0.6s ease-out 0.1s both;
86    }
87
88    .stat-pill {
89      padding: 10px 18px;
90      background: rgba(255,255,255,0.08);
91      border-radius: 980px;
92      font-size: 16px;
93      color: rgba(255,255,255,0.7);
94      border: 1px solid rgba(255,255,255,0.1);
95    }
96
97    .stat-pill strong {
98      color: white;
99    }
100
101    .category-card {
102      background: rgba(255,255,255,0.06);
103      border-radius: 14px;
104      padding: 24px;
105      margin-bottom: 16px;
106      border: 1px solid rgba(255,255,255,0.08);
107      animation: fadeInUp 0.5s ease-out both;
108    }
109
110    .category-card h3 {
111      font-size: 19px;
112      font-weight: 600;
113      color: rgba(255,255,255,0.8);
114      margin: 0 0 10px;
115    }
116
117    .category-card p {
118      font-size: 17px;
119      line-height: 1.8;
120      color: rgba(255,255,255,0.5);
121      margin: 0;
122    }
123
124    .empty-state {
125      text-align: center;
126      padding: 60px 24px;
127      color: rgba(255,255,255,0.35);
128      font-size: 18px;
129      line-height: 1.8;
130    }
131
132    .empty-state .icon {
133      font-size: 48px;
134      margin-bottom: 16px;
135      opacity: 0.5;
136    }
137
138    .corrections-section {
139      margin-top: 24px;
140      animation: fadeInUp 0.6s ease-out 0.3s both;
141    }
142
143    .corrections-section h3 {
144      font-size: 17px;
145      font-weight: 600;
146      color: rgba(255,255,255,0.5);
147      margin: 0 0 12px;
148    }
149
150    .correction-item {
151      padding: 12px 16px;
152      background: rgba(255,255,255,0.04);
153      border-radius: 8px;
154      margin-bottom: 8px;
155      font-size: 16px;
156      color: rgba(255,255,255,0.45);
157      border-left: 2px solid rgba(249, 115, 22, 0.4);
158    }
159
160    .tools-list {
161      display: flex;
162      gap: 8px;
163      flex-wrap: wrap;
164      margin-top: 8px;
165    }
166
167    .tool-tag {
168      padding: 6px 12px;
169      background: rgba(255,255,255,0.06);
170      border-radius: 6px;
171      font-size: 15px;
172      color: rgba(255,255,255,0.5);
173      font-family: monospace;
174    }
175
176    @keyframes fadeInUp {
177      from { opacity: 0; transform: translateY(12px); }
178      to { opacity: 1; transform: translateY(0); }
179    }
180  `;
181
182  const profileHtml = hasProfile ? categories.map(([key, value], i) => `
183    <div class="category-card" style="animation-delay: ${0.1 + i * 0.05}s">
184      <h3>${categoryIcons[key] || "\uD83D\uDD39"} ${escapeHtml(categoryLabels[key] || key)}</h3>
185      <p>${escapeHtml(String(value))}</p>
186    </div>
187  `).join("") : `
188    <div class="empty-state">
189      <div class="icon">\uD83C\uDF31</div>
190      <p>No profile yet. The AI builds this by observing your behavior<br/>
191      across every tree on your land. Keep using TreeOS and it will appear.</p>
192      <p style="font-size: 13px; margin-top: 8px; color: rgba(255,255,255,0.25);">
193        Compression happens every 50 interactions.
194      </p>
195    </div>
196  `;
197
198  const statsHtml = `
199    <div class="stats-row">
200      <div class="stat-pill"><strong>${stats?.totalInteractions || 0}</strong> interactions</div>
201      ${peakHours.length > 0 ? `<div class="stat-pill">peak: <strong>${peakHours.join(", ")}</strong></div>` : ""}
202      ${stats?.lastCompressed ? `<div class="stat-pill">compressed <strong>${timeAgoSimple(stats.lastCompressed)}</strong></div>` : ""}
203    </div>
204  `;
205
206  const toolsHtml = tools.length > 0 ? `
207    <div class="category-card" style="animation-delay: 0.${categories.length + 2}s">
208      <h3>\uD83D\uDEE0\uFE0F Top Tools</h3>
209      <div class="tools-list">
210        ${tools.map(([name, count]) => `<span class="tool-tag">${escapeHtml(name)} (${count})</span>`).join("")}
211      </div>
212    </div>
213  ` : "";
214
215  const correctionsHtml = (corrections || []).length > 0 ? `
216    <div class="corrections-section">
217      <h3>Your Corrections (${corrections.length})</h3>
218      ${corrections.slice(-5).reverse().map(c => `
219        <div class="correction-item">${escapeHtml(c.text || String(c))}</div>
220      `).join("")}
221    </div>
222  ` : "";
223
224  const body = `
225    <div style="max-width: 600px; margin: 0 auto; padding: 0 16px 40px;">
226      <div class="inverse-header">
227        <h1>\uD83E\uDDE0 ${safeUsername}</h1>
228        <div class="sub">as the AI sees you</div>
229        <a class="back-link" href="/api/v1/user/${userId}${queryString}">back to profile</a>
230      </div>
231
232      ${statsHtml}
233      ${profileHtml}
234      ${toolsHtml}
235      ${correctionsHtml}
236    </div>
237  `;
238
239  return page({ title: `${username} . inverse`, css, body, js: "" });
240}
241
242function timeAgoSimple(dateStr) {
243  if (!dateStr) return "never";
244  const diff = Date.now() - new Date(dateStr).getTime();
245  const mins = Math.floor(diff / 60000);
246  if (mins < 60) return `${mins}m ago`;
247  const hrs = Math.floor(mins / 60);
248  if (hrs < 24) return `${hrs}h ago`;
249  const days = Math.floor(hrs / 24);
250  return `${days}d ago`;
251}
252
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getProfile, addCorrection, resetProfile } from "./core.js";
5
6const router = express.Router();
7
8// GET /user/:userId/inverse - profile as the AI sees it
9router.get("/user/:userId/inverse", (req, res, next) => {
10  if ("html" in req.query) return next("route");
11  next();
12}, authenticate, async (req, res) => {
13  try {
14    // Users can only read their own inverse profile
15    if (req.params.userId !== req.userId) {
16      return sendError(res, 403, ERR.FORBIDDEN, "Can only view your own inverse profile");
17    }
18    const data = await getProfile(req.userId);
19    sendOk(res, data || { profile: {}, stats: {}, corrections: [] });
20  } catch (err) {
21    sendError(res, 500, ERR.INTERNAL, err.message);
22  }
23});
24
25// POST /user/:userId/inverse/correct - manual correction
26router.post("/user/:userId/inverse/correct", authenticate, async (req, res) => {
27  try {
28    if (req.params.userId !== req.userId) {
29      return sendError(res, 403, ERR.FORBIDDEN, "Can only correct your own inverse profile");
30    }
31    const { text } = req.body;
32    if (!text || typeof text !== "string") {
33      return sendError(res, 400, ERR.INVALID_INPUT, "text is required");
34    }
35    const corrections = await addCorrection(req.userId, text);
36    sendOk(res, { corrections: corrections.length, message: "Correction recorded" });
37  } catch (err) {
38    sendError(res, 400, ERR.INVALID_INPUT, err.message);
39  }
40});
41
42// POST /user/:userId/inverse/reset - wipe profile
43router.post("/user/:userId/inverse/reset", authenticate, async (req, res) => {
44  try {
45    if (req.params.userId !== req.userId) {
46      return sendError(res, 403, ERR.FORBIDDEN, "Can only reset your own inverse profile");
47    }
48    await resetProfile(req.userId);
49    sendOk(res, { message: "Inverse profile reset" });
50  } catch (err) {
51    sendError(res, 500, ERR.INTERNAL, err.message);
52  }
53});
54
55export default router;
56
1import { z } from "zod";
2import { getProfile, addCorrection, resetProfile, compress } from "./core.js";
3
4export default [
5  {
6    name: "inverse-profile",
7    description: "Show the user's profile as the AI sees it. The inverse tree: values, knowledge, habits, communication style, unresolved questions, recurring frustrations, goals vs actions.",
8    schema: {
9      userId: z.string().describe("Injected by server. Ignore."),
10      chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
11      sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
12    },
13    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
14    handler: async ({ userId }) => {
15      try {
16        const data = await getProfile(userId);
17        if (!data || Object.keys(data.profile).length === 0) {
18          return { content: [{ type: "text", text: "No inverse profile yet. It builds after enough interactions." }] };
19        }
20        return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
21      } catch (err) {
22        return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
23      }
24    },
25  },
26  {
27    name: "inverse-correct",
28    description: "Manually correct the AI's model of you. These corrections are ground truth and override inferences. Example: \"I actually prefer direct feedback\" or \"I work nights by choice not insomnia\"",
29    schema: {
30      text: z.string().describe("The correction to record."),
31      userId: z.string().describe("Injected by server. Ignore."),
32      chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
33      sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
34    },
35    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
36    handler: async ({ text, userId }) => {
37      try {
38        const corrections = await addCorrection(userId, text);
39        return { content: [{ type: "text", text: `Correction recorded (${corrections.length} total). Will be applied on next compression pass.` }] };
40      } catch (err) {
41        return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
42      }
43    },
44  },
45  {
46    name: "inverse-compress",
47    description: "Force a compression pass on your inverse profile. Normally happens automatically every 50 interactions.",
48    schema: {
49      userId: z.string().describe("Injected by server. Ignore."),
50      chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
51      sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
52    },
53    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
54    handler: async ({ userId }) => {
55      try {
56        const result = await compress(userId);
57        if (!result) return { content: [{ type: "text", text: "Compression skipped. Not enough signals yet." }] };
58        return { content: [{ type: "text", text: JSON.stringify({ message: "Profile updated", categories: Object.keys(result) }, null, 2) }] };
59      } catch (err) {
60        return { content: [{ type: "text", text: `Compression failed: ${err.message}` }] };
61      }
62    },
63  },
64  {
65    name: "inverse-reset",
66    description: "Wipe the AI's model of you. Start fresh. The profile, signals, stats, and corrections are all cleared.",
67    schema: {
68      userId: z.string().describe("Injected by server. Ignore."),
69      chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
70      sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
71    },
72    annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
73    handler: async ({ userId }) => {
74      try {
75        await resetProfile(userId);
76        return { content: [{ type: "text", text: "Inverse profile reset. Starting fresh." }] };
77      } catch (err) {
78        return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
79      }
80    },
81  },
82];
83

Versions

Version Published Downloads
1.0.3 38d 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 inverse-tree

Comments

Loading comments...

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