fc58bcfea32a3aeb903839e8c2f95ab4c790f68f9b9cafcb85d0c43948a7218d1 package depend on this
| Package | Type | Relationship |
|---|---|---|
| treeos-intelligence v1.0.2 | bundle | includes |
| Command | Method | Description |
|---|---|---|
inverse | GET | Your profile as the AI sees it. Actions: correct, reset. |
inverse correct | POST | Override AI inference |
inverse reset | POST | Wipe profile, start fresh |
afterNoteafterLLMCallafterToolCallafterNavigateenrichContext1/**
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}
2831import 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}
381import 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}
1951export 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};
611import { 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}
2521import 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;
561import { 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
treeos ext star inverse-tree
Post comments from the CLI: treeos ext comment inverse-tree "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...