e666c30ad2a57de961f2fc632e6b5338f7297330ac37bfa5f4d294ffea12385b1 package depend on this
| Package | Type | Relationship |
|---|---|---|
| treeos-intelligence v1.0.2 | bundle | includes |
afterNotebeforeNodeDeleteafterToolCallonNodeNavigateenrichContextbreath:exhale1/**
2 * Taste Core
3 *
4 * Learns user preferences from behavior. Signals accumulate on nodes.
5 * Breath cycles compress signals into a one-sentence learned preference.
6 * enrichContext injects it. The AI adapts.
7 */
8
9import log from "../../seed/log.js";
10import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
11
12let _metadata, _runChat, _Contribution, _Node;
13
14export function configure({ metadata, runChat, Contribution, Node }) {
15 _metadata = metadata;
16 _runChat = runChat;
17 _Contribution = Contribution;
18 _Node = Node;
19}
20
21// ─────────────────────────────────────────────────────────────────────────
22// AI DETECTION
23// ─────────────────────────────────────────────────────────────────────────
24
25/**
26 * Check if a note was created by AI. The Note model has no wasAi field.
27 * The Contribution model does.
28 */
29async function checkAiGenerated(noteId) {
30 try {
31 const c = await _Contribution.findOne({
32 "noteAction.noteId": noteId,
33 "noteAction.action": "add",
34 wasAi: true,
35 }).select("_id").lean();
36 return !!c;
37 } catch {
38 return false;
39 }
40}
41
42// ─────────────────────────────────────────────────────────────────────────
43// SIGNAL RECORDING
44// ─────────────────────────────────────────────────────────────────────────
45
46/**
47 * AI created a note and the user kept it. Positive signal.
48 */
49export async function recordCreateSignal(nodeId, noteId) {
50 if (!await checkAiGenerated(noteId)) return;
51 try {
52 await _metadata.pushExtMeta(nodeId, "taste", "signals", {
53 type: "created",
54 source: "ai-generated",
55 date: new Date(),
56 weight: 1.0,
57 }, 50);
58 } catch (err) {
59 log.debug("Taste", `recordCreateSignal failed: ${err.message}`);
60 }
61}
62
63/**
64 * User edited an AI-generated note. Mild negative: close but not right.
65 */
66export async function recordEditSignal(nodeId, noteId) {
67 if (!await checkAiGenerated(noteId)) return;
68 try {
69 await _metadata.pushExtMeta(nodeId, "taste", "signals", {
70 type: "edit",
71 source: "ai-corrected",
72 date: new Date(),
73 weight: -0.3,
74 }, 50);
75 } catch (err) {
76 log.debug("Taste", `recordEditSignal failed: ${err.message}`);
77 }
78}
79
80/**
81 * User is deleting a node. If it had AI-generated content, that is a
82 * strong negative signal. Write to the PARENT since this node is dying.
83 */
84export async function recordDeleteSignal(node) {
85 if (!node.parent) return;
86
87 const taste = node.metadata instanceof Map
88 ? node.metadata.get("taste")
89 : node.metadata?.taste;
90
91 if (!taste?.signals?.length) return;
92
93 const hadAiContent = taste.signals.some(s => s.source === "ai-generated");
94 if (!hadAiContent) return;
95
96 try {
97 await _metadata.pushExtMeta(node.parent, "taste", "signals", {
98 type: "deleted",
99 source: "child-rejected",
100 date: new Date(),
101 weight: -1.0,
102 }, 50);
103 } catch (err) {
104 log.debug("Taste", `recordDeleteSignal failed: ${err.message}`);
105 }
106}
107
108/**
109 * Explicit feedback from a rating tool.
110 */
111export async function recordFeedbackSignal(nodeId, positive) {
112 try {
113 await _metadata.pushExtMeta(nodeId, "taste", "signals", {
114 type: "feedback",
115 source: "explicit",
116 date: new Date(),
117 weight: positive ? 0.8 : -0.8,
118 }, 50);
119 } catch (err) {
120 log.debug("Taste", `recordFeedbackSignal failed: ${err.message}`);
121 }
122}
123
124// ─────────────────────────────────────────────────────────────────────────
125// SYNTHESIS (breath:exhale only)
126// ─────────────────────────────────────────────────────────────────────────
127
128const _synthesizing = new Set();
129const SYNTHESIS_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
130const SIGNAL_DECAY_DAYS = 30;
131const MIN_SIGNALS = 5;
132
133/**
134 * Called on breath:exhale. Finds nodes with enough signals in this tree,
135 * runs one LLM call per node to produce a learned preference sentence.
136 */
137export async function synthesize(rootId) {
138 if (_synthesizing.has(rootId)) return;
139 _synthesizing.add(rootId);
140
141 try {
142 if (!_runChat) return;
143
144 // Find nodes in this tree with at least MIN_SIGNALS signals
145 const nodes = await _Node.find({
146 $or: [
147 { _id: rootId },
148 { parent: rootId },
149 ],
150 [`metadata.taste.signals.${MIN_SIGNALS - 1}`]: { $exists: true },
151 }).select("_id name parent metadata.taste").lean();
152
153 if (!nodes.length) return;
154
155 const now = Date.now();
156 const decayCutoff = now - SIGNAL_DECAY_DAYS * 86400000;
157
158 for (const node of nodes) {
159 const taste = node.metadata instanceof Map
160 ? node.metadata.get("taste") || {}
161 : node.metadata?.taste || {};
162
163 // Cooldown: skip if synthesized recently
164 if (taste.lastSynthesis && now - new Date(taste.lastSynthesis).getTime() < SYNTHESIS_COOLDOWN_MS) {
165 continue;
166 }
167
168 const signals = taste.signals || [];
169 if (signals.length < MIN_SIGNALS) continue;
170
171 // Score: weighted rolling average
172 const totalWeight = signals.reduce((sum, s) => sum + (s.weight || 0), 0);
173 const score = Math.round((totalWeight / signals.length) * 100) / 100;
174
175 // Children's learned fields for upward propagation
176 let childPrefs = [];
177 try {
178 const children = await _Node.find({ parent: node._id })
179 .select("metadata.taste.learned")
180 .lean();
181 childPrefs = children
182 .map(c => {
183 const ct = c.metadata instanceof Map
184 ? c.metadata.get("taste")
185 : c.metadata?.taste;
186 return ct?.learned;
187 })
188 .filter(Boolean);
189 } catch {}
190
191 // LLM synthesis
192 const prompt = buildSynthesisPrompt(node.name, signals, childPrefs);
193 try {
194 const { answer } = await _runChat({
195 userId: "SYSTEM",
196 username: "taste",
197 message: prompt,
198 mode: "home:default",
199 slot: "taste",
200 });
201
202 if (!answer) continue;
203
204 const parsed = parseJsonSafe(answer);
205 const learned = parsed?.learned || answer.trim();
206
207 // Collect tags from signals
208 const tags = [...new Set(signals.flatMap(s => s.tags || []))].slice(0, 20);
209
210 // Write back
211 await _metadata.batchSetExtMeta(node._id, "taste", {
212 score,
213 tags,
214 learned,
215 lastSynthesis: new Date(),
216 });
217
218 // Decay old signals
219 const fresh = signals.filter(s => new Date(s.date).getTime() > decayCutoff);
220 if (fresh.length < signals.length) {
221 await _metadata.mergeExtMeta(node, "taste", { signals: fresh });
222 }
223
224 log.verbose("Taste", `Synthesized for ${node.name || node._id}: "${learned.slice(0, 80)}"`);
225 } catch (err) {
226 log.debug("Taste", `Synthesis LLM failed for ${node._id}: ${err.message}`);
227 }
228 }
229 } catch (err) {
230 log.error("Taste", `synthesize failed for ${rootId}: ${err.message}`);
231 } finally {
232 _synthesizing.delete(rootId);
233 }
234}
235
236function buildSynthesisPrompt(nodeName, signals, childPrefs) {
237 const positive = signals.filter(s => (s.weight || 0) > 0);
238 const negative = signals.filter(s => (s.weight || 0) < 0);
239 const neutral = signals.filter(s => (s.weight || 0) === 0);
240
241 const childSection = childPrefs.length > 0
242 ? `\n\nChild node preferences (propagate upward):\n${childPrefs.map(p => `- ${p}`).join("\n")}`
243 : "";
244
245 return (
246 `You are analyzing preference signals for the node "${nodeName || "unknown"}".\n\n` +
247 `Positive signals (${positive.length}): ${positive.map(s => s.source).join(", ") || "none"}\n` +
248 `Negative signals (${negative.length}): ${negative.map(s => s.source).join(", ") || "none"}\n` +
249 `Neutral signals (${neutral.length}): ${neutral.length}\n` +
250 `Total interactions: ${signals.length}` +
251 childSection +
252 `\n\nWrite one specific sentence describing this user's preference at this position. ` +
253 `Not "likes healthy food" but "prefers simple chicken-and-rice meals over complex recipes." ` +
254 `Be concrete. If there is not enough signal to say something specific, say "no clear preference yet."\n\n` +
255 `Return JSON only: { "learned": "..." }`
256 );
257}
2581import log from "../../seed/log.js";
2import {
3 configure,
4 recordCreateSignal,
5 recordEditSignal,
6 recordDeleteSignal,
7 recordFeedbackSignal,
8 synthesize,
9} from "./core.js";
10
11export async function init(core) {
12 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
13
14 const { runChat: _runChatDirect } = await import("../../seed/llm/conversation.js");
15 configure({
16 metadata: core.metadata,
17 runChat: async (opts) => _runChatDirect({ ...opts, llmPriority: BG }),
18 Contribution: core.models.Contribution,
19 Node: core.models.Node,
20 });
21
22 // ── afterNote: track AI-generated content signals ──────────────────
23 core.hooks.register("afterNote", async ({ note, nodeId, action }) => {
24 if (action === "create") {
25 await recordCreateSignal(nodeId, note._id);
26 } else if (action === "edit") {
27 await recordEditSignal(nodeId, note._id);
28 }
29 }, "taste");
30
31 // ── beforeNodeDelete: negative signal to parent if AI content deleted ─
32 core.hooks.register("beforeNodeDelete", async ({ node }) => {
33 await recordDeleteSignal(node);
34 // Never cancel deletion. Taste observes, it does not block.
35 }, "taste");
36
37 // ── afterToolCall: explicit feedback signals ───────────────────────
38 core.hooks.register("afterToolCall", async ({ toolName, args, success, nodeId }) => {
39 if (!success) return;
40 if (toolName === "rate-response" && args?.rating !== undefined) {
41 const targetNodeId = args.nodeId || nodeId;
42 if (targetNodeId) {
43 await recordFeedbackSignal(targetNodeId, args.rating > 0);
44 }
45 }
46 }, "taste");
47
48 // ── onNodeNavigate: implicit preference from visit frequency ───────
49 core.hooks.register("onNodeNavigate", async ({ nodeId }) => {
50 try {
51 await core.metadata.incExtMeta(nodeId, "taste", "visitCount", 1);
52 } catch {}
53 }, "taste");
54
55 // ── enrichContext: inject learned taste into AI context ─────────────
56 core.hooks.register("enrichContext", async ({ context, node }) => {
57 const taste = node.metadata instanceof Map
58 ? node.metadata.get("taste")
59 : node.metadata?.taste;
60
61 if (!taste?.learned) return;
62
63 context.taste = {
64 preference: taste.learned,
65 score: taste.score,
66 tags: taste.tags,
67 };
68 }, "taste");
69
70 // ── breath:exhale: synthesize accumulated signals ──────────────────
71 core.hooks.register("breath:exhale", async ({ rootId }) => {
72 await synthesize(rootId);
73 }, "taste");
74
75 log.info("Taste", "Loaded. The tree learns what you like.");
76 return {};
77}
781export default {
2 name: "taste",
3 version: "1.0.4",
4 builtFor: "treeos-intelligence",
5 description:
6 "The tree learns what you like. Not from settings. From watching. " +
7 "Signals accumulate on nodes as users interact. AI-generated content that gets kept is positive. " +
8 "Content that gets edited is mild negative. Content that gets deleted is strong negative. " +
9 "Navigation frequency is implicit preference. Every breath cycle, accumulated signals compress " +
10 "into a one-sentence learned preference per node. enrichContext injects it. The AI at every " +
11 "position adapts to your taste. Spatial, not global. You like complex workouts but simple meals. " +
12 "The tree knows both.",
13
14 needs: {
15 services: ["hooks", "llm", "metadata"],
16 models: ["Node", "Contribution"],
17 },
18
19 optional: {
20 extensions: ["breath"],
21 },
22
23 provides: {
24 models: {},
25 routes: false,
26 tools: false,
27 jobs: false,
28 orchestrator: false,
29 energyActions: {},
30 sessionTypes: {},
31 env: [],
32 cli: [],
33
34 hooks: {
35 fires: [],
36 listens: [
37 "afterNote",
38 "beforeNodeDelete",
39 "afterToolCall",
40 "onNodeNavigate",
41 "enrichContext",
42 "breath:exhale",
43 ],
44 },
45 },
46};
47
| Version | Published | Downloads |
|---|---|---|
| 1.0.4 | 38d ago | 0 |
| 1.0.3 | 46d ago | 0 |
| 1.0.2 | 47d ago | 0 |
| 1.0.1 | 47d ago | 0 |
| 1.0.0 | 48d ago | 0 |
treeos ext star taste
Post comments from the CLI: treeos ext comment taste "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...