EXTENSION for treeos-maintenance
changelog
What changed since you last looked. Reads contributions (the kernel's audit trail) and constructs a narrative. Scoped to subtree by default. Shows new work, completed work, decisions, stalled areas, and autonomous activity from intent and dreams.
v1.0.1 by TreeOS Site 0 downloads 5 files 432 lines 13.4 KB published 38d ago
treeos ext install changelog
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

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

Optional

  • extensions: intent, dreams
SHA256: facb4ebe4c7682fc48e929ab737115c75d1681ea2495baafef6e8c751635da7c

Dependents

1 package depend on this

PackageTypeRelationship
treeos-maintenance v1.0.1bundleincludes

CLI Commands

CommandMethodDescription
changelog [args...]GETWhat changed at this branch. --since 7d, --user <name>, --land

Source Code

1/**
2 * Changelog Core
3 *
4 * Reads contributions scoped to a subtree, groups by node, and sends
5 * to the AI for narrative construction. One LLM call with BACKGROUND priority.
6 */
7
8import log from "../../seed/log.js";
9import Node from "../../seed/models/node.js";
10import Contribution from "../../seed/models/contribution.js";
11import { getDescendantIds } from "../../seed/tree/treeFetch.js";
12import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
13
14let _runChat = null;
15export function setRunChat(fn) { _runChat = fn; }
16
17// ─────────────────────────────────────────────────────────────────────────
18// TIME PARSING
19// ─────────────────────────────────────────────────────────────────────────
20
21/**
22 * Parse a since string like "24h", "7d", "2w", "30d", or an ISO date.
23 */
24export function parseSince(since) {
25  if (!since) return new Date(Date.now() - 86400000); // default 24h
26
27  if (typeof since === "string") {
28    const match = since.match(/^(\d+)(h|d|w|m)$/);
29    if (match) {
30      const n = parseInt(match[1], 10);
31      const unit = match[2];
32      const ms = { h: 3600000, d: 86400000, w: 604800000, m: 2592000000 }[unit];
33      return new Date(Date.now() - n * ms);
34    }
35    // Try ISO date
36    const d = new Date(since);
37    if (!isNaN(d.getTime())) return d;
38  }
39
40  return new Date(Date.now() - 86400000); // fallback 24h
41}
42
43// ─────────────────────────────────────────────────────────────────────────
44// CONTRIBUTION FETCHING
45// ─────────────────────────────────────────────────────────────────────────
46
47/**
48 * Get contributions scoped to a subtree within a time window.
49 */
50export async function getChangelog(nodeId, opts = {}) {
51  const since = parseSince(opts.since);
52  const limit = opts.limit || 500;
53
54  // Scope: subtree or land
55  let scopeIds;
56  if (opts.land) {
57    // Land scope: all contributions
58    scopeIds = null; // no nodeId filter
59  } else {
60    const descendantIds = await getDescendantIds(nodeId, { maxResults: 10000 });
61    scopeIds = [nodeId, ...descendantIds];
62  }
63
64  const query = { date: { $gte: since } };
65  if (scopeIds) query.nodeId = { $in: scopeIds };
66  if (opts.userId) query.userId = opts.userId;
67
68  const contributions = await Contribution.find(query)
69    .sort({ date: -1 })
70    .limit(limit)
71    .lean();
72
73  return { contributions, since, scopeIds };
74}
75
76/**
77 * Group contributions by nodeId with action counts.
78 */
79function groupContributions(contributions) {
80  const byNode = new Map();
81
82  for (const c of contributions) {
83    const nid = c.nodeId || "unknown";
84    if (!byNode.has(nid)) {
85      byNode.set(nid, {
86        nodeId: nid,
87        actions: {},
88        users: new Set(),
89        autonomous: [],
90        count: 0,
91        lastDate: null,
92      });
93    }
94    const group = byNode.get(nid);
95    group.actions[c.action] = (group.actions[c.action] || 0) + 1;
96    group.count++;
97    if (c.userId) group.users.add(c.userId);
98    if (!group.lastDate || c.date > group.lastDate) group.lastDate = c.date;
99
100    // Track autonomous activity (intent, dreams)
101    if (c.extensionData?.intent || c.action?.startsWith("intent:")) {
102      group.autonomous.push({ by: "intent", action: c.action, date: c.date });
103    }
104    if (c.extensionData?.dreams || c.action?.startsWith("dream:")) {
105      group.autonomous.push({ by: "dreams", action: c.action, date: c.date });
106    }
107  }
108
109  return byNode;
110}
111
112/**
113 * Detect stalled areas: active in previous window, silent in current.
114 */
115export async function getStalled(nodeId, since, previousWindowMs) {
116  const prevSince = new Date(since.getTime() - (previousWindowMs || since.getTime() - Date.now() + 86400000));
117  const descendantIds = await getDescendantIds(nodeId, { maxResults: 10000 });
118  const scopeIds = [nodeId, ...descendantIds];
119
120  // Nodes active in previous window
121  const prevContribs = await Contribution.distinct("nodeId", {
122    nodeId: { $in: scopeIds },
123    date: { $gte: prevSince, $lt: since },
124  });
125
126  // Nodes active in current window
127  const currentContribs = await Contribution.distinct("nodeId", {
128    nodeId: { $in: scopeIds },
129    date: { $gte: since },
130  });
131
132  const currentSet = new Set(currentContribs);
133  const stalled = prevContribs.filter(id => !currentSet.has(id));
134
135  // Get names for stalled nodes
136  if (stalled.length === 0) return [];
137  const nodes = await Node.find({ _id: { $in: stalled } }).select("_id name").lean();
138  const nameMap = new Map(nodes.map(n => [String(n._id), n.name]));
139
140  return stalled.map(id => ({
141    nodeId: id,
142    nodeName: nameMap.get(id) || "unknown",
143    lastActivity: prevSince.toISOString(),
144  }));
145}
146
147// ─────────────────────────────────────────────────────────────────────────
148// AI NARRATIVE
149// ─────────────────────────────────────────────────────────────────────────
150
151/**
152 * Summarize contributions into a narrative via AI.
153 */
154export async function summarizeChangelog(nodeId, contributions, userId, username, opts = {}) {
155  if (!_runChat || contributions.length === 0) {
156    return buildRawSummary(contributions, nodeId);
157  }
158
159  const grouped = groupContributions(contributions);
160
161  // Get node names for all groups
162  const nodeIds = [...grouped.keys()].filter(id => id !== "unknown");
163  const nodes = await Node.find({ _id: { $in: nodeIds } }).select("_id name").lean();
164  const nameMap = new Map(nodes.map(n => [String(n._id), n.name]));
165
166  // Build summary text for the prompt
167  const sections = [];
168  for (const [nid, group] of grouped) {
169    const name = nameMap.get(nid) || nid;
170    const actionStr = Object.entries(group.actions)
171      .map(([a, count]) => `${a}: ${count}`)
172      .join(", ");
173    const autoStr = group.autonomous.length > 0
174      ? ` (autonomous: ${group.autonomous.map(a => `${a.by}:${a.action}`).join(", ")})`
175      : "";
176    sections.push(`${name}: ${actionStr}, ${group.users.size} user(s)${autoStr}`);
177  }
178
179  // Get stalled areas
180  const since = parseSince(opts.since);
181  let stalledInfo = "";
182  try {
183    const stalled = await getStalled(nodeId, since, since.getTime() - Date.now() + 86400000);
184    if (stalled.length > 0) {
185      stalledInfo = `\n\nStalled areas (active before, silent now): ${stalled.map(s => s.nodeName).join(", ")}`;
186    }
187  } catch {
188    // Stalled detection failure is non-fatal
189  }
190
191  const prompt = `Summarize what changed in this branch. Focus on:
192- New work (nodes created, notes written)
193- Completed work (status changes to completed)
194- Decisions (notes with high engagement)
195- Stalled areas (active last period, nothing this period)
196- Autonomous activity (contributions from intent, dreams)
197
198Activity (${contributions.length} contributions):
199${sections.join("\n")}${stalledInfo}
200
201Return ONLY JSON:
202{
203  "new": [{ "nodeName": "...", "summary": "..." }],
204  "active": [{ "nodeName": "...", "noteCount": 0, "summary": "..." }],
205  "completed": [{ "nodeName": "...", "completedAt": "..." }],
206  "stalled": [{ "nodeName": "...", "lastActivity": "..." }],
207  "autonomous": [{ "action": "...", "by": "intent|dreams", "summary": "..." }],
208  "contributors": [{ "username": "...", "actions": 0 }],
209  "summary": "one paragraph overview"
210}`;
211
212  // Find rootId for runChat
213  let rootId = null;
214  try {
215    const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
216    const root = await resolveRootNode(nodeId);
217    rootId = root?._id;
218  } catch {
219    // Root resolution failure is non-fatal
220  }
221
222  try {
223    const { answer } = await _runChat({
224      userId,
225      username: username || "system",
226      message: prompt,
227      mode: "tree:respond",
228      rootId,
229      slot: "changelog",
230    });
231
232    if (!answer) return buildRawSummary(contributions, nodeId);
233    const parsed = parseJsonSafe(answer);
234    if (!parsed) return buildRawSummary(contributions, nodeId);
235    return parsed;
236  } catch (err) {
237    log.debug("Changelog", `AI summarization failed: ${err.message}`);
238    return buildRawSummary(contributions, nodeId);
239  }
240}
241
242/**
243 * Fallback raw summary when AI is unavailable.
244 */
245function buildRawSummary(contributions, nodeId) {
246  const grouped = groupContributions(contributions);
247  const allUsers = new Set();
248  const allAutonomous = [];
249
250  for (const group of grouped.values()) {
251    for (const u of group.users) allUsers.add(u);
252    allAutonomous.push(...group.autonomous);
253  }
254
255  return {
256    new: [],
257    active: [],
258    completed: [],
259    stalled: [],
260    autonomous: allAutonomous.map(a => ({ action: a.action, by: a.by, summary: a.action })),
261    contributors: [...allUsers].map(u => ({ username: u, actions: contributions.filter(c => c.userId === u).length })),
262    summary: `${contributions.length} contributions across ${grouped.size} nodes by ${allUsers.size} user(s).`,
263  };
264}
265
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setRunChat } from "./core.js";
4
5export async function init(core) {
6  const BG = core.llm.LLM_PRIORITY.BACKGROUND;
7
8  core.llm.registerRootLlmSlot?.("changelog");
9
10  setRunChat(async (opts) => {
11    if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
12    return core.llm.runChat({ ...opts, llmPriority: BG });
13  });
14
15  const { default: router } = await import("./routes.js");
16
17  log.verbose("Changelog", "Changelog loaded");
18
19  return {
20    router,
21    tools,
22  };
23}
24
1export default {
2  name: "changelog",
3  version: "1.0.1",
4  builtFor: "treeos-maintenance",
5  description:
6    "What changed since you last looked. Reads contributions (the kernel's audit trail) " +
7    "and constructs a narrative. Scoped to subtree by default. Shows new work, completed " +
8    "work, decisions, stalled areas, and autonomous activity from intent and dreams.",
9
10  needs: {
11    services: ["hooks", "llm", "metadata"],
12    models: ["Node", "Contribution"],
13  },
14
15  optional: {
16    extensions: ["intent", "dreams"],
17  },
18
19  provides: {
20    models: {},
21    routes: "./routes.js",
22    tools: true,
23    jobs: false,
24    orchestrator: false,
25    energyActions: {},
26    sessionTypes: {},
27
28    hooks: {
29      fires: [],
30      listens: [],
31    },
32
33    cli: [
34      {
35        command: "changelog [args...]", scope: ["tree"],
36        description: "What changed at this branch. --since 7d, --user <name>, --land",
37        method: "GET",
38        endpoint: "/node/:nodeId/changelog",
39      },
40    ],
41  },
42};
43
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getChangelog, summarizeChangelog, parseSince } from "./core.js";
5
6const router = express.Router();
7
8// GET /node/:nodeId/changelog
9// Query params: since (24h, 7d, 2w, 30d, ISO date), userId, land (boolean)
10router.get("/node/:nodeId/changelog", authenticate, async (req, res) => {
11  try {
12    const opts = {
13      since: req.query.since || "24h",
14      userId: req.query.userId || null,
15      land: req.query.land === "true",
16      limit: parseInt(req.query.limit) || 500,
17    };
18
19    const { contributions, since } = await getChangelog(req.params.nodeId, opts);
20
21    if (contributions.length === 0) {
22      return sendOk(res, {
23        summary: `No changes since ${since.toISOString()}.`,
24        contributions: 0,
25        since: since.toISOString(),
26      });
27    }
28
29    const narrative = await summarizeChangelog(
30      req.params.nodeId,
31      contributions,
32      req.userId,
33      req.username || "system",
34      opts,
35    );
36
37    sendOk(res, {
38      ...narrative,
39      contributions: contributions.length,
40      since: since.toISOString(),
41    });
42  } catch (err) {
43    sendError(res, 500, ERR.INTERNAL, err.message);
44  }
45});
46
47export default router;
48
1import { z } from "zod";
2import { getChangelog, summarizeChangelog } from "./core.js";
3
4export default [
5  {
6    name: "changelog-get",
7    description:
8      "Show what changed in this branch. Reads the contribution audit trail and " +
9      "constructs a narrative: new work, completed work, stalled areas, autonomous " +
10      "activity from intent and dreams.",
11    schema: {
12      nodeId: z.string().describe("The node to get changelog for (scoped to subtree)."),
13      since: z.string().optional().default("24h").describe("Time window: 24h, 7d, 2w, 30d, or ISO date."),
14      land: z.boolean().optional().default(false).describe("Scope to entire land instead of subtree."),
15      userId: z.string().describe("Injected by server. Ignore."),
16      username: z.string().optional().describe("Injected by server. Ignore."),
17      chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
18      sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
19    },
20    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
21    handler: async ({ nodeId, since, land, userId, username }) => {
22      try {
23        const { contributions } = await getChangelog(nodeId, { since, land });
24
25        if (contributions.length === 0) {
26          return { content: [{ type: "text", text: `No changes since ${since}.` }] };
27        }
28
29        const narrative = await summarizeChangelog(nodeId, contributions, userId, username || "system", { since });
30
31        return {
32          content: [{
33            type: "text",
34            text: JSON.stringify({
35              summary: narrative.summary,
36              new: narrative.new,
37              active: narrative.active,
38              completed: narrative.completed,
39              stalled: narrative.stalled,
40              autonomous: narrative.autonomous,
41              contributors: narrative.contributors,
42              totalContributions: contributions.length,
43            }, null, 2),
44          }],
45        };
46      } catch (err) {
47        return { content: [{ type: "text", text: `Changelog failed: ${err.message}` }] };
48      }
49    },
50  },
51];
52

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 changelog

Comments

Loading comments...

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