6514f712b92cf52cced03480acaa0d790564a52fca1ef43e3cf949cd64af4d20| Command | Method | Description |
|---|---|---|
phase | GET | Current phase and session stats. Actions: history, cycle. |
phase history | GET | Your phase patterns over time |
phase cycle | GET | Awareness vs attention ratio |
afterNoteafterNodeCreateafterNavigateafterToolCallenrichContext1// Phase Core
2//
3// Detects whether the user is in awareness (gathering), attention (producing),
4// or scattered (bouncing) from their behavior. No toggle. No setting.
5// The tree watches what you do and tells you what it sees.
6//
7// Rolling window of last N interactions per user. Each interaction is typed:
8// navigate, read, write, create, tool, query
9//
10// The ratio of types determines the phase. The phase injects into enrichContext
11// so the AI adapts its behavior.
12
13import log from "../../seed/log.js";
14import { getUserMeta, setUserMeta } from "../../seed/tree/userMetadata.js";
15import { getExtension } from "../loader.js";
16
17let User = null;
18export function setModels(models) { User = models.User; }
19
20// ─────────────────────────────────────────────────────────────────────────
21// CONFIG (land-configurable)
22// ─────────────────────────────────────────────────────────────────────────
23
24let _getLandConfig = () => null;
25export function setLandConfig(fn) { _getLandConfig = fn; }
26
27function cfg(key, fallback) {
28 const v = _getLandConfig(key);
29 return v != null ? Number(v) : fallback;
30}
31
32function windowSize() { return cfg("phaseWindowSize", 20); }
33function awarenessThreshold() { return cfg("phaseAwarenessThreshold", 0.7); }
34function attentionThreshold() { return cfg("phaseAttentionThreshold", 0.7); }
35function scatteredBranchThreshold() { return cfg("phaseScatteredBranchThreshold", 4); }
36function historyMax() { return cfg("phaseHistoryMax", 200); }
37function transitionSummaryEnabled() {
38 const v = _getLandConfig("phaseTransitionSummary");
39 return v === true || v === "true" || v == null; // default on
40}
41
42// ─────────────────────────────────────────────────────────────────────────
43// SIGNAL RECORDING
44// ─────────────────────────────────────────────────────────────────────────
45
46/**
47 * Record an interaction signal for a user.
48 * Called from hook handlers (afterNote, afterNodeCreate, etc.)
49 *
50 * @param {string} userId
51 * @param {string} type - "navigate" | "write" | "create" | "tool" | "query" | "read"
52 * @param {string} [nodeId] - which node the interaction happened at
53 */
54export async function recordSignal(userId, type, nodeId) {
55 if (!userId || !User) return;
56
57 const user = await User.findById(userId);
58 if (!user) return;
59
60 const phaseMeta = getUserMeta(user, "phase");
61 if (!phaseMeta.window) phaseMeta.window = [];
62
63 const signal = {
64 type,
65 nodeId: nodeId || null,
66 at: Date.now(),
67 };
68
69 phaseMeta.window.push(signal);
70
71 // Trim to window size
72 const max = windowSize();
73 if (phaseMeta.window.length > max) {
74 phaseMeta.window = phaseMeta.window.slice(-max);
75 }
76
77 // Detect phase from window
78 const previousPhase = phaseMeta.currentPhase || null;
79 const detected = detectPhase(phaseMeta.window);
80 phaseMeta.currentPhase = detected.phase;
81 phaseMeta.phaseConfidence = detected.confidence;
82 phaseMeta.phaseDetectedAt = Date.now();
83
84 // Track transition
85 if (previousPhase && previousPhase !== detected.phase) {
86 recordTransition(phaseMeta, previousPhase, detected.phase);
87 }
88
89 // Atomic write to avoid clobbering other metadata namespaces
90 const { batchSetUserMeta } = await import("../../seed/tree/userMetadata.js");
91 await batchSetUserMeta(userId, "phase", phaseMeta);
92
93 // Feed inverse-tree if installed
94 if (previousPhase !== detected.phase) {
95 try {
96 const inverse = getExtension("inverse-tree");
97 if (inverse?.exports?.recordSignal) {
98 inverse.exports.recordSignal(userId, "phase-transition", {
99 from: previousPhase,
100 to: detected.phase,
101 confidence: detected.confidence,
102 });
103 }
104 } catch (err) {
105 log.debug("Phase", "inverse-tree signal failed:", err.message);
106 }
107 }
108}
109
110// ─────────────────────────────────────────────────────────────────────────
111// PHASE DETECTION
112// ─────────────────────────────────────────────────────────────────────────
113
114const READ_TYPES = new Set(["navigate", "read", "query"]);
115const WRITE_TYPES = new Set(["write", "create", "tool"]);
116
117function detectPhase(window) {
118 if (!window || window.length < 3) {
119 return { phase: "awareness", confidence: 0.5 };
120 }
121
122 let reads = 0;
123 let writes = 0;
124 const branches = new Set();
125
126 for (const signal of window) {
127 if (READ_TYPES.has(signal.type)) reads++;
128 if (WRITE_TYPES.has(signal.type)) writes++;
129 if (signal.nodeId) branches.add(signal.nodeId);
130 }
131
132 const total = reads + writes;
133 if (total === 0) return { phase: "awareness", confidence: 0.5 };
134
135 const readRatio = reads / total;
136 const writeRatio = writes / total;
137
138 // Check for scattered first: many branches, no depth
139 if (branches.size >= scatteredBranchThreshold() && writeRatio < 0.3) {
140 return { phase: "scattered", confidence: Math.min(0.9, branches.size / (scatteredBranchThreshold() * 2)) };
141 }
142
143 // Awareness: mostly reading/navigating
144 if (readRatio >= awarenessThreshold()) {
145 return { phase: "awareness", confidence: readRatio };
146 }
147
148 // Attention: mostly writing/creating/tooling
149 if (writeRatio >= attentionThreshold()) {
150 return { phase: "attention", confidence: writeRatio };
151 }
152
153 // Mixed but not scattered (some depth in a few branches)
154 if (writeRatio > readRatio) {
155 return { phase: "attention", confidence: writeRatio };
156 }
157
158 return { phase: "awareness", confidence: readRatio };
159}
160
161// ─────────────────────────────────────────────────────────────────────────
162// TRANSITIONS
163// ─────────────────────────────────────────────────────────────────────────
164
165function recordTransition(phaseMeta, from, to) {
166 if (!phaseMeta.history) phaseMeta.history = [];
167
168 // Close the previous phase entry
169 const last = phaseMeta.history[phaseMeta.history.length - 1];
170 if (last && !last.endAt) {
171 last.endAt = Date.now();
172 last.durationMs = last.endAt - last.startAt;
173 }
174
175 // Start new phase entry
176 phaseMeta.history.push({
177 phase: to,
178 startAt: Date.now(),
179 endAt: null,
180 durationMs: null,
181 transitionFrom: from,
182 });
183
184 // Trim history
185 const max = historyMax();
186 if (phaseMeta.history.length > max) {
187 phaseMeta.history = phaseMeta.history.slice(-max);
188 }
189
190 log.debug("Phase", `Phase transition: ${from} -> ${to}`);
191}
192
193// ─────────────────────────────────────────────────────────────────────────
194// CONTEXT INJECTION
195// ─────────────────────────────────────────────────────────────────────────
196
197/**
198 * Build the phase context string for enrichContext.
199 * Returns null if no phase data available.
200 */
201export function buildPhaseContext(phaseMeta) {
202 if (!phaseMeta?.currentPhase) return null;
203
204 const phase = phaseMeta.currentPhase;
205 const confidence = phaseMeta.phaseConfidence || 0;
206
207 let context = `User phase: ${phase}`;
208
209 if (phase === "awareness") {
210 context += ". The user is exploring and gathering context. Show, surface, connect. Don't push action.";
211 } else if (phase === "attention") {
212 context += ". The user is focused and producing. Do, build, execute. Don't pause to explain.";
213 } else if (phase === "scattered") {
214 context += ". The user is bouncing between branches without depth. Gently observe this pattern if appropriate.";
215 }
216
217 // Note transition if recent
218 const history = phaseMeta.history || [];
219 const last = history[history.length - 1];
220 if (last?.transitionFrom && last.startAt && (Date.now() - last.startAt) < 120000) {
221 context += ` Just transitioned from ${last.transitionFrom}.`;
222 if (last.transitionFrom === "awareness" && phase === "attention") {
223 context += " The user was gathering context and is now ready to work. Crystallize what they explored.";
224 } else if (last.transitionFrom === "attention" && phase === "awareness") {
225 context += " The user was focused and is now exploring. Summarize what they just built.";
226 }
227 }
228
229 return context;
230}
231
232// ─────────────────────────────────────────────────────────────────────────
233// READ (for routes)
234// ─────────────────────────────────────────────────────────────────────────
235
236export async function getPhaseState(userId) {
237 if (!User) return null;
238 const user = await User.findById(userId).lean();
239 if (!user) return null;
240 return getUserMeta(user, "phase");
241}
242
243export function computeCycleStats(history) {
244 if (!history || history.length === 0) return { awareness: 0, attention: 0, scattered: 0, total: 0 };
245
246 let awareness = 0;
247 let attention = 0;
248 let scattered = 0;
249
250 for (const entry of history) {
251 const dur = entry.durationMs || 0;
252 if (entry.phase === "awareness") awareness += dur;
253 else if (entry.phase === "attention") attention += dur;
254 else if (entry.phase === "scattered") scattered += dur;
255 }
256
257 const total = awareness + attention + scattered;
258 if (total === 0) return { awareness: 0, attention: 0, scattered: 0, total: 0 };
259
260 return {
261 awareness: Math.round(awareness / total * 100),
262 attention: Math.round(attention / total * 100),
263 scattered: Math.round(scattered / total * 100),
264 total,
265 awarenessMs: awareness,
266 attentionMs: attention,
267 scatteredMs: scattered,
268 };
269}
2701import log from "../../seed/log.js";
2import { setModels, setLandConfig, recordSignal, buildPhaseContext } from "./core.js";
3import { getUserMeta } from "../../seed/tree/userMetadata.js";
4
5export async function init(core) {
6 setModels(core.models);
7
8 try {
9 const { getLandConfigValue } = await import("../../seed/landConfig.js");
10 setLandConfig(getLandConfigValue);
11 } catch (err) {
12 log.debug("Phase", "landConfig import failed:", err.message);
13 }
14
15 const { default: router } = await import("./routes.js");
16
17 // ── Hook: afterNote (write signal) ─────────────────────────────────
18 core.hooks.register("afterNote", async (data) => {
19 if (data.userId) {
20 await recordSignal(data.userId, "write", data.nodeId);
21 }
22 }, "phase");
23
24 // ── Hook: afterNodeCreate (create signal) ──────────────────────────
25 core.hooks.register("afterNodeCreate", async (data) => {
26 if (data.userId) {
27 await recordSignal(data.userId, "create", data.node?._id?.toString());
28 }
29 }, "phase");
30
31 // ── Hook: afterNavigate (navigate signal) ──────────────────────────
32 core.hooks.register("afterNavigate", async (data) => {
33 if (data.userId) {
34 await recordSignal(data.userId, "navigate", data.nodeId || data.rootId);
35 }
36 }, "phase");
37
38 // ── Hook: afterToolCall (tool signal) ──────────────────────────────
39 core.hooks.register("afterToolCall", async (data) => {
40 if (data.userId) {
41 await recordSignal(data.userId, "tool", data.rootId);
42 }
43 }, "phase");
44
45 // ── Hook: enrichContext (inject phase into AI prompt) ──────────────
46 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
47 // meta doesn't have user phase (it's node metadata). We need to read
48 // from user metadata via the userId in context.
49 const userId = context._userId;
50 if (!userId) return;
51
52 try {
53 const User = core.models.User;
54 const user = await User.findById(userId).select("metadata").lean();
55 if (!user) return;
56
57 const phaseMeta = getUserMeta(user, "phase");
58 const phaseContext = buildPhaseContext(phaseMeta);
59 if (phaseContext) {
60 context.userPhase = phaseContext;
61 }
62 } catch (err) {
63 log.debug("Phase", "enrichContext failed:", err.message);
64 }
65 }, "phase");
66
67 log.verbose("Phase", "Phase detection loaded (awareness / attention / scattered)");
68
69 return { router };
70}
711export default {
2 name: "phase",
3 version: "1.0.3",
4 builtFor: "TreeOS",
5 description:
6 "The tree knows whether you are collecting or spending. Not from a toggle. Not from " +
7 "a setting. From what you actually do. " +
8 "\n\n" +
9 "Three phases. Awareness: open, exploratory, gathering. Browsing nodes, reading notes, " +
10 "asking questions, jumping between branches. Not building. Orienting. Attention: focused, " +
11 "creative, producing. Writing notes, creating nodes, changing structures, running tools. " +
12 "Spending what was gathered. Scattered: bouncing between many branches without depth. " +
13 "Four or more distinct nodes touched with low write activity. Movement without traction. " +
14 "\n\n" +
15 "Every afterNote, afterNodeCreate, afterNavigate, afterToolCall fires a typed signal " +
16 "into a rolling window of the last 20 interactions per user. The window size, awareness " +
17 "threshold, attention threshold, scattered branch threshold, and history depth are all " +
18 "land-configurable. Detection runs on every signal. Navigate, read, and query count as " +
19 "read types. Write, create, and tool count as write types. The ratio determines the " +
20 "phase. Scattered triggers when the distinct node count crosses the branch threshold " +
21 "and write ratio stays below 30%. " +
22 "\n\n" +
23 "enrichContext injects the detected phase into the AI prompt. During awareness the AI " +
24 "shifts toward showing, surfacing, connecting. During attention the AI shifts toward " +
25 "doing, building, executing. During scattered the AI gently reflects what it sees. " +
26 "You have touched four branches in the last ten minutes without writing anything. " +
27 "Are you looking for something specific? Not judgment. Observation. The tree reflects " +
28 "you back so you can see your own pattern. " +
29 "\n\n" +
30 "The most valuable moment is the transition. The user was in awareness for twenty " +
31 "minutes. Now they write their first note. The AI sees the transition signal and says: " +
32 "you have been exploring this branch for a while. Based on what you read, here is what " +
33 "I think you are about to work on. The gathered context crystallizes into the prompt " +
34 "for attention. The reverse: deep attention for two hours, then navigation. The AI says: " +
35 "good stopping point. Here is a summary of what you just built. Transition detection " +
36 "feeds inverse-tree when installed, sending phase-transition signals with from/to and " +
37 "confidence scores. " +
38 "\n\n" +
39 "Phase history tracks every transition with start time, end time, duration, and origin " +
40 "phase. Cycle stats compute the percentage split across awareness, attention, and " +
41 "scattered over all tracked time. The user sees their own patterns. 70% attention, " +
42 "10% awareness, 20% scattered. Three API endpoints: current phase with recent " +
43 "interactions, full transition history, and the awareness vs attention ratio. The " +
44 "tree does not tell you how to work. It shows you how you already do.",
45
46 needs: {
47 services: ["hooks"],
48 models: ["User"],
49 },
50
51 optional: {
52 extensions: ["inverse-tree", "evolution"],
53 },
54
55 provides: {
56 models: {},
57 routes: false,
58 tools: false,
59 jobs: false,
60 orchestrator: false,
61 energyActions: {},
62 sessionTypes: {},
63
64 hooks: {
65 fires: [],
66 listens: ["afterNote", "afterNodeCreate", "afterNavigate", "afterToolCall", "enrichContext"],
67 },
68
69 cli: [
70 {
71 command: "phase [action]", scope: ["tree"],
72 description: "Current phase and session stats. Actions: history, cycle.",
73 method: "GET",
74 endpoint: "/user/:userId/phase",
75 subcommands: {
76 "history": { method: "GET", endpoint: "/user/:userId/phase/history", description: "Your phase patterns over time" },
77 "cycle": { method: "GET", endpoint: "/user/:userId/phase/cycle", description: "Awareness vs attention ratio" },
78 },
79 },
80 ],
81 },
82};
831import express from "express";
2import { sendOk, sendError, ERR } from "../../seed/protocol.js";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { getPhaseState, computeCycleStats } from "./core.js";
5
6const router = express.Router();
7
8// GET /user/:userId/phase - Current phase and session stats
9router.get("/user/:userId/phase", authenticate, async (req, res) => {
10 try {
11 const phaseMeta = await getPhaseState(req.params.userId);
12 if (!phaseMeta) {
13 return sendOk(res, {
14 phase: null,
15 message: "No phase data yet. Interact with a tree to start tracking.",
16 });
17 }
18
19 sendOk(res, {
20 currentPhase: phaseMeta.currentPhase || null,
21 confidence: phaseMeta.phaseConfidence || 0,
22 windowSize: (phaseMeta.window || []).length,
23 recentInteractions: (phaseMeta.window || []).slice(-5).map(s => ({
24 type: s.type,
25 at: new Date(s.at).toISOString(),
26 })),
27 });
28 } catch (err) {
29 sendError(res, 500, ERR.INTERNAL, err.message);
30 }
31});
32
33// GET /user/:userId/phase/history - Phase patterns over time
34router.get("/user/:userId/phase/history", authenticate, async (req, res) => {
35 try {
36 const phaseMeta = await getPhaseState(req.params.userId);
37 if (!phaseMeta?.history?.length) {
38 return sendOk(res, { history: [], message: "No phase history yet." });
39 }
40
41 const history = phaseMeta.history.slice(-50).map(entry => ({
42 phase: entry.phase,
43 startAt: new Date(entry.startAt).toISOString(),
44 endAt: entry.endAt ? new Date(entry.endAt).toISOString() : null,
45 durationMinutes: entry.durationMs ? Math.round(entry.durationMs / 60000) : null,
46 transitionFrom: entry.transitionFrom || null,
47 }));
48
49 sendOk(res, { history });
50 } catch (err) {
51 sendError(res, 500, ERR.INTERNAL, err.message);
52 }
53});
54
55// GET /user/:userId/phase/cycle - Awareness vs attention ratio
56router.get("/user/:userId/phase/cycle", authenticate, async (req, res) => {
57 try {
58 const phaseMeta = await getPhaseState(req.params.userId);
59 if (!phaseMeta?.history?.length) {
60 return sendOk(res, { cycle: null, message: "No phase history yet." });
61 }
62
63 const stats = computeCycleStats(phaseMeta.history);
64 sendOk(res, {
65 cycle: {
66 awarenessPercent: stats.awareness,
67 attentionPercent: stats.attention,
68 scatteredPercent: stats.scattered,
69 totalTrackedMinutes: Math.round(stats.total / 60000),
70 },
71 });
72 } catch (err) {
73 sendError(res, 500, ERR.INTERNAL, err.message);
74 }
75});
76
77export default router;
78
treeos ext star phase
Post comments from the CLI: treeos ext comment phase "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...