EXTENSION for TreeOS
kb
Knowledge base. Tell it things. Ask it things. One person maintains, everyone benefits. The tree organizes input into a topic hierarchy. The AI answers from stored notes with citations. Staleness detection flags notes that haven't been updated. Unplaced node catches what the AI can't categorize yet. Two modes: kb-tell (create knowledge), kb-ask (retrieve with citations). Type 'be' for a guided review of stale notes. The tree that replaces wikis, training manuals, and the coworker who always gets interrupted.
v1.0.2 by TreeOS Site 0 downloads 9 files 1,074 lines 35.1 KB published 38d ago
treeos ext install kb
View changelog

Manifest

Provides

  • routes
  • 1 CLI commands

Requires

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

Optional

  • extensions: understanding, tree-compress, scout, embed, explore, competence, contradiction, purpose, prestige, values, channels, breath, html-rendering, treeos-base
SHA256: 5e3639217e649b4ab28f1181d8d0a77ba8ebc0b20ed0f3f70ad9a2ae12781a02

CLI Commands

CommandMethodDescription
kbPOSTKnowledge base. Tell or ask.
kb statusGETCoverage, freshness, unplaced count.
kb staleGETNotes not updated in 90+ days.
kb unplacedGETItems that couldn't be categorized.

Hooks

Listens To

  • enrichContext
  • breath:exhale

Source Code

