1/**
2 * Digest Core
3 *
4 * Collects overnight activity from every installed extension,
5 * sends one combined prompt to the AI, writes the briefing to
6 * the land root metadata. Optionally pushes to a gateway channel.
7 */
8
9import log from "../../seed/log.js";
10import Node from "../../seed/models/node.js";
11import { SYSTEM_ROLE } from "../../seed/protocol.js";
12import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
13
14let _runChat = null;
15let _metadata = null;
16export function setRunChat(fn) { _runChat = fn; }
17export function configure({ metadata }) { _metadata = metadata; }
18
19const MAX_HISTORY = 30;
20
21// ─────────────────────────────────────────────────────────────────────────
22// COLLECTORS
23// ─────────────────────────────────────────────────────────────────────────
24
25/**
26 * Collect all overnight signals from installed extensions.
27 * Each collector is independent. Missing extensions return null.
28 */
29async function collectSignals(landRootId) {
30 const signals = {};
31 let getExtension;
32 try {
33 ({ getExtension } = await import("../loader.js"));
34 } catch {
35 return signals;
36 }
37
38 const since = new Date(Date.now() - 24 * 60 * 60 * 1000); // last 24h
39
40 // Changelog: what changed
41 try {
42 const ext = getExtension("changelog");
43 if (ext?.exports?.getChangelog) {
44 // Intentionally not calling summarize here. We want raw contribution counts.
45 // The digest AI will synthesize everything in one pass.
46 const { contributions } = await ext.exports.getChangelog(landRootId, { since: "24h", land: true });
47 if (contributions?.length > 0) {
48 signals.changelog = `${contributions.length} contributions across the land in the last 24 hours.`;
49 }
50 }
51 } catch (err) { log.debug("Digest", `changelog: ${err.message}`); }
52
53 // Intent: what the tree did autonomously
54 try {
55 const roots = await Node.find({
56 rootOwner: { $nin: [null, "SYSTEM"] },
57 "metadata.intent.enabled": true,
58 }).select("_id name metadata").lean();
59
60 const intentSummaries = [];
61 for (const root of roots) {
62 const meta = root.metadata instanceof Map
63 ? root.metadata.get("intent")
64 : root.metadata?.intent;
65 const recent = (meta?.recentExecutions || [])
66 .filter(e => new Date(e.executedAt) >= since)
67 .slice(0, 5);
68 if (recent.length > 0) {
69 intentSummaries.push(`${root.name}: ${recent.map(e => e.action).join("; ")}`);
70 }
71 }
72 if (intentSummaries.length > 0) {
73 signals.intent = intentSummaries.join("\n");
74 }
75 } catch (err) { log.debug("Digest", `intent: ${err.message}`); }
76
77 // Dreams: what background maintenance ran
78 try {
79 const ext = getExtension("dreams");
80 if (ext) {
81 const roots = await Node.find({
82 rootOwner: { $nin: [null, "SYSTEM"] },
83 "metadata.dreams.lastDreamAt": { $gte: since },
84 }).select("_id name metadata").lean();
85
86 if (roots.length > 0) {
87 signals.dreams = roots.map(r => `${r.name}: dreamed recently`).join("; ");
88 }
89 }
90 } catch (err) { log.debug("Digest", `dreams: ${err.message}`); }
91
92 // Prune: what was removed
93 try {
94 const ext = getExtension("prune");
95 if (ext) {
96 const roots = await Node.find({
97 rootOwner: { $nin: [null, "SYSTEM"] },
98 }).select("_id name metadata").lean();
99
100 const pruneSummaries = [];
101 for (const root of roots) {
102 const meta = root.metadata instanceof Map
103 ? root.metadata.get("prune")
104 : root.metadata?.prune;
105 if (meta?.lastPrunedAt && new Date(meta.lastPrunedAt) >= since) {
106 const count = meta.pruneHistory?.filter(h => new Date(h.date) >= since).length || 0;
107 if (count > 0) pruneSummaries.push(`${root.name}: ${count} nodes pruned`);
108 }
109 }
110 if (pruneSummaries.length > 0) {
111 signals.prune = pruneSummaries.join("; ");
112 }
113 }
114 } catch (err) { log.debug("Digest", `prune: ${err.message}`); }
115
116 // Purpose: coherence drift
117 try {
118 const ext = getExtension("purpose");
119 if (ext) {
120 const roots = await Node.find({
121 rootOwner: { $nin: [null, "SYSTEM"] },
122 "metadata.purpose.thesis": { $exists: true },
123 }).select("_id name metadata").lean();
124
125 const drifts = [];
126 for (const root of roots) {
127 const meta = root.metadata instanceof Map
128 ? root.metadata.get("purpose")
129 : root.metadata?.purpose;
130 if (meta?.recentCoherence !== undefined && meta.recentCoherence < 0.6) {
131 drifts.push(`${root.name}: coherence ${(meta.recentCoherence * 100).toFixed(0)}%`);
132 }
133 }
134 if (drifts.length > 0) {
135 signals.purpose = `Coherence drift: ${drifts.join("; ")}`;
136 }
137 }
138 } catch (err) { log.debug("Digest", `purpose: ${err.message}`); }
139
140 // Evolution: dormancy
141 try {
142 const ext = getExtension("evolution");
143 if (ext?.exports?.getDormant) {
144 const roots = await Node.find({
145 rootOwner: { $nin: [null, "SYSTEM"] },
146 }).select("_id name").lean();
147
148 const dormantSummaries = [];
149 for (const root of roots) {
150 try {
151 const dormant = await ext.exports.getDormant(String(root._id));
152 if (dormant?.length > 0) {
153 dormantSummaries.push(`${root.name}: ${dormant.length} dormant branch${dormant.length > 1 ? "es" : ""}`);
154 }
155 } catch {}
156 }
157 if (dormantSummaries.length > 0) {
158 signals.evolution = dormantSummaries.join("; ");
159 }
160 }
161 } catch (err) { log.debug("Digest", `evolution: ${err.message}`); }
162
163 // Pulse: land health
164 try {
165 const ext = getExtension("pulse");
166 if (ext?.exports?.getLatestSnapshot) {
167 const snapshot = await ext.exports.getLatestSnapshot();
168 if (snapshot) {
169 const parts = [];
170 if (snapshot.failureRate > 0) parts.push(`failure rate: ${(snapshot.failureRate * 100).toFixed(1)}%`);
171 if (snapshot.elevated) parts.push("ELEVATED");
172 if (snapshot.totalToday > 0) parts.push(`${snapshot.totalToday} cascade signals today`);
173 if (parts.length > 0) signals.pulse = parts.join(", ");
174 }
175 }
176 } catch (err) { log.debug("Digest", `pulse: ${err.message}`); }
177
178 // Delegate: pending suggestions
179 try {
180 const ext = getExtension("delegate");
181 if (ext?.exports?.getSuggestions) {
182 const roots = await Node.find({
183 rootOwner: { $nin: [null, "SYSTEM"] },
184 contributors: { $exists: true, $not: { $size: 0 } },
185 }).select("_id").lean();
186
187 let totalPending = 0;
188 for (const root of roots) {
189 try {
190 const suggestions = await ext.exports.getSuggestions(String(root._id));
191 totalPending += suggestions.length;
192 } catch {}
193 }
194 if (totalPending > 0) {
195 signals.delegate = `${totalPending} pending delegate suggestion${totalPending > 1 ? "s" : ""}`;
196 }
197 }
198 } catch (err) { log.debug("Digest", `delegate: ${err.message}`); }
199
200 return signals;
201}
202
203// ─────────────────────────────────────────────────────────────────────────
204// BRIEFING GENERATION
205// ─────────────────────────────────────────────────────────────────────────
206
207/**
208 * Generate the daily digest briefing.
209 */
210export async function generateDigest() {
211 // Find the land root
212 const landRoot = await Node.findOne({ systemRole: SYSTEM_ROLE.LAND_ROOT });
213 if (!landRoot) {
214 log.debug("Digest", "No land root found");
215 return null;
216 }
217
218 const signals = await collectSignals(String(landRoot._id));
219
220 if (Object.keys(signals).length === 0) {
221 const briefing = {
222 date: new Date().toISOString().slice(0, 10),
223 summary: "Nothing notable happened in the last 24 hours. The land is quiet.",
224 signals: {},
225 generatedAt: new Date().toISOString(),
226 };
227 await writeDigest(landRoot, briefing);
228 return briefing;
229 }
230
231 // Build the prompt
232 const signalText = Object.entries(signals)
233 .map(([source, text]) => `[${source}] ${text}`)
234 .join("\n");
235
236 const prompt =
237 `Write a morning briefing for this land. What happened overnight. What needs attention. ` +
238 `What the tree did on its own. What's healthy. What's drifting. Keep it short.\n\n` +
239 `Signals from the last 24 hours:\n${signalText}\n\n` +
240 `Return ONLY JSON:\n` +
241 `{\n` +
242 ` "summary": "2-4 sentence overview",\n` +
243 ` "overnight": ["what happened while you were away"],\n` +
244 ` "needsAttention": ["what you should look at today"],\n` +
245 ` "healthy": ["what is running well"],\n` +
246 ` "drifting": ["what is going off track"]\n` +
247 `}`;
248
249 let parsed = null;
250 if (_runChat) {
251 try {
252 const { answer } = await _runChat({
253 userId: "SYSTEM",
254 username: "digest",
255 message: prompt,
256 mode: "tree:respond",
257 rootId: null,
258 slot: "digest",
259 });
260 if (answer) parsed = parseJsonSafe(answer);
261 } catch (err) {
262 log.debug("Digest", `AI briefing failed: ${err.message}`);
263 }
264 }
265
266 const briefing = {
267 date: new Date().toISOString().slice(0, 10),
268 summary: parsed?.summary || signalText,
269 overnight: parsed?.overnight || [],
270 needsAttention: parsed?.needsAttention || [],
271 healthy: parsed?.healthy || [],
272 drifting: parsed?.drifting || [],
273 signals,
274 generatedAt: new Date().toISOString(),
275 };
276
277 await writeDigest(landRoot, briefing);
278
279 // Push to gateway if configured
280 try {
281 const digestMeta = _metadata.getExtMeta(landRoot, "digest") || {};
282 if (digestMeta.gatewayChannel) {
283 const { getExtension } = await import("../loader.js");
284 const gatewayExt = getExtension("gateway");
285 if (gatewayExt?.exports?.sendNotification) {
286 const text = formatBriefingForChannel(briefing);
287 await gatewayExt.exports.sendNotification(digestMeta.gatewayChannel, {
288 type: "digest",
289 title: `Daily Digest: ${briefing.date}`,
290 content: text,
291 });
292 log.verbose("Digest", `Briefing pushed to gateway channel ${digestMeta.gatewayChannel}`);
293 }
294 }
295 } catch (err) {
296 log.debug("Digest", `Gateway push failed: ${err.message}`);
297 }
298
299 return briefing;
300}
301
302/**
303 * Write the briefing to land root metadata.
304 */
305async function writeDigest(landRoot, briefing) {
306 try {
307 const meta = _metadata.getExtMeta(landRoot, "digest") || {};
308 meta.latest = briefing;
309
310 if (!meta.history) meta.history = [];
311 meta.history.unshift({
312 date: briefing.date,
313 summary: briefing.summary,
314 generatedAt: briefing.generatedAt,
315 });
316 meta.history = meta.history.slice(0, MAX_HISTORY);
317
318 await _metadata.setExtMeta(landRoot, "digest", meta);
319 } catch (err) {
320 log.debug("Digest", `Failed to write digest: ${err.message}`);
321 }
322}
323
324/**
325 * Format briefing for gateway channel delivery.
326 */
327function formatBriefingForChannel(briefing) {
328 const parts = [briefing.summary];
329
330 if (briefing.overnight?.length > 0) {
331 parts.push(`\nOvernight: ${briefing.overnight.join(". ")}`);
332 }
333 if (briefing.needsAttention?.length > 0) {
334 parts.push(`\nNeeds attention: ${briefing.needsAttention.join(". ")}`);
335 }
336 if (briefing.drifting?.length > 0) {
337 parts.push(`\nDrifting: ${briefing.drifting.join(". ")}`);
338 }
339
340 return parts.join("\n");
341}
342
343// ─────────────────────────────────────────────────────────────────────────
344// READ
345// ─────────────────────────────────────────────────────────────────────────
346
347/**
348 * Get the latest digest.
349 */
350export async function getLatestDigest() {
351 const landRoot = await Node.findOne({ systemRole: SYSTEM_ROLE.LAND_ROOT }).select("metadata").lean();
352 if (!landRoot) return null;
353
354 const meta = landRoot.metadata instanceof Map
355 ? landRoot.metadata.get("digest")
356 : landRoot.metadata?.digest;
357
358 return meta?.latest || null;
359}
360
361/**
362 * Get digest history.
363 */
364export async function getDigestHistory() {
365 const landRoot = await Node.findOne({ systemRole: SYSTEM_ROLE.LAND_ROOT }).select("metadata").lean();
366 if (!landRoot) return [];
367
368 const meta = landRoot.metadata instanceof Map
369 ? landRoot.metadata.get("digest")
370 : landRoot.metadata?.digest;
371
372 return meta?.history || [];
373}
374
375/**
376 * Get digest config (delivery time, channel, scope).
377 */
378export async function getDigestConfig() {
379 const landRoot = await Node.findOne({ systemRole: SYSTEM_ROLE.LAND_ROOT }).select("metadata").lean();
380 if (!landRoot) return {};
381
382 const meta = landRoot.metadata instanceof Map
383 ? landRoot.metadata.get("digest")
384 : landRoot.metadata?.digest;
385
386 return {
387 gatewayChannel: meta?.gatewayChannel || null,
388 deliveryHour: meta?.deliveryHour ?? 7,
389 enabled: meta?.enabled !== false,
390 };
391}
392
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { configure, setRunChat, generateDigest, getDigestConfig } from "./core.js";
4import { getLandConfigValue } from "../../seed/landConfig.js";
5
6let _jobTimer = null;
7
8export async function init(core) {
9 configure({ metadata: core.metadata });
10 core.llm.registerRootLlmSlot("digest");
11 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
12 setRunChat(async (opts) => {
13 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
14 return core.llm.runChat({ ...opts, llmPriority: BG });
15 });
16
17 const { default: router } = await import("./routes.js");
18
19 log.verbose("Digest", "Digest loaded");
20
21 return {
22 router,
23 tools,
24 jobs: [
25 {
26 name: "digest-daily",
27 start: () => {
28 // Check every hour if it's time to run the daily digest
29 _jobTimer = setInterval(async () => {
30 try {
31 const config = await getDigestConfig();
32 if (config.enabled === false) return;
33
34 const now = new Date();
35 const hour = now.getHours();
36 const deliveryHour = config.deliveryHour ?? 7;
37
38 // Run if current hour matches delivery hour
39 if (hour !== deliveryHour) return;
40
41 // Check if already ran today
42 const { getLatestDigest } = await import("./core.js");
43 const latest = await getLatestDigest();
44 if (latest?.date === now.toISOString().slice(0, 10)) return;
45
46 log.verbose("Digest", "Running daily digest");
47 await generateDigest();
48 } catch (err) {
49 log.error("Digest", `Daily digest failed: ${err.message}`);
50 }
51 }, 60 * 60 * 1000); // check every hour
52 if (_jobTimer.unref) _jobTimer.unref();
53 },
54 stop: () => {
55 if (_jobTimer) {
56 clearInterval(_jobTimer);
57 _jobTimer = null;
58 }
59 },
60 },
61 ],
62 exports: {
63 generateDigest,
64 },
65 };
66}
67
1export default {
2 name: "digest",
3 version: "1.0.1",
4 builtFor: "treeos-maintenance",
5 description:
6 "The tree's daily newspaper. Written by the tree about itself. Runs once daily, reads " +
7 "changelog, intent history, dream logs, prune history, purpose coherence trends, evolution " +
8 "dormancy alerts, pulse health, and delegate suggestions. Sends one combined summary to the " +
9 "AI: write a morning briefing for this land. What happened overnight. What needs attention. " +
10 "What the tree did on its own. What's healthy. What's drifting. Result writes to metadata on " +
11 "the land root. If gateway is installed, pushes the briefing to a configured channel.",
12
13 needs: {
14 services: ["hooks", "llm", "metadata"],
15 models: ["Node"],
16 },
17
18 optional: {
19 extensions: [
20 "changelog",
21 "intent",
22 "dreams",
23 "prune",
24 "purpose",
25 "evolution",
26 "pulse",
27 "delegate",
28 "gateway",
29 ],
30 },
31
32 provides: {
33 models: {},
34 routes: "./routes.js",
35 tools: true,
36 jobs: true,
37 orchestrator: false,
38 energyActions: {},
39 sessionTypes: {},
40
41 hooks: {
42 fires: [],
43 listens: [],
44 },
45
46 cli: [
47 {
48 command: "digest [action]", scope: ["tree"],
49 description: "The tree's daily briefing. Actions: history, config",
50 method: "GET",
51 endpoint: "/land/digest",
52 subcommands: {
53 history: {
54 method: "GET",
55 endpoint: "/land/digest/history",
56 description: "Past briefings",
57 },
58 config: {
59 method: "GET",
60 endpoint: "/land/digest/config",
61 description: "Delivery time, channel, scope",
62 },
63 },
64 },
65 ],
66 },
67};
68
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getLatestDigest, getDigestHistory, getDigestConfig, generateDigest } from "./core.js";
5
6const router = express.Router();
7
8// GET /land/digest - show latest briefing
9router.get("/land/digest", authenticate, async (req, res) => {
10 try {
11 let digest = await getLatestDigest();
12 if (!digest) {
13 digest = await generateDigest();
14 }
15 if (!digest) return sendOk(res, { message: "No digest available." });
16 sendOk(res, digest);
17 } catch (err) {
18 sendError(res, 500, ERR.INTERNAL, err.message);
19 }
20});
21
22// GET /land/digest/history - past briefings
23router.get("/land/digest/history", authenticate, async (req, res) => {
24 try {
25 const history = await getDigestHistory();
26 sendOk(res, { history });
27 } catch (err) {
28 sendError(res, 500, ERR.INTERNAL, err.message);
29 }
30});
31
32// GET /land/digest/config - delivery settings
33router.get("/land/digest/config", authenticate, async (req, res) => {
34 try {
35 const config = await getDigestConfig();
36 sendOk(res, config);
37 } catch (err) {
38 sendError(res, 500, ERR.INTERNAL, err.message);
39 }
40});
41
42export default router;
43
1import { z } from "zod";
2import { getLatestDigest, getDigestHistory, generateDigest } from "./core.js";
3
4export default [
5 {
6 name: "digest-show",
7 description:
8 "Show today's daily briefing. What happened overnight, what needs attention, " +
9 "what the tree did on its own, what's healthy, what's drifting.",
10 schema: {
11 userId: z.string().describe("Injected by server. Ignore."),
12 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
13 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
14 },
15 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
16 handler: async () => {
17 try {
18 let digest = await getLatestDigest();
19 if (!digest) {
20 // Generate on demand if none exists
21 digest = await generateDigest();
22 }
23 if (!digest) {
24 return { content: [{ type: "text", text: "No digest available." }] };
25 }
26 return {
27 content: [{
28 type: "text",
29 text: JSON.stringify({
30 date: digest.date,
31 summary: digest.summary,
32 overnight: digest.overnight,
33 needsAttention: digest.needsAttention,
34 healthy: digest.healthy,
35 drifting: digest.drifting,
36 }, null, 2),
37 }],
38 };
39 } catch (err) {
40 return { content: [{ type: "text", text: `Digest failed: ${err.message}` }] };
41 }
42 },
43 },
44 {
45 name: "digest-history",
46 description: "Past daily briefings.",
47 schema: {
48 userId: z.string().describe("Injected by server. Ignore."),
49 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
50 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
51 },
52 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
53 handler: async () => {
54 try {
55 const history = await getDigestHistory();
56 if (history.length === 0) {
57 return { content: [{ type: "text", text: "No digest history." }] };
58 }
59 return { content: [{ type: "text", text: JSON.stringify(history, null, 2) }] };
60 } catch (err) {
61 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
62 }
63 },
64 },
65];
66
Loading comments...