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
Loading comments...