5e3639217e649b4ab28f1181d8d0a77ba8ebc0b20ed0f3f70ad9a2ae12781a02| Command | Method | Description |
|---|---|---|
kb | POST | Knowledge base. Tell or ask. |
kb status | GET | Coverage, freshness, unplaced count. |
kb stale | GET | Notes not updated in 90+ days. |
kb unplaced | GET | Items that couldn't be categorized. |
enrichContextbreath:exhale1/**
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}
3291/**
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}
371/**
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}
1951export 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};
881// 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};
551// 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};
461// 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};
901/**
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}
1041import 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
treeos ext star kb
Post comments from the CLI: treeos ext comment kb "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...