1/**
2 * KB Core
3 *
4 * Tell it things. Ask it things. The tree organizes.
5 * The AI answers from what it knows.
6 */
7
8import log from "../../seed/log.js";
9import Contribution from "../../seed/models/contribution.js";
10import { setNodeMode } from "../../seed/modes/registry.js";
11
12let _Node = null;
13let _Note = null;
14let _runChat = null;
15let _metadata = null;
16
17export function configure({ Node, Note, runChat, metadata }) {
18  _Node = Node;
19  _Note = Note;
20  _runChat = runChat;
21  _metadata = metadata;
22}
23
24const ROLES = {
25  LOG: "log",
26  TOPICS: "topics",
27  UNPLACED: "unplaced",
28  PROFILE: "profile",
29  HISTORY: "history",
30};
31
32const STALE_DAYS = 90;
33
34// ── Scaffold ──
35
36export async function scaffold(rootId, userId) {
37  if (!_Node) throw new Error("KB core not configured");
38  const { createNode } = await import("../../seed/tree/treeManagement.js");
39
40  const logNode = await createNode({ name: "Log", parentId: rootId, userId });
41  const topicsNode = await createNode({ name: "Topics", parentId: rootId, userId });
42  const unplacedNode = await createNode({ name: "Unplaced", parentId: rootId, userId });
43  const profileNode = await createNode({ name: "Profile", parentId: rootId, userId });
44  const historyNode = await createNode({ name: "History", parentId: rootId, userId });
45
46  const tags = [
47    [logNode, ROLES.LOG],
48    [topicsNode, ROLES.TOPICS],
49    [unplacedNode, ROLES.UNPLACED],
50    [profileNode, ROLES.PROFILE],
51    [historyNode, ROLES.HISTORY],
52  ];
53
54  for (const [node, role] of tags) {
55    await _metadata.setExtMeta(node, "kb", { role });
56  }
57
58  await setNodeMode(rootId, "respond", "tree:kb-tell");
59  await setNodeMode(logNode._id, "respond", "tree:kb-tell");
60
61  const root = await _Node.findById(rootId);
62  if (root) {
63    await _metadata.setExtMeta(root, "kb", {
64      initialized: true,
65      setupPhase: "complete",
66      profile: {
67        name: root.name || "Knowledge Base",
68        maintainers: [userId],
69        readers: ["*"],
70      },
71    });
72  }
73
74  const ids = {};
75  for (const [node, role] of tags) ids[role] = String(node._id);
76
77  log.info("KB", `Scaffolded under ${rootId}`);
78  return ids;
79}
80
81// ── Find nodes ──
82
83export async function findKbNodes(rootId) {
84  if (!_Node) return null;
85  const children = await _Node.find({ parent: rootId }).select("_id name metadata").lean();
86  const result = {};
87  for (const child of children) {
88    const meta = child.metadata instanceof Map ? child.metadata.get("kb") : child.metadata?.kb;
89    if (meta?.role) result[meta.role] = { id: String(child._id), name: child.name };
90  }
91  return result;
92}
93
94export async function isInitialized(rootId) {
95  if (!_Node) return false;
96  const root = await _Node.findById(rootId).select("metadata").lean();
97  if (!root) return false;
98  const meta = root.metadata instanceof Map ? root.metadata.get("kb") : root.metadata?.kb;
99  return !!meta?.initialized;
100}
101
102export async function getSetupPhase(rootId) {
103  if (!_Node) return null;
104  const root = await _Node.findById(rootId).select("metadata").lean();
105  if (!root) return null;
106  const meta = root.metadata instanceof Map ? root.metadata.get("kb") : root.metadata?.kb;
107  return meta?.setupPhase || (meta?.initialized ? "complete" : null);
108}
109
110export async function completeSetup(rootId) {
111  const root = await _Node.findById(rootId);
112  if (!root) return;
113  const existing = _metadata.getExtMeta(root, "kb") || {};
114  await _metadata.setExtMeta(root, "kb", { ...existing, setupPhase: "complete" });
115}
116
117// ── Intent routing ──
118
119export function routeKbIntent(message) {
120  const lower = message.toLowerCase().trim();
121  if (lower === "be") return "review";
122  if (/^(what|how|why|when|where|who|is |are |does |do |can |show |tell me|explain)\b/.test(lower))
123    return "ask";
124  return "tell";
125}
126
127// ── Recently edited note IDs (filters out false stale positives) ──
128
129async function getRecentlyEditedNoteIds(noteIds, sinceDate) {
130  if (noteIds.length === 0) return new Set();
131  const edits = await Contribution.find({
132    action: "note",
133    "noteAction.action": "edit",
134    "noteAction.noteId": { $in: noteIds },
135    date: { $gte: sinceDate },
136  }).select("noteAction.noteId").lean();
137  return new Set(edits.map(e => e.noteAction?.noteId).filter(Boolean));
138}
139
140// ── Status ──
141
142export async function getStatus(rootId) {
143  if (!_Node || !_Note) return null;
144  const nodes = await findKbNodes(rootId);
145  if (!nodes) return null;
146
147  const root = await _Node.findById(rootId).select("name metadata").lean();
148  const kbMeta = root?.metadata instanceof Map ? root.metadata.get("kb") : root?.metadata?.kb;
149
150  // Count topics and notes
151  let topicCount = 0;
152  let noteCount = 0;
153  const coverage = [];
154  const topicNoteCounts = {};
155
156  if (nodes.topics) {
157    const topics = await _Node.find({ parent: nodes.topics.id }).select("_id name").lean();
158    topicCount = topics.length;
159
160    for (const t of topics) {
161      const children = await _Node.find({ parent: t._id }).select("_id").lean();
162      const nodeIds = [String(t._id), ...children.map(c => String(c._id))];
163      const count = await _Note.countDocuments({ nodeId: { $in: nodeIds } });
164      noteCount += count;
165      coverage.push(t.name);
166      topicNoteCounts[t.name] = count;
167    }
168  }
169
170  // Stale notes (created long ago AND not recently edited)
171  const staleDate = new Date(Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000);
172  const topicNodeIds = await getTopicNodeIds(nodes);
173
174  const candidateStale = await _Note.find({
175    nodeId: { $in: topicNodeIds },
176    createdAt: { $lt: staleDate },
177  }).select("_id nodeId createdAt").lean();
178
179  const candidateIds = candidateStale.map(n => String(n._id));
180  const recentlyEdited = await getRecentlyEditedNoteIds(candidateIds, staleDate);
181  const trueStale = candidateStale.filter(n => !recentlyEdited.has(String(n._id)));
182
183  // Stale branches (group by topic ancestor)
184  const staleBranches = [];
185  const branchStaleMap = {};
186  for (const n of trueStale) {
187    const node = await _Node.findById(n.nodeId).select("name").lean();
188    const name = node?.name || "unknown";
189    const days = Math.floor((Date.now() - new Date(n.createdAt).getTime()) / (24 * 60 * 60 * 1000));
190    if (!branchStaleMap[name] || days > branchStaleMap[name]) {
191      branchStaleMap[name] = days;
192    }
193  }
194  for (const [name, days] of Object.entries(branchStaleMap)) {
195    staleBranches.push(`${name} (${days} days)`);
196  }
197  staleBranches.sort((a, b) => {
198    const da = parseInt(a.match(/\((\d+)/)?.[1] || "0");
199    const db = parseInt(b.match(/\((\d+)/)?.[1] || "0");
200    return db - da;
201  });
202
203  // Unplaced count
204  let unplacedCount = 0;
205  if (nodes.unplaced) {
206    unplacedCount = await _Note.countDocuments({ nodeId: nodes.unplaced.id });
207  }
208
209  // Recent updates: merge recent creates and recent edits
210  const recentCreated = await _Note.find({ nodeId: { $in: topicNodeIds } })
211    .sort({ createdAt: -1 })
212    .limit(5)
213    .select("_id nodeId createdAt")
214    .lean();
215
216  const recentEdits = await Contribution.find({
217    action: "note",
218    "noteAction.action": "edit",
219    nodeId: { $in: topicNodeIds },
220  }).sort({ date: -1 }).limit(5).select("nodeId date").lean();
221
222  // Merge and deduplicate by nodeId, sort by most recent
223  const updateMap = new Map();
224  for (const n of recentCreated) {
225    const node = await _Node.findById(n.nodeId).select("name").lean();
226    if (node) updateMap.set(String(n.nodeId), { name: node.name, date: n.createdAt });
227  }
228  for (const e of recentEdits) {
229    const existing = updateMap.get(String(e.nodeId));
230    if (!existing || new Date(e.date) > new Date(existing.date)) {
231      const node = await _Node.findById(e.nodeId).select("name").lean();
232      if (node) updateMap.set(String(e.nodeId), { name: node.name, date: e.date });
233    }
234  }
235  const recentUpdates = [...updateMap.values()]
236    .sort((a, b) => new Date(b.date) - new Date(a.date))
237    .slice(0, 5);
238
239  return {
240    name: root?.name || kbMeta?.profile?.name || "Knowledge Base",
241    profile: kbMeta?.profile || null,
242    topicCount,
243    noteCount,
244    topicNoteCounts,
245    staleNotes: trueStale.length,
246    staleBranches: staleBranches.slice(0, 10),
247    unplacedCount,
248    coverage: coverage.slice(0, 20),
249    recentUpdates,
250  };
251}
252
253async function getTopicNodeIds(nodes) {
254  if (!nodes?.topics || !_Node) return [];
255  const topics = await _Node.find({ parent: nodes.topics.id }).select("_id").lean();
256  const ids = [nodes.topics.id, ...topics.map(t => String(t._id))];
257  // One level of children
258  for (const t of topics) {
259    const children = await _Node.find({ parent: t._id }).select("_id").lean();
260    ids.push(...children.map(c => String(c._id)));
261  }
262  return ids;
263}
264
265export async function getStaleNotes(rootId) {
266  if (!_Node || !_Note) return [];
267  const nodes = await findKbNodes(rootId);
268  if (!nodes) return [];
269
270  const staleDate = new Date(Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000);
271  const nodeIds = await getTopicNodeIds(nodes);
272
273  const candidates = await _Note.find({
274    nodeId: { $in: nodeIds },
275    createdAt: { $lt: staleDate },
276  })
277    .sort({ createdAt: 1 })
278    .limit(50)
279    .select("_id nodeId content createdAt")
280    .lean();
281
282  // Filter out notes that were recently edited
283  const candidateIds = candidates.map(n => String(n._id));
284  const recentlyEdited = await getRecentlyEditedNoteIds(candidateIds, staleDate);
285
286  const results = [];
287  for (const n of candidates) {
288    if (recentlyEdited.has(String(n._id))) continue;
289    const node = await _Node.findById(n.nodeId).select("name").lean();
290    results.push({
291      noteId: String(n._id),
292      nodeId: n.nodeId,
293      nodeName: node?.name || "unknown",
294      preview: typeof n.content === "string" ? n.content.slice(0, 200) : "",
295      lastUpdated: n.createdAt,
296      daysStale: Math.floor((Date.now() - new Date(n.createdAt).getTime()) / (24 * 60 * 60 * 1000)),
297    });
298  }
299  return results;
300}
301
302export async function getUnplaced(rootId) {
303  if (!_Node || !_Note) return [];
304  const nodes = await findKbNodes(rootId);
305  if (!nodes?.unplaced) return [];
306
307  const notes = await _Note.find({ nodeId: nodes.unplaced.id })
308    .sort({ createdAt: -1 })
309    .limit(50)
310    .select("content createdAt")
311    .lean();
312
313  return notes.map(n => ({
314    content: typeof n.content === "string" ? n.content.slice(0, 300) : "",
315    date: n.createdAt,
316  }));
317}
318
319// ── Maintainer check ──
320
321export async function isMaintainer(rootId, userId) {
322  if (!_Node) return false;
323  const root = await _Node.findById(rootId).select("metadata rootOwner").lean();
324  if (!root) return false;
325  if (root.rootOwner?.toString() === userId) return true;
326  const kbMeta = root.metadata instanceof Map ? root.metadata.get("kb") : root.metadata?.kb;
327  return (kbMeta?.profile?.maintainers || []).includes(userId);
328}
329
1/**
2 * KB Handler
3 *
4 * Only does data work and permission checks.
5 * Returns { answer } for permission denials.
6 * Returns null for everything else (AI handles it).
7 */
8
9import { createNote } from "../../seed/tree/notes.js";
10import {
11  findKbNodes,
12  routeKbIntent,
13  isMaintainer,
14} from "./core.js";
15
16export async function handleMessage(message, { userId, username, rootId, targetNodeId }) {
17  const kbRoot = targetNodeId || rootId;
18
19  // Permission check: non-maintainers can only ask, not tell or review
20  const intent = routeKbIntent(message);
21  if (intent === "tell") {
22    const maintainer = await isMaintainer(kbRoot, userId);
23    if (!maintainer) {
24      return { answer: "Only maintainers can add knowledge. You can ask questions." };
25    }
26
27    // Write to log node (data work)
28    const nodes = await findKbNodes(kbRoot);
29    if (nodes?.log) {
30      try { await createNote({ nodeId: nodes.log.id, content: message, contentType: "text", userId }); } catch {}
31    }
32  }
33
34  // Let the AI handle everything else
35  return null;
36}
37
1/**
2 * KB
3 *
4 * Tell it things. Ask it things. The tree organizes.
5 * The AI answers from what it knows.
6 */
7
8import log from "../../seed/log.js";
9import tellMode from "./modes/tell.js";
10import askMode from "./modes/ask.js";
11import reviewMode from "./modes/review.js";
12import {
13  configure,
14  scaffold,
15  isInitialized,
16  findKbNodes,
17  getStatus,
18  getStaleNotes,
19  getUnplaced,
20  isMaintainer,
21  routeKbIntent,
22  getSetupPhase,
23} from "./core.js";
24import { handleMessage } from "./handler.js";
25
26export async function init(core) {
27  core.llm.registerRootLlmSlot?.("kb");
28
29  const runChat = core.llm?.runChat || null;
30  configure({
31    Node: core.models.Node,
32    Note: core.models.Note,
33    runChat: runChat
34      ? async (opts) => {
35          if (opts.userId && opts.userId !== "SYSTEM") {
36            const hasLlm = await core.llm.userHasLlm(opts.userId);
37            if (!hasLlm) return { answer: null };
38          }
39          return core.llm.runChat({
40            ...opts,
41            llmPriority: core.llm.LLM_PRIORITY.INTERACTIVE,
42          });
43        }
44      : null,
45    metadata: core.metadata,
46  });
47
48  // Register modes
49  core.modes.registerMode("tree:kb-tell", tellMode, "kb");
50  core.modes.registerMode("tree:kb-ask", askMode, "kb");
51  core.modes.registerMode("tree:kb-review", reviewMode, "kb");
52
53  if (core.llm?.registerModeAssignment) {
54    core.llm.registerModeAssignment("tree:kb-tell", "kb");
55    core.llm.registerModeAssignment("tree:kb-ask", "kb");
56    core.llm.registerModeAssignment("tree:kb-review", "kb");
57  }
58
59  // ── Boot self-heal ──
60  core.hooks.register("afterBoot", async () => {
61    try {
62      const roots = await core.models.Node.find({
63        "metadata.kb.initialized": true,
64      }).select("_id metadata").lean();
65      for (const root of roots) {
66        const modes = root.metadata instanceof Map
67          ? root.metadata.get("modes")
68          : root.metadata?.modes;
69        if (!modes?.respond) {
70          const { setNodeMode } = await import("../../seed/modes/registry.js");
71          await setNodeMode(root._id, "respond", "tree:kb-tell");
72        }
73      }
74    } catch {}
75  }, "kb");
76
77  // ── enrichContext ──
78  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
79    if (!node?._id) return;
80    const kbMeta = meta?.kb;
81    if (!kbMeta) return;
82
83    // Only inject at the root or direct children
84    let rootId = null;
85    if (kbMeta.initialized) {
86      rootId = String(node._id);
87    } else if (kbMeta.role) {
88      rootId = String(node.parent);
89    }
90    if (!rootId) return;
91
92    try {
93      const status = await getStatus(rootId);
94      if (!status) return;
95
96      context.kb = {
97        name: status.name,
98        topicCount: status.topicCount,
99        noteCount: status.noteCount,
100        coverage: status.coverage,
101        staleNotes: status.staleNotes,
102        staleAreas: status.staleBranches || [],
103        unplaced: status.unplacedCount,
104      };
105
106      if (status.recentUpdates?.length > 0) {
107        context.kb.recentlyUpdated = status.recentUpdates.slice(0, 3).map(u =>
108          `${u.name} (${Math.floor((Date.now() - new Date(u.date).getTime()) / (24 * 60 * 60 * 1000))}d ago)`
109        );
110      }
111
112      if (status.staleNotes > 0) {
113        context.kb.staleWarning = `${status.staleNotes} notes haven't been updated in 90+ days. Areas: ${(status.staleBranches || []).slice(0, 3).join(", ") || "various"}.`;
114      }
115    } catch {}
116  }, "kb");
117
118  // ── breath:exhale ──
119  core.hooks.register("breath:exhale", async ({ rootId }) => {
120    try {
121      if (!(await isInitialized(rootId))) return;
122      const status = await getStatus(rootId);
123      if (status?.staleNotes > 5) {
124        log.warn("KB", `${status.name}: ${status.staleNotes} stale notes need review`);
125      }
126    } catch {}
127  }, "kb");
128
129  // ── Live dashboard updates ──
130  core.hooks.register("afterNote", async ({ nodeId }) => {
131    if (!nodeId) return;
132    try {
133      const node = await core.models.Node.findById(nodeId).select("rootOwner metadata").lean();
134      if (!node?.rootOwner) return;
135      const fm = node.metadata instanceof Map ? node.metadata.get("kb") : node.metadata?.kb;
136      if (!fm?.role) return;
137      core.websocket?.emitToUser?.(String(node.rootOwner), "dashboardUpdate", { rootId: String(node.rootOwner) });
138    } catch {}
139  }, "kb");
140
141  core.hooks.register("afterMetadataWrite", async ({ nodeId, extName }) => {
142    if (extName !== "values" && extName !== "kb") return;
143    try {
144      const node = await core.models.Node.findById(nodeId).select("rootOwner").lean();
145      if (!node?.rootOwner) return;
146      core.websocket?.emitToUser?.(String(node.rootOwner), "dashboardUpdate", { rootId: String(node.rootOwner) });
147    } catch {}
148  }, "kb");
149
150  // ── Register apps-grid slot ──
151  try {
152    const { getExtension } = await import("../loader.js");
153    const base = getExtension("treeos-base");
154    base?.exports?.registerSlot?.("apps-grid", "kb", ({ userId, rootMap, tokenParam, tokenField, esc: e }) => {
155      const entries = rootMap.get("KB") || rootMap.get("Knowledge Base") || [];
156      const existing = entries.map(entry =>
157        entry.ready
158          ? `<a class="app-active" href="/api/v1/root/${entry.id}/kb?html${tokenParam}" style="margin-right:8px;margin-bottom:6px;">${e(entry.name)}</a>`
159          : `<a class="app-active" style="background:rgba(236,201,75,0.12);border-color:rgba(236,201,75,0.3);color:#ecc94b;margin-right:8px;margin-bottom:6px;" href="/api/v1/root/${entry.id}/kb?html${tokenParam}">${e(entry.name)} (setup)</a>`
160      ).join("");
161      return `<div class="app-card">
162        <div class="app-header"><span class="app-emoji">📖</span><span class="app-name">Knowledge Base</span></div>
163        <div class="app-desc">Tell it things. Ask it things. The tree organizes knowledge into topics with citations.</div>
164        ${existing ? `<div style="display:flex;flex-wrap:wrap;margin-bottom:10px;">${existing}</div>` : ""}
165        <form class="app-form" method="POST" action="/api/v1/user/${userId}/apps/create">
166          ${tokenField}<input type="hidden" name="app" value="kb" />
167          <input class="app-input" name="message" placeholder="What's this knowledge base about? (e.g. team wiki, personal notes)" required />
168          <button class="app-start" type="submit">${entries.length > 0 ? "New" : "Start"} KB</button>
169        </form>
170      </div>`;
171    }, { priority: 50 });
172  } catch {}
173
174  // ── Router ──
175  const { default: router } = await import("./routes.js");
176
177  log.info("KB", "Loaded. Tell it things. Ask it things.");
178
179  return {
180    router,
181    exports: {
182      scaffold,
183      isInitialized,
184      getSetupPhase,
185      findKbNodes,
186      getStatus,
187      getStaleNotes,
188      getUnplaced,
189      isMaintainer,
190      routeKbIntent,
191      handleMessage,
192    },
193  };
194}
195
1export default {
2  name: "kb",
3  version: "1.0.2",
4  builtFor: "TreeOS",
5  description:
6    "Knowledge base. Tell it things. Ask it things. One person maintains, " +
7    "everyone benefits. The tree organizes input into a topic hierarchy. " +
8    "The AI answers from stored notes with citations. Staleness detection " +
9    "flags notes that haven't been updated. Unplaced node catches what the " +
10    "AI can't categorize yet. Two modes: kb-tell (create knowledge), " +
11    "kb-ask (retrieve with citations). Type 'be' for a guided review " +
12    "of stale notes. The tree that replaces wikis, training manuals, " +
13    "and the coworker who always gets interrupted.",
14
15  territory: "storing and retrieving knowledge, references, notes",
16  classifierHints: [
17    /\b(kb|knowledge base|tell kb|save to kb|add to kb|store in kb|ask kb)\b/i,
18    /\b(procedure|protocol|policy|process|steps for)\b/i,
19    /\b(remember this|note that|fyi|heads up|update:|changed to)\b/i,
20  ],
21
22  needs: {
23    models: ["Node", "Note"],
24    services: ["hooks", "llm", "metadata"],
25  },
26
27  optional: {
28    extensions: [
29      "understanding",
30      "tree-compress",
31      "scout",
32      "embed",
33      "explore",
34      "competence",
35      "contradiction",
36      "purpose",
37      "prestige",
38      "values",
39      "channels",
40      "breath",
41      "html-rendering",
42      "treeos-base",
43    ],
44  },
45
46  provides: {
47    models: {},
48    routes: "./routes.js",
49    tools: false,
50    jobs: false,
51    modes: true,
52    guidedMode: "tree:kb-review",
53
54    hooks: {
55      fires: [],
56      listens: ["enrichContext", "breath:exhale"],
57    },
58
59    cli: [
60      {
61        command: "kb [action] [message...]",
62        scope: ["tree"],
63        description: "Knowledge base. Tell or ask.",
64        method: "POST",
65        endpoint: "/root/:rootId/kb",
66        body: ["message"],
67        subcommands: {
68          status: {
69            method: "GET",
70            endpoint: "/root/:rootId/kb/status",
71            description: "Coverage, freshness, unplaced count.",
72          },
73          stale: {
74            method: "GET",
75            endpoint: "/root/:rootId/kb/stale",
76            description: "Notes not updated in 90+ days.",
77          },
78          unplaced: {
79            method: "GET",
80            endpoint: "/root/:rootId/kb/unplaced",
81            description: "Items that couldn't be categorized.",
82          },
83        },
84      },
85    ],
86  },
87};
88
1// kb/modes/ask.js
2// Search the tree. Read notes. Assemble answers with citations.
3// Admit what you don't know.
4
5export default {
6  name: "tree:kb-ask",
7  emoji: "🔍",
8  label: "KB Ask",
9  bigMode: "tree",
10  hidden: true,
11
12  maxMessagesBeforeLoop: 8,
13  preserveContextOnLoop: true,
14
15  toolNames: [
16    "navigate-tree",
17    "get-tree-context",
18    "get-node-notes",
19    "get-searched-notes-by-user",
20    "get-tree",
21  ],
22
23  async buildSystemPrompt({ username, rootId, currentNodeId }) {
24    const kbRoot = await findExtensionRoot(currentNodeId || rootId, "kb") || rootId;
25    const { findKbNodes } = await import("../core.js");
26    const nodes = await findKbNodes(kbRoot);
27    const topicsId = nodes?.topics?.id || "unknown";
28
29    return `You are answering questions from ${username} using a knowledge base.
30
31The Topics tree (node: ${topicsId}) contains organized knowledge as notes on nodes.
32Your job is to find the relevant information and present it clearly with citations.
33
34WORKFLOW:
351. Read the question. Identify what topic area it touches.
362. Navigate to the Topics node (${topicsId}) and search for relevant branches.
373. Navigate to the most relevant branch. Read the notes there in full.
384. If multiple branches might have the answer, check each one.
395. Present the answer clearly. Cite the source.
40
41CITATION FORMAT:
42After your answer, include the source:
43[Source: "note preview" on Topics/Branch Name, updated X ago]
44
45RULES:
46- Answer from the notes. Do not invent information the kb doesn't have.
47- If the answer is in the notes, give it confidently with the citation.
48- If the notes are stale (90+ days old), mention it: "Note: this information is X months old. Verify before relying on it."
49- If the kb doesn't have the answer: "I don't have information about that. Tell me and I'll remember."
50- Keep answers practical. The user is asking because they need to act.
51- If multiple notes are relevant, synthesize them. Cite each source.
52- Never expose node IDs, metadata, or internal structure.`.trim();
53  },
54};
55
1// kb/modes/review.js
2// Guided walk through stale notes. The guidedMode for `be`.
3// Present each stale note, ask if it's still current, update or remove.
4
5export default {
6  name: "tree:kb-review",
7  emoji: "🔄",
8  label: "KB Review",
9  bigMode: "tree",
10  hidden: true,
11
12  maxMessagesBeforeLoop: 20,
13  preserveContextOnLoop: true,
14
15  toolNames: [
16    "navigate-tree",
17    "get-node-notes",
18    "edit-node-note",
19    "delete-node-note",
20    "get-tree-context",
21    "edit-node-schedule",
22  ],
23
24  buildSystemPrompt({ username }) {
25    return `You are guiding ${username} through a knowledge base review.
26
27Find notes that haven't been updated recently. Present each one. Ask if it's still accurate.
28
29FLOW:
301. Find the stalest notes in the Topics tree (oldest first).
312. For each one:
32   - Show the topic branch and note preview.
33   - Say how old it is.
34   - Ask: "Still accurate? Update needed? Or remove?"
353. If they say it's fine: move to the next one.
364. If they give an update: edit the note with the new information.
375. If they say remove: delete the note.
386. After reviewing all stale notes, summarize what was updated, removed, and confirmed.
39
40TONE:
41- Quick and practical. One note at a time.
42- "Vendor Contacts / Cisco: 'Support: 1-800-553-2447, contract #DC-2024-0891'. This is 6 months old. Still current?"
43- Keep it moving. The user is reviewing, not chatting.`.trim();
44  },
45};
46
1// kb/modes/tell.js
2// Parse statements into knowledge. Find or create the right location.
3// Write notes. Detect updates to existing notes.
4
5import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
6import { findKbNodes } from "../core.js";
7
8export default {
9  name: "tree:kb-tell",
10  emoji: "📝",
11  label: "KB Tell",
12  bigMode: "tree",
13  hidden: true,
14
15  maxMessagesBeforeLoop: 6,
16  preserveContextOnLoop: true,
17
18  toolNames: [
19    "navigate-tree",
20    "get-tree-context",
21    "get-tree",
22    "get-node-notes",
23    "create-new-node",
24    "create-node-note",
25    "edit-node-note",
26    "edit-node-name",
27    "get-searched-notes-by-user",
28  ],
29
30  async buildSystemPrompt({ username, rootId, currentNodeId }) {
31    const kbRoot = await findExtensionRoot(currentNodeId || rootId, "kb") || rootId;
32    const nodes = kbRoot ? await findKbNodes(kbRoot) : null;
33
34    const EXPECTED = ["topics", "unplaced"];
35    const found = [];
36    const missing = [];
37    if (nodes) {
38      for (const role of EXPECTED) {
39        if (nodes[role]) found.push(`${nodes[role].name} (role: ${role}, id: ${nodes[role].id})`);
40        else missing.push(role);
41      }
42      for (const [role, info] of Object.entries(nodes)) {
43        if (!EXPECTED.includes(role) && info?.id) {
44          found.push(`${info.name} (role: ${role}, id: ${info.id}) [user-created]`);
45        }
46      }
47    }
48
49    const topicsId = nodes?.topics?.id;
50    const unplacedId = nodes?.unplaced?.id;
51
52    const structureBlock = found.length > 0
53      ? `CURRENT TREE STRUCTURE\n${found.map(f => `- ${f}`).join("\n")}`
54      : "TREE STRUCTURE: not yet discovered.";
55
56    const missingBlock = missing.length > 0
57      ? `\nMISSING STRUCTURAL NODES: ${missing.join(", ")}\nUse create-new-node to recreate them under root ${kbRoot} with the correct metadata.kb.role.`
58      : "";
59
60    return `You are maintaining a knowledge base for ${username}.
61
62${structureBlock}${missingBlock}
63
64The user tells you things. Your job is to organize that information into the tree.
65
66WORKFLOW:
67${topicsId ? `1. Use navigate-tree on the Topics node (${topicsId}) to see existing branches.
682. If a matching branch exists: read its notes. Update existing notes or add new ones.
693. If no matching branch exists: create a new node under Topics (parentId: ${topicsId}). Write the note there.
70${unplacedId ? `4. If you genuinely can't categorize it: write to Unplaced (${unplacedId}). Say so.` : "4. If you genuinely can't categorize it, create an Unplaced node first, then write there."}`
71: `1. Navigate the tree to understand what structure exists.
722. Find or create appropriate locations for the information.
733. Adapt to whatever structure the user has built.`}
74
75ADAPTING TO CUSTOM STRUCTURE
76The user may have reorganized their knowledge base. They might have renamed Topics, added
77new category nodes, or restructured entirely. Work with whatever is there. The tree shape
78IS the application. Read it, don't assume it.
79
80RULES:
81- Keep note content factual and clear. Strip conversational filler.
82- Use the user's exact terminology for names, numbers, procedures.
83- When updating existing notes, preserve what's still accurate. Change only what's new.
84- Topic branch names should be short and descriptive: "Server Rack Layout", "Alert Procedures", "Vendor Contacts".
85- If the user corrects something: find the existing note and update it. Don't create duplicates.
86- Confirm what you filed and where. One sentence.
87- Never expose node IDs, metadata, or internal structure to the user.`.trim();
88  },
89};
90
1/**
2 * KB Dashboard
3 *
4 * Topics, stale notes, unplaced items, recent updates.
5 * Renders via the generic app dashboard.
6 */
7
8import { renderAppDashboard } from "../../html-rendering/html/appDashboard.js";
9import { timeAgo } from "../../html-rendering/html/utils.js";
10
11export function renderKbDashboard({ rootId, rootName, status, stale, unplaced, token, userId, hasEmbed, hasScout, inApp }) {
12  if (!status) {
13    return renderAppDashboard({
14      rootId, rootName, token, userId, inApp,
15      emptyState: { title: "Not initialized yet", message: "Tell it something to get started. The AI will create the topic structure from what you say." },
16      commands: [
17        { cmd: "kb <statement>", desc: "Tell the kb something new" },
18        { cmd: "kb <question>", desc: "Ask the kb something" },
19      ],
20      chatBar: { placeholder: "Tell me something to get started...", endpoint: `/api/v1/root/${rootId}/kb` },
21    });
22  }
23
24  const profile = status.profile || {};
25
26  // Subtitle
27  const subParts = [];
28  const maintainers = (profile.maintainers || []).slice(0, 5);
29  if (maintainers.length > 0) subParts.push(`Maintained by ${maintainers.join(", ")}`);
30  if (profile.description) subParts.push(profile.description);
31
32  // Stats
33  const stats = [
34    { value: String(status.topicCount || 0), label: "topics" },
35    { value: String(status.noteCount || 0), label: "notes" },
36  ];
37  if (status.staleNotes > 0) stats.push({ value: String(status.staleNotes), label: "stale" });
38  if (status.unplacedCount > 0) stats.push({ value: String(status.unplacedCount), label: "unplaced" });
39
40  // Tags for topics + capabilities
41  const tags = [];
42  const topicNoteCounts = status.topicNoteCounts || {};
43  if (status.coverage?.length > 0) {
44    for (const t of status.coverage) {
45      tags.push({ label: t, count: topicNoteCounts[t] || null });
46    }
47  }
48  // Capability badges
49  if (hasScout) tags.push({ label: "scout", color: "#48bb78" });
50  if (hasEmbed) tags.push({ label: "semantic", color: "#48bb78" });
51
52  // Cards
53  const cards = [];
54
55  // Stale notes
56  cards.push({
57    title: "Stale Notes",
58    items: (stale || []).slice(0, 10).map(s => ({
59      text: s.nodeName,
60      sub: `${s.daysStale}d old . ${s.preview || ""}`,
61    })),
62    empty: "No stale notes. Everything is fresh.",
63  });
64
65  // Unplaced
66  cards.push({
67    title: "Unplaced",
68    items: (unplaced || []).slice(0, 10).map(u => ({
69      text: u.content,
70      sub: timeAgo(u.date),
71    })),
72    empty: "Nothing unplaced. Everything has a home.",
73  });
74
75  // Recent updates
76  if (status.recentUpdates?.length > 0) {
77    cards.push({
78      title: "Recent Updates",
79      items: status.recentUpdates.map(u => ({
80        text: u.name,
81        sub: timeAgo(u.date),
82      })),
83    });
84  }
85
86  return renderAppDashboard({
87    rootId, rootName: status.name || rootName, token, userId, inApp,
88    subtitle: subParts.join(" . ") || null,
89    stats,
90    tags: tags.length > 0 ? tags : null,
91    cards,
92    commands: [
93      { cmd: "kb <statement>", desc: "Tell the kb something new" },
94      { cmd: "kb <question>", desc: "Ask the kb something" },
95      { cmd: "kb status", desc: "Coverage and freshness" },
96      { cmd: "kb stale", desc: "Notes needing review" },
97      { cmd: "kb unplaced", desc: "Uncategorized items" },
98      { cmd: "kb review", desc: "Guided review of stale notes" },
99      { cmd: "be", desc: "Start guided review mode" },
100    ],
101    chatBar: { placeholder: "Tell me something or ask a question...", endpoint: `/api/v1/root/${rootId}/kb` },
102  });
103}
104
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import log from "../../seed/log.js";
5import NodeModel from "../../seed/models/node.js";
6import UserModel from "../../seed/models/user.js";
7import {
8  isInitialized,
9  getStatus,
10  getStaleNotes,
11  getUnplaced,
12} from "./core.js";
13import { handleMessage } from "./handler.js";
14
15const router = express.Router();
16
17// ── HTML Dashboard (GET with ?html) ──
18router.get("/root/:rootId/kb", async (req, res, next) => {
19  if (!("html" in req.query)) return next();
20  try {
21    const { isHtmlEnabled } = await import("../html-rendering/config.js");
22    if (!isHtmlEnabled()) return next();
23    const urlAuth = (await import("../html-rendering/urlAuth.js")).default;
24    urlAuth(req, res, async () => {
25      const { rootId } = req.params;
26      const root = await NodeModel.findById(rootId).select("name metadata").lean();
27      if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Not found");
28
29      let status = null, stale = null, unplaced = null;
30      if (await isInitialized(rootId)) {
31        [status, stale, unplaced] = await Promise.all([
32          getStatus(rootId), getStaleNotes(rootId), getUnplaced(rootId),
33        ]);
34      }
35
36      // Check search capabilities
37      const { getExtension } = await import("../loader.js");
38      const hasEmbed = !!getExtension("embed");
39      const hasScout = !!getExtension("scout");
40
41      const { renderKbDashboard } = await import("./pages/dashboard.js");
42      res.send(renderKbDashboard({
43        rootId,
44        rootName: root.name,
45        status,
46        stale,
47        unplaced,
48        token: req.query.token || null,
49        userId: req.userId,
50        hasEmbed,
51        hasScout,
52        inApp: !!req.query.inApp,
53      }));
54    });
55  } catch (err) {
56    sendError(res, 500, ERR.INTERNAL, "Dashboard failed");
57  }
58});
59
60/**
61 * POST /root/:rootId/kb
62 * Main entry. Routes tell vs ask based on intent.
63 */
64router.post("/root/:rootId/kb", authenticate, async (req, res) => {
65  try {
66    const { rootId } = req.params;
67    const rawMessage = req.body.message;
68    const message = Array.isArray(rawMessage) ? rawMessage.join(" ") : rawMessage;
69    if (!message) return sendError(res, 400, ERR.INVALID_INPUT, "message required");
70
71    const root = await NodeModel.findById(rootId).select("rootOwner contributors").lean();
72    if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
73
74    const userId = req.userId;
75    const isOwner = root.rootOwner?.toString() === userId;
76    const isContributor = root.contributors?.some(c => c.toString() === userId);
77    if (!isOwner && !isContributor) return sendError(res, 403, ERR.FORBIDDEN, "No access");
78
79    const { isExtensionBlockedAtNode } = await import("../../seed/tree/extensionScope.js");
80    if (await isExtensionBlockedAtNode("kb", rootId)) {
81      return sendError(res, 403, ERR.EXTENSION_BLOCKED, "KB is blocked on this branch.");
82    }
83
84    const user = await UserModel.findById(userId).select("username").lean();
85    const username = user?.username || "user";
86
87    const result = await handleMessage(message, { userId, username, rootId, res });
88
89    if (result.error) {
90      if (!res.headersSent) sendError(res, result.status || 500, result.code || ERR.FORBIDDEN, result.message);
91      return;
92    }
93
94    if (!res.headersSent) sendOk(res, result);
95  } catch (err) {
96    log.error("KB", "Route error:", err.message);
97    if (!res.headersSent) sendError(res, 500, ERR.INTERNAL, "KB request failed");
98  }
99});
100
101router.get("/root/:rootId/kb/status", authenticate, async (req, res) => {
102  try {
103    const status = await getStatus(req.params.rootId);
104    if (!status) return sendError(res, 404, ERR.TREE_NOT_FOUND, "KB not found");
105    sendOk(res, status);
106  } catch (err) {
107    sendError(res, 500, ERR.INTERNAL, "Status failed");
108  }
109});
110
111router.get("/root/:rootId/kb/stale", authenticate, async (req, res) => {
112  try {
113    const stale = await getStaleNotes(req.params.rootId);
114    sendOk(res, { stale, count: stale.length });
115  } catch (err) {
116    sendError(res, 500, ERR.INTERNAL, "Stale query failed");
117  }
118});
119
120router.get("/root/:rootId/kb/unplaced", authenticate, async (req, res) => {
121  try {
122    const items = await getUnplaced(req.params.rootId);
123    sendOk(res, { items, count: items.length });
124  } catch (err) {
125    sendError(res, 500, ERR.INTERNAL, "Unplaced query failed");
126  }
127});
128
129export default router;
130

Versions

Version Published Downloads
1.0.2 38d ago 0
1.0.1 46d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star kb

Comments

Loading comments...

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