EXTENSION for TreeOS
learn
Information intake and decomposition. Paste in 50,000 words. The extension scans through, labels the main sections, cuts it up into nodes, repeats for each node, and keeps expanding until any length text is turned into nodes that are good sized. The opposite of understanding compression. Understanding compresses bottom-up. Learn expands top-down. Modular: leaves state in metadata and knows how to take breaks. Can stop and start at any time without breaking. Queue-based processing, one node at a time. Each step: read the text, ask the AI to identify sections, create children, move text, add large children back to the queue. For very long text that exceeds AI context, does a structural scan first (headings, paragraph boundaries) to chunk into manageable pieces before AI refinement.
v1.0.1 by TreeOS Site 0 downloads 5 files 780 lines 26.7 KB published 38d ago
treeos ext install learn
View changelog

Manifest

Provides

  • routes
  • tools
  • 4 CLI commands

Requires

  • services: llm
  • models: Node
SHA256: b9f9f275d48c9d6ed15324e902eda34ff7bb858bac16b000a5efa9ec6c4cef0a

CLI Commands

CommandMethodDescription
learn-statusGETCheck progress of a learn operation at current node
learn-resumePOSTResume a paused learn operation at current node
learn-pausePOSTPause a learn operation at current node
learn-stopPOSTStop a learn operation and clear the queue

Source Code

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

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 learn

Comments

Loading comments...

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