EXTENSION for treeos-intelligence
reflect
The tree notices how the conversation is going. Not sentiment analysis. Not mood detection. Tracks conversational pattern shifts: message lengths compressing, pauses lengthening, topics circling, sudden shifts. Injects a single field into context: conversationalState. Values: flowing, compressed, searching, resistant. No labels shown to the user. The AI reads it and adjusts naturally. In flowing state it matches pace. In compressed state it gets shorter. In searching state it offers more. In resistant state it backs off. No LLM calls. Pure observation.
v1.0.1 by TreeOS Site 0 downloads 2 files 208 lines 7.0 KB published 38d ago
treeos ext install reflect
View changelog

Manifest

Requires

  • services: hooks

Optional

  • extensions: phase, inverse-tree
SHA256: 853e0c2c0066fcd4a491727044eb2049dc885502eb7bfc8e21e83dd0073e1169

Dependents

1 package depend on this

PackageTypeRelationship
treeos-intelligence v1.0.2bundleincludes

Hooks

Listens To

  • afterLLMCall
  • enrichContext

Source Code

1/**
2 * Reflect
3 *
4 * Tracks conversational patterns per session. No LLM calls.
5 * Records message lengths, response lengths, tool counts, timestamps.
6 * Detects state from the rolling window: flowing, compressed, searching, resistant.
7 * Injects conversationalState into enrichContext.
8 */
9
10import log from "../../seed/log.js";
11
12// Per-session rolling windows. Map<sessionId, entry[]>
13const windows = new Map();
14const WINDOW_SIZE = 12;    // last 12 exchanges
15const WINDOW_TTL = 30 * 60 * 1000; // 30 min, then reset (new conversation feel)
16
17// Cleanup stale sessions periodically
18const CLEANUP_INTERVAL = 10 * 60 * 1000;
19let _cleanupTimer = null;
20
21function cleanup() {
22  const cutoff = Date.now() - WINDOW_TTL;
23  for (const [key, entries] of windows) {
24    if (entries.length === 0 || entries[entries.length - 1].ts < cutoff) {
25      windows.delete(key);
26    }
27  }
28}
29
30// ─────────────────────────────────────────────────────────────────────────
31// STATE DETECTION
32// ─────────────────────────────────────────────────────────────────────────
33
34/**
35 * Detect conversational state from the rolling window.
36 *
37 * flowing:     user and AI matched pace, messages substantial, steady rhythm
38 * compressed:  user messages getting shorter, possible frustration or efficiency
39 * searching:   user asking questions, topic shifting, exploring
40 * resistant:   very short user messages, long gaps, or repeated circling
41 */
42function detectState(entries) {
43  if (entries.length < 3) return null; // not enough data
44
45  const recent = entries.slice(-6); // focus on last 6 exchanges
46  const older = entries.length > 6 ? entries.slice(-12, -6) : [];
47
48  // Compute averages
49  const avgUserLen = recent.reduce((s, e) => s + e.userLen, 0) / recent.length;
50  const avgResponseLen = recent.reduce((s, e) => s + e.responseLen, 0) / recent.length;
51  const avgToolCount = recent.reduce((s, e) => s + e.toolCount, 0) / recent.length;
52
53  // Compute trends (are user messages getting shorter?)
54  let userLenTrend = 0;
55  if (recent.length >= 4) {
56    const firstHalf = recent.slice(0, Math.floor(recent.length / 2));
57    const secondHalf = recent.slice(Math.floor(recent.length / 2));
58    const firstAvg = firstHalf.reduce((s, e) => s + e.userLen, 0) / firstHalf.length;
59    const secondAvg = secondHalf.reduce((s, e) => s + e.userLen, 0) / secondHalf.length;
60    userLenTrend = secondAvg - firstAvg; // negative = compressing
61  }
62
63  // Time gaps between messages
64  const gaps = [];
65  for (let i = 1; i < recent.length; i++) {
66    gaps.push(recent[i].ts - recent[i - 1].ts);
67  }
68  const avgGap = gaps.length > 0 ? gaps.reduce((s, g) => s + g, 0) / gaps.length : 0;
69
70  // Mode switch frequency (different modes in recent window)
71  const modes = new Set(recent.map(e => e.mode).filter(Boolean));
72  const modeSwitches = modes.size;
73
74  // Ratio of user length to response length
75  const ratio = avgResponseLen > 0 ? avgUserLen / avgResponseLen : 1;
76
77  // ── Detection rules ────────────────────────────────────────────────
78
79  // Resistant: very short messages, getting shorter, or long gaps
80  if (avgUserLen < 20 && userLenTrend < -10) return "resistant";
81  if (avgUserLen < 15 && avgGap > 120000) return "resistant"; // <15 chars avg + 2min gaps
82
83  // Compressed: messages getting shorter but still engaged
84  if (userLenTrend < -20 && avgUserLen < 50) return "compressed";
85  if (ratio < 0.1 && avgUserLen < 30) return "compressed"; // user writing 10% of what AI writes
86
87  // Searching: frequent mode switches, questions, topic shifts
88  if (modeSwitches >= 3) return "searching";
89  if (avgToolCount > 2 && avgUserLen > 50) return "searching"; // lots of tool calls, substantial messages
90
91  // Flowing: steady pace, matched lengths, no trend
92  if (Math.abs(userLenTrend) < 15 && avgUserLen > 30) return "flowing";
93  if (ratio > 0.15 && ratio < 2.0 && avgUserLen > 40) return "flowing";
94
95  // Default: if we can't tell, don't inject
96  return null;
97}
98
99// ─────────────────────────────────────────────────────────────────────────
100// INIT
101// ─────────────────────────────────────────────────────────────────────────
102
103export async function init(core) {
104  // afterLLMCall: record the exchange pattern
105  core.hooks.register("afterLLMCall", async ({ userId, rootId, mode, usage, hasToolCalls }) => {
106    if (!rootId || !userId || userId === "SYSTEM") return;
107
108    const key = `${rootId}:${userId}`;
109    if (!windows.has(key)) windows.set(key, []);
110    const window = windows.get(key);
111
112    window.push({
113      ts: Date.now(),
114      hasToolCalls: !!hasToolCalls,
115      tokenCount: (usage?.prompt_tokens || 0) + (usage?.completion_tokens || 0),
116      mode: mode || null,
117    });
118
119    // Trim to window size
120    if (window.length > WINDOW_SIZE) {
121      windows.set(key, window.slice(-WINDOW_SIZE));
122    }
123  }, "reflect");
124
125  // enrichContext: inject conversational state
126  core.hooks.register("enrichContext", async ({ context, userId }) => {
127    if (!userId) return;
128
129    // Find the session for this user. We check all sessions since
130    // enrichContext doesn't receive sessionId directly.
131    // Use the most recently active session for this user.
132    let bestWindow = null;
133    let bestTs = 0;
134
135    for (const [key, entries] of windows) {
136      if (entries.length === 0) continue;
137      const lastEntry = entries[entries.length - 1];
138      // Session key format varies, but we check recency
139      if (lastEntry.ts > bestTs && Date.now() - lastEntry.ts < WINDOW_TTL) {
140        bestWindow = entries;
141        bestTs = lastEntry.ts;
142      }
143    }
144
145    if (!bestWindow || bestWindow.length < 3) return;
146
147    const state = detectState(bestWindow);
148    if (!state) return;
149
150    context.conversationalState = state;
151  }, "reflect");
152
153  // Start cleanup timer
154  _cleanupTimer = setInterval(cleanup, CLEANUP_INTERVAL);
155  if (_cleanupTimer.unref) _cleanupTimer.unref();
156
157  log.verbose("Reflect", "Reflect loaded");
158
159  return {
160    stop: () => {
161      if (_cleanupTimer) {
162        clearInterval(_cleanupTimer);
163        _cleanupTimer = null;
164      }
165      windows.clear();
166    },
167  };
168}
169
1export default {
2  name: "reflect",
3  version: "1.0.1",
4  builtFor: "treeos-intelligence",
5  description:
6    "The tree notices how the conversation is going. Not sentiment analysis. Not mood " +
7    "detection. Tracks conversational pattern shifts: message lengths compressing, pauses " +
8    "lengthening, topics circling, sudden shifts. Injects a single field into context: " +
9    "conversationalState. Values: flowing, compressed, searching, resistant. No labels " +
10    "shown to the user. The AI reads it and adjusts naturally. In flowing state it matches " +
11    "pace. In compressed state it gets shorter. In searching state it offers more. In " +
12    "resistant state it backs off. No LLM calls. Pure observation.",
13
14  needs: {
15    services: ["hooks"],
16  },
17
18  optional: {
19    extensions: ["phase", "inverse-tree"],
20  },
21
22  provides: {
23    models: {},
24    routes: false,
25    tools: false,
26    jobs: false,
27    orchestrator: false,
28    energyActions: {},
29    sessionTypes: {},
30
31    hooks: {
32      fires: [],
33      listens: ["afterLLMCall", "enrichContext"],
34    },
35
36    cli: [],
37  },
38};
39

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star reflect

Comments

Loading comments...

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