853e0c2c0066fcd4a491727044eb2049dc885502eb7bfc8e21e83dd0073e11691 package depend on this
| Package | Type | Relationship |
|---|---|---|
| treeos-intelligence v1.0.2 | bundle | includes |
afterLLMCallenrichContext1/**
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}
1691export 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
| Version | Published | Downloads |
|---|---|---|
| 1.0.1 | 38d ago | 0 |
| 1.0.0 | 48d ago | 0 |
treeos ext star reflect
Post comments from the CLI: treeos ext comment reflect "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...