EXTENSION for treeos-maintenance
digest
The tree's daily newspaper. Written by the tree about itself. Runs once daily, reads changelog, intent history, dream logs, prune history, purpose coherence trends, evolution dormancy alerts, pulse health, and delegate suggestions. Sends one combined summary to the AI: write a morning briefing for this land. What happened overnight. What needs attention. What the tree did on its own. What's healthy. What's drifting. Result writes to metadata on the land root. If gateway is installed, pushes the briefing to a configured channel.
v1.0.1 by TreeOS Site 0 downloads 5 files 636 lines 19.7 KB published 38d ago
treeos ext install digest
View changelog

Manifest

Provides

  • routes
  • tools
  • jobs
  • 1 CLI commands

Requires

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

Optional

  • extensions: changelog, intent, dreams, prune, purpose, evolution, pulse, delegate, gateway
SHA256: ff9dc140122a8d564592c5cb65311628073e294ea02c0f5a839f0571d43ce2c4

Dependents

1 package depend on this

PackageTypeRelationship
treeos-maintenance v1.0.1bundleincludes

CLI Commands

CommandMethodDescription
digestGETThe tree's daily briefing. Actions: history, config
digest historyGETPast briefings
digest configGETDelivery time, channel, scope

Source Code

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

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 digest

Comments

Loading comments...

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