9f698fecda6dde540bc36726fd16472a31518dd16847ae3371d3a8d0074dd83c| Command | Method | Description |
|---|---|---|
invest [message...] | POST | Investments. Log trades, check portfolio, review. |
enrichContext1/**
2 * Investor Core
3 *
4 * Track holdings, log trades, monitor portfolio.
5 * The tree is the portfolio.
6 */
7
8import log from "../../seed/log.js";
9import { setNodeMode } from "../../seed/modes/registry.js";
10
11let _Node = null;
12let _Note = null;
13let _metadata = null;
14
15export function configure({ Node, Note, metadata }) {
16 _Node = Node;
17 _Note = Note;
18 _metadata = metadata;
19}
20
21const ROLES = {
22 LOG: "log",
23 PORTFOLIO: "portfolio",
24 WATCHLIST: "watchlist",
25 HISTORY: "history",
26};
27
28export { ROLES };
29
30// -- Scaffold --
31
32export async function scaffold(rootId, userId) {
33 if (!_Node) throw new Error("Investor core not configured");
34 const { createNode } = await import("../../seed/tree/treeManagement.js");
35
36 const logNode = await createNode({ name: "Log", parentId: rootId, userId });
37 const portfolioNode = await createNode({ name: "Portfolio", parentId: rootId, userId });
38 const watchlistNode = await createNode({ name: "Watchlist", parentId: rootId, userId });
39 const historyNode = await createNode({ name: "History", parentId: rootId, userId });
40
41 const tags = [
42 [logNode, ROLES.LOG],
43 [portfolioNode, ROLES.PORTFOLIO],
44 [watchlistNode, ROLES.WATCHLIST],
45 [historyNode, ROLES.HISTORY],
46 ];
47
48 for (const [node, role] of tags) {
49 await _metadata.setExtMeta(node, "investor", { role });
50 }
51
52 await setNodeMode(rootId, "respond", "tree:investor-coach");
53 await setNodeMode(logNode._id, "respond", "tree:investor-log");
54
55 const root = await _Node.findById(rootId);
56 if (root) {
57 await _metadata.setExtMeta(root, "investor", {
58 initialized: true,
59 setupPhase: "complete",
60 });
61 }
62
63 const ids = {};
64 for (const [node, role] of tags) ids[role] = String(node._id);
65
66 log.info("Investor", `Scaffolded under ${rootId}`);
67 return ids;
68}
69
70// -- Find nodes --
71
72export async function findInvestorNodes(rootId) {
73 if (!_Node) return null;
74 const children = await _Node.find({ parent: rootId }).select("_id name metadata").lean();
75 const result = {};
76 for (const child of children) {
77 const meta = child.metadata instanceof Map
78 ? child.metadata.get("investor")
79 : child.metadata?.investor;
80 if (meta?.role) result[meta.role] = { id: String(child._id), name: child.name };
81 }
82 return result;
83}
84
85export async function isInitialized(rootId) {
86 if (!_Node) return false;
87 const root = await _Node.findById(rootId).select("metadata").lean();
88 if (!root) return false;
89 const meta = root.metadata instanceof Map
90 ? root.metadata.get("investor")
91 : root.metadata?.investor;
92 return !!meta?.initialized;
93}
94
95export async function getSetupPhase(rootId) {
96 if (!_Node) return null;
97 const root = await _Node.findById(rootId).select("metadata").lean();
98 if (!root) return null;
99 const meta = root.metadata instanceof Map
100 ? root.metadata.get("investor")
101 : root.metadata?.investor;
102 return meta?.setupPhase || (meta?.initialized ? "complete" : null);
103}
104
105export async function completeSetup(rootId) {
106 const root = await _Node.findById(rootId);
107 if (!root) return;
108 const existing = _metadata.getExtMeta(root, "investor") || {};
109 await _metadata.setExtMeta(root, "investor", { ...existing, setupPhase: "complete" });
110}
111
112// -- Holdings --
113
114export async function getHoldings(rootId) {
115 const nodes = await findInvestorNodes(rootId);
116 if (!nodes?.portfolio) return [];
117
118 const children = await _Node.find({ parent: nodes.portfolio.id })
119 .select("_id name metadata").lean();
120
121 return children.map(c => {
122 const meta = c.metadata instanceof Map
123 ? c.metadata.get("investor")
124 : c.metadata?.investor;
125 const values = c.metadata instanceof Map
126 ? c.metadata.get("values")
127 : c.metadata?.values;
128 const shares = meta?.shares || 0;
129 const entryPrice = meta?.entryPrice || 0;
130 const currentPrice = meta?.currentPrice || entryPrice;
131 const value = shares * currentPrice;
132 const cost = shares * entryPrice;
133 const gain = value - cost;
134 const gainPercent = cost > 0 ? ((gain / cost) * 100) : 0;
135 return {
136 id: String(c._id),
137 name: c.name,
138 ticker: meta?.ticker || c.name,
139 assetType: meta?.assetType || "stock",
140 shares,
141 entryPrice,
142 currentPrice,
143 value,
144 gain,
145 gainPercent,
146 };
147 });
148}
149
150// -- Watchlist --
151
152export async function getWatchlist(rootId) {
153 const nodes = await findInvestorNodes(rootId);
154 if (!nodes?.watchlist) return [];
155
156 const children = await _Node.find({ parent: nodes.watchlist.id })
157 .select("_id name metadata").lean();
158
159 return children.map(c => {
160 const meta = c.metadata instanceof Map
161 ? c.metadata.get("investor")
162 : c.metadata?.investor;
163 return {
164 id: String(c._id),
165 name: c.name,
166 ticker: meta?.ticker || c.name,
167 targetPrice: meta?.targetPrice || null,
168 stopLoss: meta?.stopLoss || null,
169 notes: meta?.notes || null,
170 };
171 });
172}
173
174// -- Portfolio Summary --
175
176export async function getPortfolioSummary(rootId) {
177 const holdings = await getHoldings(rootId);
178
179 const totalValue = holdings.reduce((sum, h) => sum + h.value, 0);
180 const totalCost = holdings.reduce((sum, h) => sum + (h.shares * h.entryPrice), 0);
181 const totalGain = totalValue - totalCost;
182 const totalGainPercent = totalCost > 0 ? ((totalGain / totalCost) * 100) : 0;
183
184 const allocation = holdings.map(h => ({
185 ticker: h.ticker,
186 name: h.name,
187 value: h.value,
188 percent: totalValue > 0 ? ((h.value / totalValue) * 100) : 0,
189 gain: h.gain,
190 gainPercent: h.gainPercent,
191 }));
192
193 return {
194 holdings,
195 totalValue,
196 totalCost,
197 totalGain,
198 totalGainPercent,
199 allocation,
200 };
201}
2021/**
2 * No data work needed. The AI handles everything via modes.
3 * Returns null so the orchestrator routes to the default mode.
4 */
5
6export async function handleMessage() {
7 return null;
8}
91import 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 Node from "../../seed/models/node.js";
6import { isInitialized, getPortfolioSummary, getWatchlist } from "./core.js";
7import { renderInvestorDashboard } from "./pages/dashboard.js";
8
9const router = express.Router();
10
11router.get("/root/:rootId/investor", urlAuth, htmlOnly, async (req, res) => {
12 try {
13 const { rootId } = req.params;
14 const root = await Node.findById(rootId).select("name metadata").lean();
15 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Investor tree not found");
16
17 if (!(await isInitialized(rootId))) {
18 return sendError(res, 404, ERR.TREE_NOT_FOUND, "Investor not initialized");
19 }
20
21 const summary = await getPortfolioSummary(rootId);
22 const watchlist = await getWatchlist(rootId);
23
24 res.send(renderInvestorDashboard({
25 rootId,
26 rootName: root.name,
27 summary,
28 watchlist,
29 token: req.query.token || null,
30 userId: req.user?._id?.toString() || req.user?.id || null,
31 inApp: !!req.query.inApp,
32 }));
33 } catch (err) {
34 sendError(res, 500, ERR.INTERNAL, "Investor dashboard failed");
35 }
36});
37
38export default router;
391import log from "../../seed/log.js";
2import { configure, findInvestorNodes, getHoldings, getPortfolioSummary, getWatchlist, isInitialized, scaffold } from "./core.js";
3import { handleMessage } from "./handler.js";
4
5export async function init(core) {
6 configure({
7 Node: core.models.Node,
8 Note: core.models.Note,
9 metadata: core.metadata,
10 });
11
12 // -- Modes --
13 const logMode = (await import("./modes/log.js")).default;
14 const coachMode = (await import("./modes/coach.js")).default;
15 const reviewMode = (await import("./modes/review.js")).default;
16
17 core.modes.registerMode(logMode.name, logMode, "investor");
18 core.modes.registerMode(coachMode.name, coachMode, "investor");
19 core.modes.registerMode(reviewMode.name, reviewMode, "investor");
20
21 // -- enrichContext: inject portfolio awareness into ALL tree nodes --
22 const _investorCache = new Map(); // treeRootId -> { summary, ts }
23 const INVESTOR_CACHE_TTL = 60000;
24
25 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
26 if (!node?._id) return;
27
28 // At investor holding nodes: full detail
29 const invMeta = meta?.investor;
30 if (invMeta?.role === "portfolio") {
31 return;
32 }
33
34 // For all other tree nodes: compact portfolio summary
35 const treeRootId = node.rootOwner ? String(node.rootOwner) : null;
36 if (!treeRootId) return;
37
38 const cached = _investorCache.get(treeRootId);
39 if (cached && Date.now() - cached.ts < INVESTOR_CACHE_TTL) {
40 if (cached.summary) context.investorSummary = cached.summary;
41 return;
42 }
43
44 try {
45 const { getExtension } = await import("../loader.js");
46 const life = getExtension("life");
47 if (!life?.exports?.getDomainNodes) return;
48
49 const domains = await life.exports.getDomainNodes(treeRootId);
50 if (!domains.investor?.id) {
51 _investorCache.set(treeRootId, { summary: null, ts: Date.now() });
52 return;
53 }
54
55 const portfolio = await getPortfolioSummary(domains.investor.id);
56 const topHoldings = portfolio.allocation
57 .sort((a, b) => b.value - a.value)
58 .slice(0, 5)
59 .map(a => ({ ticker: a.ticker, value: a.value, percent: a.percent }));
60
61 const compact = {
62 totalValue: portfolio.totalValue,
63 totalGain: portfolio.totalGain,
64 totalGainPercent: portfolio.totalGainPercent,
65 topHoldings,
66 };
67
68 _investorCache.set(treeRootId, { summary: compact, ts: Date.now() });
69 if (portfolio.totalValue > 0) {
70 context.investorSummary = compact;
71 }
72 } catch {}
73 }, "investor");
74
75 log.info("Investor", "Loaded. The tree tracks investments.");
76
77 // ── Register apps-grid slot ──
78 try {
79 const { getExtension } = await import("../loader.js");
80 const base = getExtension("treeos-base");
81 base?.exports?.registerSlot?.("apps-grid", "investor", ({ userId, rootMap, tokenParam, tokenField, esc: e }) => {
82 const entries = rootMap.get("Investor") || [];
83 const existing = entries.map(entry =>
84 `<a class="app-active" href="/api/v1/root/${entry.id}/investor?html${tokenParam}" style="margin-right:8px;margin-bottom:6px;">${e(entry.name)}</a>`
85 ).join("");
86 return `<div class="app-card">
87 <div class="app-header"><span class="app-emoji">📈</span><span class="app-name">Investor</span></div>
88 <div class="app-desc">Track holdings, cost basis, gains and losses. Portfolio allocation. The AI helps you think through decisions without predicting.</div>
89 ${entries.length > 0
90 ? `<div style="display:flex;flex-wrap:wrap;">${existing}</div>`
91 : `<form class="app-form" method="POST" action="/api/v1/user/${userId}/apps/create">
92 ${tokenField}<input type="hidden" name="app" value="investor" />
93 <input class="app-input" name="message" placeholder="What did you buy or sell?" required />
94 <button class="app-start" type="submit">Start Investor</button>
95 </form>`}
96 </div>`;
97 }, { priority: 65 });
98 } catch {}
99
100 // ── HTML dashboard route ──
101 let router = null;
102 try {
103 const { getExtension } = await import("../loader.js");
104 const htmlExt = getExtension("html-rendering");
105 if (htmlExt) {
106 router = (await import("./htmlRoutes.js")).default;
107 }
108 } catch {}
109
110 return {
111 router,
112 exports: {
113 scaffold,
114 isInitialized,
115 findInvestorNodes,
116 getHoldings,
117 getPortfolioSummary,
118 handleMessage,
119 },
120 };
121}
1221export default {
2 name: "investor",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Investment portfolio tracker. Log buys and sells in natural language, " +
7 "track holdings with cost basis, monitor gains and losses, review allocation. " +
8 "Each holding is a node with shares, entry price, and current price. " +
9 "Say what you bought or sold and the tree updates. Watchlist for targets. " +
10 "The AI reflects on concentration risk, unrealized losses, and allocation balance. " +
11 "Cross-domain: knows your financial health, how investments relate to your whole life. " +
12 "Type 'be' for a guided check-in on your portfolio.",
13
14 territory: "investments, portfolio, stocks, crypto, holdings, shares, buy, sell, position, allocation, gains, losses, dividends",
15 classifierHints: [
16 /\$[A-Z]{1,5}\b/, // "$AAPL", "$BTC"
17 /\b(bought|sold|buy|sell|buying|selling)\b/i, // trade language
18 /\b(shares?|lots?|position|ticker|stock|etf|bond|crypto|bitcoin|ethereum)\b/i,
19 /\b(portfolio|holdings?|allocation|diversif|rebalance)\b/i,
20 /\b(gain|loss|return|dividend|yield|cost basis|unrealized|realized)\b/i,
21 /\b(watchlist|target|stop.?loss|entry price|exit)\b/i,
22 ],
23
24 needs: {
25 models: ["Node", "Note"],
26 services: ["hooks", "llm", "metadata"],
27 },
28
29 optional: {
30 extensions: [
31 "transactions",
32 "solana",
33 "channels",
34 "browser-bridge",
35 "html-rendering",
36 "treeos-base",
37 ],
38 },
39
40 provides: {
41 models: {},
42 routes: false,
43 tools: false,
44 jobs: false,
45 modes: true,
46
47 hooks: {
48 fires: [],
49 listens: ["enrichContext"],
50 },
51
52 cli: [
53 {
54 command: "invest [message...]",
55 scope: ["tree"],
56 description: "Investments. Log trades, check portfolio, review.",
57 method: "POST",
58 endpoint: "/root/:rootId/chat",
59 body: ["message"],
60 },
61 ],
62 },
63};
641import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
2import { findInvestorNodes, getPortfolioSummary, getWatchlist } from "../core.js";
3
4export default {
5 name: "tree:investor-coach",
6 emoji: "\uD83E\uDDE0",
7 label: "Investor Coach",
8 bigMode: "tree",
9 hidden: true,
10
11 maxMessagesBeforeLoop: 6,
12 preserveContextOnLoop: true,
13
14 toolNames: [
15 "navigate-tree",
16 "get-tree-context",
17 "get-tree",
18 "get-node-notes",
19 "create-new-node",
20 "create-node-note",
21 "edit-node-note",
22 "edit-node-value",
23 "get-searched-notes-by-user",
24 ],
25
26 async buildSystemPrompt({ username, rootId, currentNodeId }) {
27 const invRoot = await findExtensionRoot(currentNodeId || rootId, "investor") || rootId;
28 const summary = invRoot ? await getPortfolioSummary(invRoot) : null;
29 const watchlist = invRoot ? await getWatchlist(invRoot) : [];
30 const nodes = invRoot ? await findInvestorNodes(invRoot) : null;
31
32 const holdingList = summary?.holdings?.length > 0
33 ? summary.holdings.map(h => {
34 const gainSign = h.gain >= 0 ? "+" : "";
35 return `- ${h.ticker} (${h.assetType}): ${h.shares} shares, $${h.value.toFixed(2)} (${gainSign}${h.gainPercent.toFixed(1)}%)`;
36 }).join("\n")
37 : "No holdings yet.";
38
39 const allocationList = summary?.allocation?.length > 0
40 ? summary.allocation
41 .sort((a, b) => b.percent - a.percent)
42 .map(a => `- ${a.ticker}: ${a.percent.toFixed(1)}% ($${a.value.toFixed(2)})`)
43 .join("\n")
44 : "";
45
46 const watchlistList = watchlist.length > 0
47 ? watchlist.map(w => {
48 const parts = [w.ticker];
49 if (w.targetPrice) parts.push(`target $${w.targetPrice}`);
50 if (w.stopLoss) parts.push(`stop $${w.stopLoss}`);
51 return `- ${parts.join(", ")}`;
52 }).join("\n")
53 : "Empty.";
54
55 const totalsBlock = summary
56 ? `Total value: $${summary.totalValue.toFixed(2)}\nTotal cost: $${summary.totalCost.toFixed(2)}\nTotal gain: ${summary.totalGain >= 0 ? "+" : ""}$${summary.totalGain.toFixed(2)} (${summary.totalGain >= 0 ? "+" : ""}${summary.totalGainPercent.toFixed(1)}%)`
57 : "";
58
59 const concentration = summary?.allocation?.filter(a => a.percent > 30) || [];
60 const concentrationWarning = concentration.length > 0
61 ? `\nCONCENTRATION WARNING: ${concentration.map(c => `${c.ticker} is ${c.percent.toFixed(1)}% of portfolio`).join(", ")}`
62 : "";
63
64 const watchlistId = nodes?.watchlist?.id;
65
66 const hasHoldings = summary?.holdings?.length > 0;
67
68 return `You are ${username}'s investment coach.
69
70${hasHoldings ? `STATUS: ${summary.holdings.length} holdings tracked.` : "STATUS: No holdings yet. Help them log their first investment."}
71
72${totalsBlock ? `PORTFOLIO SNAPSHOT:\n${totalsBlock}\n` : ""}
73${holdingList ? `HOLDINGS:\n${holdingList}\n` : ""}
74${allocationList ? `ALLOCATION:\n${allocationList}${concentrationWarning}\n` : ""}
75${watchlistList ? `WATCHLIST:\n${watchlistList}\n` : ""}
76
77Your role: help ${username} think about investments clearly. Allocation balance, concentration risk, cost basis awareness, entry/exit discipline. I track and reflect. I don't predict.
78
79CAPABILITIES:
80- Create watchlist items under Watchlist (${watchlistId || "find it"}) with target prices and stop-losses
81- Set targets and stop-losses on existing holdings (edit-node-value on the holding node, metadata.investor.targetPrice / stopLoss)
82- Review allocation and flag concentration risk (any single position > 30%)
83- Search past trade notes for patterns
84
85BEHAVIOR:
86- Be direct about numbers. Don't sugarcoat losses.
87- When they ask "should I buy more X?", check current allocation, concentration, cost basis. How much of the portfolio is already in this asset?
88- When they ask about risk: look at allocation, unrealized losses, positions without stop-losses.
89- CLEAR: you track and reflect. You do NOT predict prices, recommend specific trades, or give financial advice. You help them think through their own decisions.
90- "Is this a good entry?" becomes "Your average cost is $X. This would bring it to $Y. That's Z% of your portfolio."
91- Cross-domain awareness: if you see financial health data, spending patterns, or other life context, use it naturally. "Your savings are thin this month. Adding $5000 to a volatile position right now means that money is locked up."
92- Never expose node IDs or metadata to the user.`.trim();
93 },
94};
951import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
2import { findInvestorNodes, getHoldings, getWatchlist } from "../core.js";
3
4export default {
5 name: "tree:investor-log",
6 emoji: "\uD83D\uDCC8",
7 label: "Investor Log",
8 bigMode: "tree",
9 hidden: true,
10
11 maxMessagesBeforeLoop: 6,
12 preserveContextOnLoop: true,
13
14 toolNames: [
15 "navigate-tree",
16 "get-tree-context",
17 "get-tree",
18 "get-node-notes",
19 "create-new-node",
20 "create-node-note",
21 "edit-node-note",
22 "edit-node-value",
23 "edit-node-goal",
24 ],
25
26 async buildSystemPrompt({ username, rootId, currentNodeId }) {
27 const invRoot = await findExtensionRoot(currentNodeId || rootId, "investor") || rootId;
28 const nodes = invRoot ? await findInvestorNodes(invRoot) : null;
29 const holdings = invRoot ? await getHoldings(invRoot) : [];
30 const watchlist = invRoot ? await getWatchlist(invRoot) : [];
31
32 const holdingList = holdings.length > 0
33 ? holdings.map(h => {
34 const gainSign = h.gain >= 0 ? "+" : "";
35 return `- ${h.ticker} (${h.assetType}): ${h.shares} shares @ $${h.entryPrice} avg, current $${h.currentPrice}, value $${h.value.toFixed(2)} (${gainSign}$${h.gain.toFixed(2)}, ${gainSign}${h.gainPercent.toFixed(1)}%)`;
36 }).join("\n")
37 : "No holdings yet.";
38
39 const watchlistList = watchlist.length > 0
40 ? watchlist.map(w => {
41 const parts = [w.ticker];
42 if (w.targetPrice) parts.push(`target $${w.targetPrice}`);
43 if (w.stopLoss) parts.push(`stop $${w.stopLoss}`);
44 return `- ${parts.join(", ")}`;
45 }).join("\n")
46 : "Empty watchlist.";
47
48 const portfolioId = nodes?.portfolio?.id;
49 const watchlistId = nodes?.watchlist?.id;
50 const logId = nodes?.log?.id;
51 const historyId = nodes?.history?.id;
52
53 return `You are logging investment transactions for ${username}.
54
55CURRENT HOLDINGS:
56${holdingList}
57
58WATCHLIST:
59${watchlistList}
60
61The user tells you about assets they bought, sold, or are tracking. Parse it and record it.
62
63WORKFLOW FOR BUYS:
641. Parse the trade: ticker/asset name, number of shares/units, price per share, asset type (stock, etf, crypto, bond, other).
652. Check if a holding node already exists under Portfolio (${portfolioId || "find it"}) for this ticker.
663. If it exists: update shares (edit-node-value, key "shares") and recalculate average entry price. Set metadata.investor with updated shares and entryPrice via edit-node-value.
674. If new: create a node under Portfolio named after the ticker. Set metadata.investor: { ticker, shares, entryPrice, currentPrice, assetType }. Set values: { value: shares*price }.
685. Write a note to Log (${logId || "find it"}): "BUY 10 AAPL @ $150. Total position: 25 shares @ $145 avg."
696. Confirm with updated position summary.
70
71WORKFLOW FOR SELLS:
721. Parse the trade: which holding, how many shares, at what price.
732. Calculate realized gain: (sellPrice - entryPrice) * sharesSold.
743. Reduce shares on the holding node. If fully sold, note it but keep the node for history.
754. Write a note to Log: "SELL 10 AAPL @ $175. Realized +$250. Remaining: 15 shares."
765. If there is a History node (${historyId || "find it"}), write a note there with the realized gain.
776. Confirm with realized gain and remaining position.
78
79PARSING RULES:
80- "bought 10 shares of AAPL at $150" = BUY 10 AAPL @ $150
81- "sold half my ETH at $3200" = SELL (half of current shares) ETH @ $3200
82- "$TSLA 5 shares at $240" = BUY 5 TSLA @ $240
83- "added more BTC at $65000" = BUY, ask how much if not specified
84- If no price given, ask.
85- If no quantity given, ask.
86- Default asset type: stock. Use crypto for known crypto (BTC, ETH, SOL, etc.).
87
88RULES:
89- One log note per trade. Terse. "BUY 10 AAPL @ $150"
90- Always update the holding node metadata and values after each trade.
91- Never expose node IDs or metadata to the user.
92- Confirm in one line with position summary.`.trim();
93 },
94};
951import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
2import { getPortfolioSummary, getWatchlist } from "../core.js";
3
4export default {
5 name: "tree:investor-review",
6 emoji: "\uD83D\uDD0D",
7 label: "Investor Review",
8 bigMode: "tree",
9 hidden: true,
10
11 maxMessagesBeforeLoop: 6,
12 preserveContextOnLoop: true,
13
14 toolNames: [
15 "navigate-tree",
16 "get-tree-context",
17 "get-tree",
18 "get-node-notes",
19 "get-searched-notes-by-user",
20 ],
21
22 async buildSystemPrompt({ username, rootId, currentNodeId }) {
23 const invRoot = await findExtensionRoot(currentNodeId || rootId, "investor") || rootId;
24 const summary = invRoot ? await getPortfolioSummary(invRoot) : null;
25 const watchlist = invRoot ? await getWatchlist(invRoot) : [];
26
27 const holdingList = summary?.holdings?.length > 0
28 ? summary.holdings
29 .sort((a, b) => b.value - a.value)
30 .map(h => {
31 const gainSign = h.gain >= 0 ? "+" : "";
32 const pct = summary.totalValue > 0 ? ((h.value / summary.totalValue) * 100).toFixed(1) : "0.0";
33 return `- ${h.ticker} (${h.assetType}): ${h.shares} shares @ $${h.entryPrice} avg, current $${h.currentPrice}, value $${h.value.toFixed(2)} (${gainSign}$${h.gain.toFixed(2)}, ${gainSign}${h.gainPercent.toFixed(1)}%), allocation ${pct}%`;
34 }).join("\n")
35 : "No holdings.";
36
37 const totalsBlock = summary
38 ? `Total value: $${summary.totalValue.toFixed(2)}\nTotal cost: $${summary.totalCost.toFixed(2)}\nTotal gain: ${summary.totalGain >= 0 ? "+" : ""}$${summary.totalGain.toFixed(2)} (${summary.totalGain >= 0 ? "+" : ""}${summary.totalGainPercent.toFixed(1)}%)`
39 : "";
40
41 // Flags
42 const flags = [];
43 if (summary?.allocation) {
44 const concentrated = summary.allocation.filter(a => a.percent > 30);
45 if (concentrated.length > 0) {
46 flags.push(`CONCENTRATION RISK: ${concentrated.map(c => `${c.ticker} at ${c.percent.toFixed(1)}%`).join(", ")}`);
47 }
48 const losers = summary.holdings.filter(h => h.gain < 0);
49 if (losers.length > 0) {
50 flags.push(`UNREALIZED LOSSES: ${losers.map(l => `${l.ticker} ${l.gain >= 0 ? "+" : ""}$${l.gain.toFixed(2)}`).join(", ")}`);
51 }
52 }
53
54 // Check for holdings without stop-losses
55 const holdingsWithoutStops = summary?.holdings?.filter(h => {
56 // Holdings don't have stopLoss in the standard fields, but we flag the concern
57 return true;
58 }) || [];
59
60 const watchlistList = watchlist.length > 0
61 ? watchlist.map(w => {
62 const parts = [w.ticker];
63 if (w.targetPrice) parts.push(`target $${w.targetPrice}`);
64 if (w.stopLoss) parts.push(`stop $${w.stopLoss}`);
65 if (w.notes) parts.push(w.notes);
66 return `- ${parts.join(", ")}`;
67 }).join("\n")
68 : "Empty watchlist.";
69
70 const flagBlock = flags.length > 0 ? `\nFLAGS:\n${flags.map(f => `! ${f}`).join("\n")}\n` : "";
71
72 return `You are reviewing ${username}'s investment portfolio. Show the full picture. Be honest.
73
74${totalsBlock ? `PORTFOLIO:\n${totalsBlock}\n` : ""}
75HOLDINGS:
76${holdingList}
77${flagBlock}
78WATCHLIST:
79${watchlistList}
80
81BEHAVIOR:
82- Answer their question directly with numbers.
83- "How is my portfolio doing?" = show total value, gain/loss, top and bottom performers.
84- "What's my allocation?" = show each holding's percentage of total.
85- "How much am I up/down?" = total unrealized gain/loss with per-holding breakdown.
86- Flag concentration risk: any single holding over 30% of portfolio.
87- Flag unrealized losses with the exact dollar amount.
88- Note if holdings lack stop-losses set. Disciplined investors define their exit.
89- Show watchlist items and how current prices compare to targets.
90- Be factual. Don't moralize. Just show the numbers and let them decide.
91- Never expose node IDs or metadata to the user.`.trim();
92 },
93};
941/**
2 * Investor Dashboard
3 *
4 * Portfolio overview. Holdings with gains/losses, allocation,
5 * watchlist, concentration warnings.
6 */
7
8import { renderAppDashboard } from "../../html-rendering/html/appDashboard.js";
9
10const TYPE_COLORS = {
11 stock: "#667eea",
12 etf: "#48bb78",
13 crypto: "#f6ad55",
14 bond: "#4fd1c5",
15 "real-estate": "#ed64a6",
16 other: "#718096",
17};
18
19export function renderInvestorDashboard({ rootId, rootName, summary, watchlist, token, userId, inApp }) {
20 const s = summary || {};
21 const holdings = s.holdings || [];
22 const allocation = s.allocation || [];
23 const totalValue = s.totalValue || 0;
24 const totalGain = s.totalGain || 0;
25 const totalGainPercent = s.totalGainPercent || 0;
26 const totalCost = s.totalCost || 0;
27 const wl = watchlist || [];
28
29 // Hero: total portfolio value
30 const gainSign = totalGain >= 0 ? "+" : "";
31 const hero = {
32 value: `$${totalValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
33 label: `${gainSign}$${totalGain.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} (${gainSign}${totalGainPercent.toFixed(1)}%)`,
34 color: totalGain >= 0 ? "#48bb78" : "#fc8181",
35 sub: `Cost basis: $${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
36 };
37
38 // Stats
39 const stats = [];
40 stats.push({ value: String(holdings.length), label: "holdings" });
41 if (wl.length > 0) stats.push({ value: String(wl.length), label: "watching" });
42 const concentration = allocation.filter(a => a.percent > 30);
43 if (concentration.length > 0) {
44 stats.push({ value: concentration.map(c => c.ticker).join(", "), label: ">30% concentration" });
45 }
46
47 // Bars: allocation by holding
48 const bars = allocation
49 .sort((a, b) => b.value - a.value)
50 .map(a => {
51 const h = holdings.find(h => h.ticker === a.ticker);
52 const assetType = h?.assetType || "stock";
53 return {
54 label: `${a.ticker} (${a.percent.toFixed(1)}%)`,
55 current: a.value,
56 goal: totalValue,
57 color: TYPE_COLORS[assetType] || "#a78bfa",
58 unit: "$",
59 };
60 });
61
62 // Cards
63 const cards = [];
64
65 // Holdings card with gain/loss detail
66 if (holdings.length > 0) {
67 cards.push({
68 title: "Holdings",
69 items: holdings
70 .sort((a, b) => b.value - a.value)
71 .map(h => {
72 const gs = h.gain >= 0 ? "+" : "";
73 return {
74 text: `${h.ticker} . ${h.shares} ${h.assetType === "crypto" ? "units" : "shares"} @ $${h.entryPrice}`,
75 detail: [`$${h.value.toFixed(2)}`, `${gs}$${h.gain.toFixed(2)}`, `${gs}${h.gainPercent.toFixed(1)}%`],
76 sub: h.assetType,
77 };
78 }),
79 });
80 }
81
82 // Watchlist card
83 if (wl.length > 0) {
84 cards.push({
85 title: "Watchlist",
86 items: wl.map(w => {
87 const parts = [];
88 if (w.targetPrice) parts.push(`target $${w.targetPrice}`);
89 if (w.stopLoss) parts.push(`stop $${w.stopLoss}`);
90 return {
91 text: w.ticker,
92 sub: parts.length > 0 ? parts.join(" . ") : null,
93 };
94 }),
95 });
96 }
97
98 return renderAppDashboard({
99 rootId,
100 rootName: rootName || "Investor",
101 token,
102 userId,
103 inApp: !!inApp,
104 subtitle: new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }),
105 hero,
106 stats,
107 bars,
108 cards,
109 chatBar: {
110 placeholder: "Log a trade or ask about your portfolio...",
111 endpoint: `/api/v1/root/${rootId}/chat`,
112 },
113 emptyState: holdings.length === 0
114 ? { title: "No holdings yet", message: "Tell me what you bought. The tree tracks your portfolio." }
115 : null,
116 });
117}
118
treeos ext star investor
Post comments from the CLI: treeos ext comment investor "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...