EXTENSION for treeos-intelligence
taste
The tree learns what you like. Not from settings. From watching. Signals accumulate on nodes as users interact. AI-generated content that gets kept is positive. Content that gets edited is mild negative. Content that gets deleted is strong negative. Navigation frequency is implicit preference. Every breath cycle, accumulated signals compress into a one-sentence learned preference per node. enrichContext injects it. The AI at every position adapts to your taste. Spatial, not global. You like complex workouts but simple meals. The tree knows both.
v1.0.4 by TreeOS Site 0 downloads 3 files 383 lines 11.6 KB published 38d ago
treeos ext install taste
View changelog

Manifest

Requires

  • services: hooks, llm, metadata
  • models: Node, Contribution

Optional

  • extensions: breath
SHA256: e666c30ad2a57de961f2fc632e6b5338f7297330ac37bfa5f4d294ffea12385b

Dependents

1 package depend on this

PackageTypeRelationship
treeos-intelligence v1.0.2bundleincludes

Hooks

Listens To

  • afterNote
  • beforeNodeDelete
  • afterToolCall
  • onNodeNavigate
  • enrichContext
  • breath:exhale

Source Code

1/**
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}
258
1import 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}
78
1export 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

Versions

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
0 stars
0 flags
React from the CLI: treeos ext star taste

Comments

Loading comments...

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