1/**
2 * Learn Core
3 *
4 * Two phases of decomposition:
5 *
6 * 1. Structural scan (no AI). For text too long for a single AI call,
7 * splits at natural boundaries: markdown headings, numbered sections,
8 * double newlines, paragraph breaks. Creates rough chunks.
9 *
10 * 2. AI decomposition. For each chunk that fits in context, asks the AI
11 * to identify logical sections with titles. Creates child nodes.
12 * Children that are still too large go back to the queue.
13 *
14 * State lives in metadata.learn on the root node of the operation.
15 * Queue-based BFS. One node at a time. Can pause and resume.
16 */
17
18import log from "../../seed/log.js";
19import Node from "../../seed/models/node.js";
20import Note from "../../seed/models/note.js";
21import { createNode } from "../../seed/tree/treeManagement.js";
22import { createNote } from "../../seed/tree/notes.js";
23import { CONTENT_TYPE } from "../../seed/protocol.js";
24import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
25
26// Held from init
27let _runChat = null;
28export function setRunChat(fn) { _runChat = fn; }
29
30// ─────────────────────────────────────────────────────────────────────────
31// CONSTANTS
32// ─────────────────────────────────────────────────────────────────────────
33
34const DEFAULT_TARGET_SIZE = 3000; // chars per leaf note (roughly 500 words)
35const AI_CONTEXT_LIMIT = 12000; // max chars to send to AI in one call
36const MIN_SECTION_SIZE = 200; // don't create nodes for tiny fragments
37const MAX_SECTIONS_PER_PASS = 15; // cap sections AI can return per call
38const MAX_QUEUE_ITERATIONS = 500; // safety cap on total processing steps
39
40// ─────────────────────────────────────────────────────────────────────────
41// STATE
42// ─────────────────────────────────────────────────────────────────────────
43
44/**
45 * Initialize learn state on a node.
46 */
47export async function initLearnState(nodeId, targetSize) {
48 await Node.findByIdAndUpdate(nodeId, {
49 $set: {
50 "metadata.learn": {
51 status: "processing",
52 queue: [nodeId],
53 targetNoteSize: targetSize || DEFAULT_TARGET_SIZE,
54 nodesCreated: 0,
55 nodesProcessed: 0,
56 startedAt: new Date().toISOString(),
57 lastActivityAt: new Date().toISOString(),
58 },
59 },
60 });
61}
62
63export async function getLearnState(nodeId) {
64 const node = await Node.findById(nodeId).select("metadata").lean();
65 if (!node) return null;
66 const meta = node.metadata instanceof Map
67 ? node.metadata.get("learn") || null
68 : node.metadata?.learn || null;
69 return meta;
70}
71
72async function updateLearnState(rootId, updates) {
73 const setFields = {};
74 for (const [key, value] of Object.entries(updates)) {
75 setFields[`metadata.learn.${key}`] = value;
76 }
77 setFields["metadata.learn.lastActivityAt"] = new Date().toISOString();
78 await Node.findByIdAndUpdate(rootId, { $set: setFields });
79}
80
81// ─────────────────────────────────────────────────────────────────────────
82// STRUCTURAL SCAN (no AI, for very long text)
83// ─────────────────────────────────────────────────────────────────────────
84
85/**
86 * Split long text at natural boundaries without AI.
87 * Returns array of { title, content } sections.
88 */
89export function structuralScan(text, targetSize) {
90 const sections = [];
91
92 // Try markdown headings first
93 const headingPattern = /^(#{1,3})\s+(.+)$/gm;
94 const headings = [];
95 let match;
96 while ((match = headingPattern.exec(text)) !== null) {
97 headings.push({ level: match[1].length, title: match[2].trim(), index: match.index });
98 }
99
100 if (headings.length >= 2) {
101 // Split at headings
102 for (let i = 0; i < headings.length; i++) {
103 const start = headings[i].index;
104 const end = i + 1 < headings.length ? headings[i + 1].index : text.length;
105 const content = text.slice(start, end).trim();
106 if (content.length >= MIN_SECTION_SIZE) {
107 // Strip the heading line from content (it becomes the node name)
108 const firstNewline = content.indexOf("\n");
109 const body = firstNewline >= 0 ? content.slice(firstNewline).trim() : content;
110 sections.push({ title: headings[i].title, content: body });
111 }
112 }
113
114 // Handle text before the first heading
115 const preamble = text.slice(0, headings[0].index).trim();
116 if (preamble.length >= MIN_SECTION_SIZE) {
117 sections.unshift({ title: "Preamble", content: preamble });
118 }
119
120 if (sections.length >= 2) return sections;
121 }
122
123 // Try numbered sections (1. Title, 2. Title, etc.)
124 const numberedPattern = /^(\d+)[.)]\s+(.+)$/gm;
125 const numbered = [];
126 while ((match = numberedPattern.exec(text)) !== null) {
127 numbered.push({ num: match[1], title: match[2].trim(), index: match.index });
128 }
129
130 if (numbered.length >= 2) {
131 for (let i = 0; i < numbered.length; i++) {
132 const start = numbered[i].index;
133 const end = i + 1 < numbered.length ? numbered[i + 1].index : text.length;
134 const content = text.slice(start, end).trim();
135 if (content.length >= MIN_SECTION_SIZE) {
136 const firstNewline = content.indexOf("\n");
137 const body = firstNewline >= 0 ? content.slice(firstNewline).trim() : content;
138 sections.push({ title: numbered[i].title, content: body });
139 }
140 }
141 if (sections.length >= 2) return sections;
142 }
143
144 // Fallback: split at double newlines into roughly targetSize chunks
145 const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
146 let current = { title: null, content: "" };
147 let sectionNum = 1;
148
149 for (const para of paragraphs) {
150 if (current.content.length + para.length > targetSize && current.content.length >= MIN_SECTION_SIZE) {
151 current.title = current.title || `Part ${sectionNum}`;
152 sections.push(current);
153 sectionNum++;
154 current = { title: null, content: "" };
155 }
156 current.content += (current.content ? "\n\n" : "") + para.trim();
157 }
158
159 if (current.content.length >= MIN_SECTION_SIZE) {
160 current.title = current.title || `Part ${sectionNum}`;
161 sections.push(current);
162 }
163
164 return sections;
165}
166
167// ─────────────────────────────────────────────────────────────────────────
168// AI DECOMPOSITION
169// ─────────────────────────────────────────────────────────────────────────
170
171/**
172 * Ask AI to identify logical sections in the text.
173 * Returns array of { title, content } or null on failure.
174 */
175export async function aiDecompose(text, userId, username, rootId) {
176 if (!_runChat) return null;
177
178 const prompt =
179 `You are organizing text into logical sections for a knowledge tree.\n\n` +
180 `TEXT TO ORGANIZE:\n${text}\n\n` +
181 `Divide this text into logical sections. Each section should be a coherent topic or concept.\n` +
182 `Return ONLY a JSON array. Each element: { "title": "Section Title", "content": "The full text of that section" }.\n` +
183 `Rules:\n` +
184 `- Every word of the original text must appear in exactly one section. Do not summarize or compress.\n` +
185 `- Section titles should be descriptive and concise.\n` +
186 `- Aim for 2 to ${MAX_SECTIONS_PER_PASS} sections.\n` +
187 `- If the text is already focused on a single topic, return a single-element array.\n` +
188 `- Preserve the original text exactly. This is organization, not rewriting.`;
189
190 try {
191 const { answer } = await _runChat({
192 userId,
193 username: username || "system",
194 message: prompt,
195 mode: "tree:respond",
196 rootId,
197 slot: "learn",
198 });
199
200 if (!answer) return null;
201
202 const parsed = parseJsonSafe(answer);
203 if (!Array.isArray(parsed) || parsed.length === 0) return null;
204
205 // Validate structure
206 const valid = parsed.filter(
207 (s) => s && typeof s.title === "string" && typeof s.content === "string" && s.content.length > 0,
208 );
209
210 return valid.length > 0 ? valid.slice(0, MAX_SECTIONS_PER_PASS) : null;
211 } catch (err) {
212 log.warn("Learn", `AI decomposition failed: ${err.message}`);
213 return null;
214 }
215}
216
217// ─────────────────────────────────────────────────────────────────────────
218// NODE PROCESSING
219// ─────────────────────────────────────────────────────────────────────────
220
221/**
222 * Get all text content from a node's notes, concatenated.
223 */
224async function getNodeText(nodeId) {
225 const notes = await Note.find({ nodeId, contentType: CONTENT_TYPE.TEXT })
226 .sort({ createdAt: 1 })
227 .select("content")
228 .lean();
229 return notes.map((n) => n.content).join("\n\n");
230}
231
232/**
233 * Process a single node in the learn queue.
234 * Returns { created: number, addedToQueue: string[] }.
235 */
236export async function processNode(nodeId, rootId, userId, username, targetSize) {
237 const text = await getNodeText(nodeId);
238
239 // If text is under target, this node is done
240 if (text.length <= targetSize) {
241 return { created: 0, addedToQueue: [] };
242 }
243
244 let sections;
245
246 // If text is too long for AI context, structural scan first
247 if (text.length > AI_CONTEXT_LIMIT) {
248 sections = structuralScan(text, targetSize);
249 } else {
250 // AI decomposition
251 let aiRootId = rootId;
252 if (!aiRootId) {
253 try {
254 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
255 const root = await resolveRootNode(nodeId);
256 aiRootId = root?._id || null;
257 } catch (err) {
258 log.debug("Learn", "Root node resolution failed:", err.message);
259 }
260 }
261
262 sections = await aiDecompose(text, userId, username, aiRootId);
263
264 // Fall back to structural scan if AI fails or returns single section
265 if (!sections || sections.length <= 1) {
266 sections = structuralScan(text, targetSize);
267 }
268 }
269
270 // If still single section or no sections, this node can't be decomposed further
271 if (!sections || sections.length <= 1) {
272 return { created: 0, addedToQueue: [] };
273 }
274
275 // Create child nodes for each section
276 const addedToQueue = [];
277 let created = 0;
278
279 for (const section of sections) {
280 if (section.content.length < MIN_SECTION_SIZE) continue;
281
282 try {
283 const result = await createNode({
284 name: section.title,
285 parentId: nodeId,
286 userId,
287 note: section.content,
288 wasAi: true,
289 });
290
291 if (result?._id) {
292 created++;
293
294 // If child is still too large, add to queue
295 if (section.content.length > targetSize) {
296 addedToQueue.push(result._id.toString());
297 }
298 }
299 } catch (err) {
300 log.warn("Learn", `Failed to create node "${section.title}": ${err.message}`);
301 }
302 }
303
304 return { created, addedToQueue };
305}
306
307// ─────────────────────────────────────────────────────────────────────────
308// MAIN LOOP
309// ─────────────────────────────────────────────────────────────────────────
310
311/**
312 * Process the next batch of nodes in the learn queue.
313 * Processes up to maxSteps nodes, then pauses.
314 * Returns the updated state.
315 */
316export async function processQueue(rootId, userId, username, maxSteps = 10) {
317 const state = await getLearnState(rootId);
318 if (!state || state.status !== "processing") return state;
319
320 const queue = [...(state.queue || [])];
321 const targetSize = state.targetNoteSize || DEFAULT_TARGET_SIZE;
322 let nodesCreated = state.nodesCreated || 0;
323 let nodesProcessed = state.nodesProcessed || 0;
324 let steps = 0;
325
326 while (queue.length > 0 && steps < maxSteps && nodesProcessed < MAX_QUEUE_ITERATIONS) {
327 const nodeId = queue.shift();
328 steps++;
329
330 try {
331 const { created, addedToQueue } = await processNode(nodeId, rootId, userId, username, targetSize);
332 nodesCreated += created;
333 nodesProcessed++;
334
335 for (const childId of addedToQueue) {
336 queue.push(childId);
337 }
338
339 log.debug("Learn", `Processed node ${nodeId}: ${created} children, ${addedToQueue.length} queued`);
340 } catch (err) {
341 log.error("Learn", `Failed to process node ${nodeId}: ${err.message}`);
342 nodesProcessed++;
343 }
344 }
345
346 // Determine new status
347 const newStatus = queue.length === 0 ? "complete" : "processing";
348
349 await updateLearnState(rootId, {
350 status: newStatus,
351 queue,
352 nodesCreated,
353 nodesProcessed,
354 });
355
356 return await getLearnState(rootId);
357}
358
359/**
360 * Pause a learn operation.
361 */
362export async function pauseLearn(rootId) {
363 const state = await getLearnState(rootId);
364 if (!state || state.status !== "processing") return state;
365 await updateLearnState(rootId, { status: "paused" });
366 return await getLearnState(rootId);
367}
368
369/**
370 * Resume a paused learn operation.
371 */
372export async function resumeLearn(rootId) {
373 const state = await getLearnState(rootId);
374 if (!state) return null;
375 if (state.status === "complete") return state;
376 await updateLearnState(rootId, { status: "processing" });
377 return state;
378}
379
380/**
381 * Stop a learn operation entirely. Clears the queue.
382 * Nodes already created stay. Only future processing is cancelled.
383 */
384export async function stopLearn(rootId) {
385 const state = await getLearnState(rootId);
386 if (!state) return null;
387 await updateLearnState(rootId, { status: "complete", queue: [] });
388 return await getLearnState(rootId);
389}
390
1import tools from "./tools.js";
2import { setRunChat, initLearnState, getLearnState, processQueue, pauseLearn, resumeLearn, stopLearn } from "./core.js";
3
4export async function init(core) {
5 const INT = core.llm.LLM_PRIORITY.INTERACTIVE;
6
7 core.llm.registerRootLlmSlot?.("learn");
8
9 setRunChat(async (opts) => {
10 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
11 return core.llm.runChat({ ...opts, llmPriority: INT });
12 });
13
14 const { default: router } = await import("./routes.js");
15
16 return {
17 router,
18 tools,
19 exports: {
20 initLearnState,
21 getLearnState,
22 processQueue,
23 pauseLearn,
24 resumeLearn,
25 stopLearn,
26 },
27 };
28}
29
1export default {
2 name: "learn",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Information intake and decomposition. Paste in 50,000 words. The extension scans through, " +
7 "labels the main sections, cuts it up into nodes, repeats for each node, and keeps expanding " +
8 "until any length text is turned into nodes that are good sized. The opposite of understanding " +
9 "compression. Understanding compresses bottom-up. Learn expands top-down. Modular: leaves " +
10 "state in metadata and knows how to take breaks. Can stop and start at any time without " +
11 "breaking. Queue-based processing, one node at a time. Each step: read the text, ask the " +
12 "AI to identify sections, create children, move text, add large children back to the queue. " +
13 "For very long text that exceeds AI context, does a structural scan first (headings, " +
14 "paragraph boundaries) to chunk into manageable pieces before AI refinement.",
15
16 needs: {
17 services: ["llm"],
18 models: ["Node"],
19 },
20
21 optional: {},
22
23 provides: {
24 models: {},
25 routes: "./routes.js",
26 tools: true,
27 jobs: false,
28 orchestrator: false,
29 energyActions: {},
30 sessionTypes: {},
31 env: [],
32 cli: [
33 {
34 command: "learn-status", scope: ["tree"],
35 description: "Check progress of a learn operation at current node",
36 method: "GET",
37 endpoint: "/node/:nodeId/learn",
38 },
39 {
40 command: "learn-resume",
41 description: "Resume a paused learn operation at current node",
42 method: "POST",
43 endpoint: "/node/:nodeId/learn/resume",
44 },
45 {
46 command: "learn-pause",
47 description: "Pause a learn operation at current node",
48 method: "POST",
49 endpoint: "/node/:nodeId/learn/pause",
50 },
51 {
52 command: "learn-stop",
53 description: "Stop a learn operation and clear the queue",
54 method: "POST",
55 endpoint: "/node/:nodeId/learn/stop",
56 },
57 ],
58
59 hooks: {
60 fires: [],
61 listens: [],
62 },
63 },
64};
65
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getLearnState, pauseLearn, resumeLearn, stopLearn, processQueue } from "./core.js";
5import User from "../../seed/models/user.js";
6
7const router = express.Router();
8
9// GET /node/:nodeId/learn - learn status
10router.get("/node/:nodeId/learn", authenticate, async (req, res) => {
11 try {
12 const state = await getLearnState(req.params.nodeId);
13 if (!state) return sendOk(res, { message: "No learn operation on this node." });
14 sendOk(res, {
15 status: state.status,
16 nodesCreated: state.nodesCreated,
17 nodesProcessed: state.nodesProcessed,
18 queueRemaining: (state.queue || []).length,
19 targetNoteSize: state.targetNoteSize,
20 startedAt: state.startedAt,
21 lastActivityAt: state.lastActivityAt,
22 });
23 } catch (err) {
24 sendError(res, 500, ERR.INTERNAL, err.message);
25 }
26});
27
28// POST /node/:nodeId/learn/resume - resume and process next batch
29router.post("/node/:nodeId/learn/resume", authenticate, async (req, res) => {
30 try {
31 const nodeId = req.params.nodeId;
32 const state = await getLearnState(nodeId);
33 if (!state) return sendError(res, 404, ERR.NODE_NOT_FOUND, "No learn operation on this node");
34 if (state.status === "complete") return sendOk(res, { message: "Already complete", ...state });
35
36 if (state.status === "paused") await resumeLearn(nodeId);
37
38 const user = await User.findById(req.userId).select("username").lean();
39 const updated = await processQueue(nodeId, req.userId, user?.username, 10);
40 sendOk(res, updated);
41 } catch (err) {
42 sendError(res, 500, ERR.INTERNAL, err.message);
43 }
44});
45
46// POST /node/:nodeId/learn/pause - pause
47router.post("/node/:nodeId/learn/pause", authenticate, async (req, res) => {
48 try {
49 const state = await pauseLearn(req.params.nodeId);
50 if (!state) return sendError(res, 404, ERR.NODE_NOT_FOUND, "No learn operation on this node");
51 sendOk(res, state);
52 } catch (err) {
53 sendError(res, 500, ERR.INTERNAL, err.message);
54 }
55});
56
57// POST /node/:nodeId/learn/stop - stop and clear queue
58router.post("/node/:nodeId/learn/stop", authenticate, async (req, res) => {
59 try {
60 const state = await stopLearn(req.params.nodeId);
61 if (!state) return sendError(res, 404, ERR.NODE_NOT_FOUND, "No learn operation on this node");
62 sendOk(res, state);
63 } catch (err) {
64 sendError(res, 500, ERR.INTERNAL, err.message);
65 }
66});
67
68export default router;
69
1import { z } from "zod";
2import log from "../../seed/log.js";
3import {
4 initLearnState,
5 getLearnState,
6 processQueue,
7 pauseLearn,
8 resumeLearn,
9} from "./core.js";
10
11export default [
12 {
13 name: "learn",
14 description:
15 "Start learning from text. Paste a massive block of text and the extension decomposes it " +
16 "into a tree structure. First pass identifies top-level sections. Each section becomes a child " +
17 "node. Repeat for each child until all leaves are a reasonable size. Queue-based, can be " +
18 "paused and resumed. If no text is provided, learns from the existing notes on the node.",
19 schema: {
20 nodeId: z.string().describe("The node to start learning at. Children will be created here."),
21 text: z.string().max(1000000).optional().describe("The text to learn (max 1MB). If omitted, reads from existing notes on the node."),
22 targetSize: z.number().optional().default(3000).describe("Target note size in characters for leaf nodes (default 3000)."),
23 maxSteps: z.number().optional().default(10).describe("Max nodes to process in this call (default 10). Use for incremental processing."),
24 userId: z.string().describe("Injected by server. Ignore."),
25 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
26 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
27 },
28 annotations: {
29 readOnlyHint: false,
30 destructiveHint: false,
31 idempotentHint: false,
32 openWorldHint: true,
33 },
34 handler: async ({ nodeId, text, targetSize, maxSteps, userId }) => {
35 try {
36 // Check if there's already a learn operation on this node
37 const existing = await getLearnState(nodeId);
38 if (existing && existing.status === "processing") {
39 return { content: [{ type: "text", text: "Learn operation already in progress on this node. Use learn-resume to continue or learn-status to check progress." }] };
40 }
41
42 // If text provided, write it as a note first
43 if (text && text.trim().length > 0) {
44 const { createNote } = await import("../../seed/tree/notes.js");
45 await createNote({
46 contentType: "text",
47 content: text,
48 userId,
49 nodeId,
50 wasAi: false,
51 });
52 }
53
54 // Initialize learn state and start processing
55 await initLearnState(nodeId, targetSize);
56
57 // Look up username
58 let username = null;
59 try {
60 const User = (await import("../../seed/models/user.js")).default;
61 const user = await User.findById(userId).select("username").lean();
62 username = user?.username;
63 } catch (err) {
64 log.debug("Learn", "Username lookup failed:", err.message);
65 }
66
67 const state = await processQueue(nodeId, userId, username, maxSteps || 10);
68
69 const statusText = state.status === "complete"
70 ? `Learning complete. Created ${state.nodesCreated} nodes from ${state.nodesProcessed} decomposition passes.`
71 : `Learning in progress. Created ${state.nodesCreated} nodes so far. ${state.queue.length} nodes still in queue. Use learn-resume to continue.`;
72
73 return {
74 content: [{
75 type: "text",
76 text: JSON.stringify({
77 message: statusText,
78 status: state.status,
79 nodesCreated: state.nodesCreated,
80 nodesProcessed: state.nodesProcessed,
81 queueRemaining: state.queue.length,
82 }, null, 2),
83 }],
84 };
85 } catch (err) {
86 return { content: [{ type: "text", text: `Learn failed: ${err.message}` }] };
87 }
88 },
89 },
90 {
91 name: "learn-resume",
92 description:
93 "Resume a paused or in-progress learn operation. Processes the next batch of nodes in the queue.",
94 schema: {
95 nodeId: z.string().describe("The root node of the learn operation."),
96 maxSteps: z.number().optional().default(10).describe("Max nodes to process in this call (default 10)."),
97 userId: z.string().describe("Injected by server. Ignore."),
98 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
99 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
100 },
101 annotations: {
102 readOnlyHint: false,
103 destructiveHint: false,
104 idempotentHint: false,
105 openWorldHint: true,
106 },
107 handler: async ({ nodeId, maxSteps, userId }) => {
108 try {
109 const state = await getLearnState(nodeId);
110 if (!state) {
111 return { content: [{ type: "text", text: "No learn operation found on this node. Use learn to start one." }] };
112 }
113 if (state.status === "complete") {
114 return { content: [{ type: "text", text: `Learning already complete. ${state.nodesCreated} nodes created across ${state.nodesProcessed} passes.` }] };
115 }
116
117 // Resume if paused
118 if (state.status === "paused") {
119 await resumeLearn(nodeId);
120 }
121
122 let username = null;
123 try {
124 const User = (await import("../../seed/models/user.js")).default;
125 const user = await User.findById(userId).select("username").lean();
126 username = user?.username;
127 } catch (err) {
128 log.debug("Learn", "Username lookup failed:", err.message);
129 }
130
131 const updated = await processQueue(nodeId, userId, username, maxSteps || 10);
132
133 const statusText = updated.status === "complete"
134 ? `Learning complete. Created ${updated.nodesCreated} nodes from ${updated.nodesProcessed} passes.`
135 : `Processed batch. Created ${updated.nodesCreated} total nodes. ${updated.queue.length} still in queue. Call learn-resume again to continue.`;
136
137 return {
138 content: [{
139 type: "text",
140 text: JSON.stringify({
141 message: statusText,
142 status: updated.status,
143 nodesCreated: updated.nodesCreated,
144 nodesProcessed: updated.nodesProcessed,
145 queueRemaining: updated.queue.length,
146 }, null, 2),
147 }],
148 };
149 } catch (err) {
150 return { content: [{ type: "text", text: `Resume failed: ${err.message}` }] };
151 }
152 },
153 },
154 {
155 name: "learn-status",
156 description: "Check the progress of a learn operation on a node.",
157 schema: {
158 nodeId: z.string().describe("The root node of the learn operation."),
159 userId: z.string().describe("Injected by server. Ignore."),
160 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
161 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
162 },
163 annotations: {
164 readOnlyHint: true,
165 destructiveHint: false,
166 idempotentHint: true,
167 openWorldHint: false,
168 },
169 handler: async ({ nodeId }) => {
170 try {
171 const state = await getLearnState(nodeId);
172 if (!state) {
173 return { content: [{ type: "text", text: "No learn operation found on this node." }] };
174 }
175 return {
176 content: [{
177 type: "text",
178 text: JSON.stringify({
179 status: state.status,
180 nodesCreated: state.nodesCreated,
181 nodesProcessed: state.nodesProcessed,
182 queueRemaining: (state.queue || []).length,
183 targetNoteSize: state.targetNoteSize,
184 startedAt: state.startedAt,
185 lastActivityAt: state.lastActivityAt,
186 }, null, 2),
187 }],
188 };
189 } catch (err) {
190 return { content: [{ type: "text", text: `Status check failed: ${err.message}` }] };
191 }
192 },
193 },
194 {
195 name: "learn-pause",
196 description: "Pause a learn operation. The queue is preserved and can be resumed later.",
197 schema: {
198 nodeId: z.string().describe("The root node of the learn operation."),
199 userId: z.string().describe("Injected by server. Ignore."),
200 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
201 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
202 },
203 annotations: {
204 readOnlyHint: false,
205 destructiveHint: false,
206 idempotentHint: true,
207 openWorldHint: false,
208 },
209 handler: async ({ nodeId }) => {
210 try {
211 const state = await pauseLearn(nodeId);
212 if (!state) {
213 return { content: [{ type: "text", text: "No learn operation found on this node." }] };
214 }
215 return {
216 content: [{
217 type: "text",
218 text: `Learn paused. ${state.nodesCreated} nodes created so far. ${(state.queue || []).length} nodes still in queue. Use learn-resume to continue.`,
219 }],
220 };
221 } catch (err) {
222 return { content: [{ type: "text", text: `Pause failed: ${err.message}` }] };
223 }
224 },
225 },
226];
227
Loading comments...