efc010efca8f3ddb28236fc4200c8e3b5e8cbd29db86abc1908f762fddb84f98| Command | Method | Description |
|---|---|---|
codebook | GET | Codebook dictionary. No action shows your dictionary. Actions: compress, clear, stats. |
codebook compress | POST | Force a compression pass now |
codebook clear | DELETE | Wipe the dictionary. Start fresh. |
codebook stats | GET | Notes since last compression, dictionary size, timestamps |
afterNoteenrichContext1/**
2 * Codebook Core
3 *
4 * Tracks note accumulation per user-node pair. When enough new material
5 * has accumulated, runs a compression pass via runChat to extract a
6 * dictionary of recurring concepts, shorthand, and compressed references.
7 *
8 * The dictionary lives in metadata.codebook on the node, namespaced by userId.
9 * enrichContext injects it so the AI picks up the compressed language.
10 */
11
12import log from "../../seed/log.js";
13import Node from "../../seed/models/node.js";
14import Note from "../../seed/models/note.js";
15import { SYSTEM_ROLE, CONTENT_TYPE } from "../../seed/protocol.js";
16import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
17
18// Held from init
19let _runChat = null;
20export function setRunChat(fn) { _runChat = fn; }
21
22// ─────────────────────────────────────────────────────────────────────────
23// CONFIG (stored on .config metadata.codebook)
24// ─────────────────────────────────────────────────────────────────────────
25
26const DEFAULTS = {
27 compressionThreshold: 20, // notes before triggering compression
28 maxNotesForPrompt: 40, // recent notes fed to the compression prompt
29 minInteractions: 5, // minimum long-memory interactions before codebook activates
30 maxDictionaryEntries: 30, // cap entries in the dictionary
31};
32
33export async function getCodebookConfig() {
34 const configNode = await Node.findOne({ systemRole: SYSTEM_ROLE.CONFIG }).select("metadata").lean();
35 if (!configNode) return { ...DEFAULTS };
36
37 const meta = configNode.metadata instanceof Map
38 ? configNode.metadata.get("codebook") || {}
39 : configNode.metadata?.codebook || {};
40
41 return {
42 compressionThreshold: meta.compressionThreshold ?? DEFAULTS.compressionThreshold,
43 maxNotesForPrompt: meta.maxNotesForPrompt ?? DEFAULTS.maxNotesForPrompt,
44 minInteractions: meta.minInteractions ?? DEFAULTS.minInteractions,
45 maxDictionaryEntries: meta.maxDictionaryEntries ?? DEFAULTS.maxDictionaryEntries,
46 };
47}
48
49// ─────────────────────────────────────────────────────────────────────────
50// ACCUMULATION
51// ─────────────────────────────────────────────────────────────────────────
52
53/**
54 * Increment the note counter for a user-node pair.
55 * Returns the new count.
56 */
57export async function incrementNoteCount(nodeId, userId) {
58 const key = `metadata.codebook.${userId}.notesSinceCompression`;
59
60 const result = await Node.findByIdAndUpdate(
61 nodeId,
62 { $inc: { [key]: 1 } },
63 { new: true, select: `metadata` },
64 ).lean();
65
66 if (!result) return 0;
67
68 const meta = result.metadata instanceof Map
69 ? result.metadata.get("codebook") || {}
70 : result.metadata?.codebook || {};
71
72 return meta[userId]?.notesSinceCompression || 0;
73}
74
75/**
76 * Reset the note counter after compression.
77 */
78async function resetNoteCount(nodeId, userId) {
79 const key = `metadata.codebook.${userId}.notesSinceCompression`;
80 await Node.findByIdAndUpdate(nodeId, { $set: { [key]: 0 } });
81}
82
83// ─────────────────────────────────────────────────────────────────────────
84// LONG MEMORY CHECK
85// ─────────────────────────────────────────────────────────────────────────
86
87/**
88 * Check if a node has enough interaction history to justify compression.
89 * Uses long-memory's metadata.memory if available.
90 */
91async function hasEnoughHistory(nodeId, minInteractions) {
92 const node = await Node.findById(nodeId).select("metadata").lean();
93 if (!node) return false;
94
95 const meta = node.metadata instanceof Map
96 ? Object.fromEntries(node.metadata)
97 : (node.metadata || {});
98
99 const memory = meta.memory;
100 if (!memory) return true; // No long-memory installed, skip the check
101
102 return (memory.totalInteractions || 0) >= minInteractions;
103}
104
105// ─────────────────────────────────────────────────────────────────────────
106// COMPRESSION
107// ─────────────────────────────────────────────────────────────────────────
108
109// In-flight compression guard: don't run two compressions on the same pair
110const _inFlight = new Set();
111
112/**
113 * Run a compression pass for a user-node pair.
114 * Loads recent notes, builds a prompt, calls runChat, parses the result,
115 * and writes the dictionary to metadata.codebook.{userId}.dictionary.
116 */
117export async function runCompression(nodeId, userId, username) {
118 const pairKey = `${nodeId}:${userId}`;
119 if (_inFlight.has(pairKey)) return null;
120 _inFlight.add(pairKey);
121
122 try {
123 if (!_runChat) {
124 log.warn("Codebook", "runChat not available, skipping compression");
125 return null;
126 }
127
128 const config = await getCodebookConfig();
129
130 // Check long-memory interaction threshold
131 const enough = await hasEnoughHistory(nodeId, config.minInteractions);
132 if (!enough) {
133 log.debug("Codebook", `Node ${nodeId} below interaction threshold, skipping compression`);
134 await resetNoteCount(nodeId, userId);
135 return null;
136 }
137
138 // Load recent notes at this node from this user
139 const notes = await Note.find({
140 nodeId,
141 userId,
142 contentType: CONTENT_TYPE.TEXT,
143 })
144 .sort({ createdAt: -1 })
145 .limit(config.maxNotesForPrompt)
146 .select("content createdAt")
147 .lean();
148
149 if (notes.length < 3) {
150 await resetNoteCount(nodeId, userId);
151 return null;
152 }
153
154 // Load existing dictionary if any
155 const node = await Node.findById(nodeId).select("name metadata").lean();
156 const meta = node?.metadata instanceof Map
157 ? node.metadata.get("codebook") || {}
158 : node?.metadata?.codebook || {};
159 const existingDict = meta[userId]?.dictionary || null;
160
161 // Build compression prompt
162 const noteTexts = notes
163 .reverse()
164 .map((n, i) => `[${i + 1}] ${n.content}`)
165 .join("\n\n");
166
167 const existingSection = existingDict
168 ? `\n\nExisting codebook dictionary (update and expand, do not discard valid entries):\n${JSON.stringify(existingDict, null, 2)}`
169 : "";
170
171 const prompt =
172 `You are analyzing the conversation history between a user and this node to extract a codebook dictionary.\n\n` +
173 `Node: "${node?.name || nodeId}"\n` +
174 `Recent notes (${notes.length}):\n${noteTexts}` +
175 existingSection +
176 `\n\nExtract recurring concepts, shorthand, compressed references, and frequently used terms ` +
177 `into a dictionary. Each entry should have a short key and a description of what it means ` +
178 `in the context of this relationship. Keep entries dense and useful. Maximum ${config.maxDictionaryEntries} entries.\n\n` +
179 `Respond with ONLY a JSON object. Keys are the shorthand/concept. Values are brief descriptions.\n` +
180 `Example: { "standup": "daily morning sync meeting with the design team", "the refactor": "ongoing migration from REST to GraphQL in the payments service" }`;
181
182 // Find the root for this node (needed for runChat)
183 let rootId = null;
184 try {
185 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
186 const root = await resolveRootNode(nodeId);
187 rootId = root?._id || null;
188 } catch (err) {
189 log.debug("Codebook", "Root node resolution failed:", err.message);
190 }
191
192 const { answer } = await _runChat({
193 userId,
194 username: username || "system",
195 message: prompt,
196 mode: "tree:respond",
197 rootId,
198 nodeId,
199 slot: "codebook",
200 });
201
202 if (!answer) {
203 log.debug("Codebook", `Compression returned empty for ${pairKey}`);
204 await resetNoteCount(nodeId, userId);
205 return null;
206 }
207
208 // Parse the dictionary from the AI response
209 const dictionary = parseJsonSafe(answer);
210 if (!dictionary || typeof dictionary !== "object" || Array.isArray(dictionary)) {
211 log.warn("Codebook", `Compression produced invalid dictionary for ${pairKey}`);
212 await resetNoteCount(nodeId, userId);
213 return null;
214 }
215
216 // Cap entries
217 const entries = Object.entries(dictionary).slice(0, config.maxDictionaryEntries);
218 const capped = Object.fromEntries(entries);
219
220 // Write to metadata
221 const dictKey = `metadata.codebook.${userId}.dictionary`;
222 const tsKey = `metadata.codebook.${userId}.lastCompressed`;
223 const countKey = `metadata.codebook.${userId}.notesSinceCompression`;
224
225 await Node.findByIdAndUpdate(nodeId, {
226 $set: {
227 [dictKey]: capped,
228 [tsKey]: new Date().toISOString(),
229 [countKey]: 0,
230 },
231 });
232
233 log.verbose("Codebook", `Compressed ${notes.length} notes into ${entries.length} entries for ${pairKey}`);
234 return capped;
235 } catch (err) {
236 log.error("Codebook", `Compression failed for ${pairKey}: ${err.message}`);
237 return null;
238 } finally {
239 _inFlight.delete(pairKey);
240 }
241}
242
243// ─────────────────────────────────────────────────────────────────────────
244// READER
245// ─────────────────────────────────────────────────────────────────────────
246
247/**
248 * Get the codebook dictionary for a user at a node.
249 */
250export async function getCodebook(nodeId, userId) {
251 const node = await Node.findById(nodeId).select("metadata").lean();
252 if (!node) return null;
253
254 const meta = node.metadata instanceof Map
255 ? node.metadata.get("codebook") || {}
256 : node.metadata?.codebook || {};
257
258 return meta[userId] || null;
259}
260
261/**
262 * Clear the codebook for a user at a node.
263 */
264export async function clearCodebook(nodeId, userId) {
265 const key = `metadata.codebook.${userId}`;
266 await Node.findByIdAndUpdate(nodeId, { $unset: { [key]: 1 } });
267}
2681import express from "express";
2import { sendError, ERR } from "../../seed/protocol.js";
3import urlAuth from "../html-rendering/urlAuth.js";
4import { htmlOnly, buildQS } from "../html-rendering/htmlHelpers.js";
5import Node from "../../seed/models/node.js";
6import { renderCodebookPage } from "./pages/codebookPage.js";
7
8export default function buildHtmlRoutes() {
9 const router = express.Router();
10
11 router.get("/root/:rootId/codebook", urlAuth, htmlOnly, async (req, res) => {
12 try {
13 const { rootId } = req.params;
14 const userId = req.userId;
15 const root = await Node.findById(rootId).select("name children").lean();
16 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
17
18 const qs = buildQS(req);
19
20 // Collect all node IDs in the tree via BFS
21 const nodeIds = [rootId];
22 const queue = [rootId];
23 const nodeNames = new Map();
24 nodeNames.set(rootId, root.name);
25
26 while (queue.length > 0) {
27 const batch = queue.splice(0, 50);
28 const nodes = await Node.find({ parent: { $in: batch } })
29 .select("_id name children parent")
30 .lean();
31 for (const n of nodes) {
32 const id = String(n._id);
33 nodeIds.push(id);
34 nodeNames.set(id, n.name);
35 queue.push(id);
36 }
37 if (nodeIds.length > 500) break;
38 }
39
40 // Batch fetch metadata for all nodes
41 const nodesWithCodebook = await Node.find({
42 _id: { $in: nodeIds },
43 [`metadata.codebook.${userId}`]: { $exists: true },
44 }).select("_id name metadata").lean();
45
46 const entries = [];
47 for (const node of nodesWithCodebook) {
48 const meta = node.metadata instanceof Map
49 ? node.metadata.get("codebook") || {}
50 : node.metadata?.codebook || {};
51 const userEntry = meta[userId];
52 if (!userEntry?.dictionary || Object.keys(userEntry.dictionary).length === 0) continue;
53 entries.push({
54 nodeId: String(node._id),
55 nodeName: node.name,
56 dictionary: userEntry.dictionary,
57 notesSinceCompression: userEntry.notesSinceCompression || 0,
58 lastCompressed: userEntry.lastCompressed || null,
59 });
60 }
61
62 res.send(renderCodebookPage({
63 rootId,
64 rootName: root.name,
65 entries,
66 qs,
67 }));
68 } catch (err) {
69 sendError(res, 500, ERR.INTERNAL, "Codebook page failed");
70 }
71 });
72
73 return router;
74}
751import log from "../../seed/log.js";
2import tools from "./tools.js";
3import {
4 setRunChat,
5 getCodebookConfig,
6 incrementNoteCount,
7 runCompression,
8 getCodebook,
9 clearCodebook,
10} from "./core.js";
11
12export async function init(core) {
13 // Wire runChat with BACKGROUND priority (compression runs are background work)
14 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
15
16 core.llm.registerRootLlmSlot("codebook");
17
18 setRunChat(async (opts) => {
19 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
20 return core.llm.runChat({ ...opts, llmPriority: BG });
21 });
22
23 const config = await getCodebookConfig();
24
25 // Listen to every note create/edit. Increment the counter for the user-node pair.
26 // When the counter crosses the compression threshold, fire a background compression.
27 core.hooks.register("afterNote", async ({ note, nodeId, userId, contentType, action }) => {
28 // Only track text notes, only creates and edits
29 if (contentType !== "text") return;
30 if (action !== "create" && action !== "edit") return;
31
32 // Skip system writes (pulse, etc.)
33 if (!userId || userId === "SYSTEM") return;
34
35 // Skip system nodes
36 try {
37 const Node = core.models.Node;
38 const node = await Node.findById(nodeId).select("systemRole").lean();
39 if (node?.systemRole) return;
40 } catch { return; }
41
42 const count = await incrementNoteCount(nodeId, userId);
43
44 if (count >= config.compressionThreshold) {
45 // Look up username for the compression prompt
46 let username = null;
47 try {
48 const user = await core.models.User.findById(userId).select("username").lean();
49 username = user?.username;
50 } catch (err) {
51 log.debug("Codebook", "Username lookup failed:", err.message);
52 }
53
54 // Fire compression in background, don't block the note write
55 runCompression(nodeId, userId, username).catch((err) => {
56 log.debug("Codebook", `Background compression failed: ${err.message}`);
57 });
58 }
59 }, "codebook");
60
61 // Inject this user's codebook into AI context. userId is threaded through
62 // getContextForAi options into enrichContext hookData by the kernel.
63 core.hooks.register("enrichContext", async ({ context, node, meta, userId }) => {
64 if (!userId) return;
65
66 const codebook = meta.codebook;
67 if (!codebook) return;
68
69 const userEntry = codebook[userId];
70 if (userEntry?.dictionary && Object.keys(userEntry.dictionary).length > 0) {
71 context.codebook = userEntry.dictionary;
72 }
73 }, "codebook");
74
75 const { default: router } = await import("./routes.js");
76
77 // HTML page + tree quick link
78 try {
79 const { getExtension } = await import("../loader.js");
80 const htmlExt = getExtension("html-rendering");
81 const base = getExtension("treeos-base");
82 if (htmlExt) {
83 const { default: buildHtmlRoutes } = await import("./htmlRoutes.js");
84 htmlExt.router.use("/", buildHtmlRoutes());
85 }
86 base?.exports?.registerSlot?.("tree-quick-links", "codebook", ({ rootId, queryString }) =>
87 `<a href="/api/v1/root/${rootId}/codebook${queryString}" class="back-link">Codebook</a>`,
88 { priority: 50 }
89 );
90 } catch {}
91
92 return {
93 router,
94 tools,
95 exports: {
96 getCodebook,
97 clearCodebook,
98 runCompression,
99 },
100 };
101}
1021export default {
2 name: "codebook",
3 version: "1.0.2",
4 builtFor: "treeos-cascade",
5 description:
6 "What makes two nodes faster together over time. Listens to afterNote on any node. Tracks " +
7 "conversation patterns between specific users and specific nodes. When patterns recur, when " +
8 "the same concepts are referenced repeatedly, when shorthand emerges, the extension " +
9 "periodically runs a compression pass. The compression pass is an AI call using runChat. " +
10 "The prompt says: here are the last N conversations at this node with this user, extract " +
11 "recurring concepts, shorthand, compressed references, and produce a dictionary. The " +
12 "dictionary writes to metadata.codebook on the node, namespaced per user. enrichContext " +
13 "injects the codebook into the AI system prompt when that user is active at that node. " +
14 "The AI picks up the compressed language without needing the full conversation history. " +
15 "The codebook is the relationship memory. Dense. Compact. Earned through repeated " +
16 "interaction. The dependency on long memory is soft but important. Long memory tells the " +
17 "codebook extension which relationships are active and how frequently nodes interact. " +
18 "Without long memory, codebook would have to track all of that itself. With long memory, " +
19 "it reads metadata.memory and knows which node-user pairs have enough history to justify " +
20 "a compression pass. The compression runs after a threshold of new conversations, not " +
21 "after every note. Most notes do not trigger compression. Only when enough new material " +
22 "has accumulated does the extension spend tokens to update the codebook.",
23
24 needs: {
25 services: ["llm"],
26 models: ["Node"],
27 },
28
29 optional: {
30 extensions: ["long-memory"],
31 },
32
33 provides: {
34 models: {},
35 routes: "./routes.js",
36 tools: true,
37 jobs: false,
38 orchestrator: false,
39 energyActions: {},
40 sessionTypes: {},
41 env: [],
42 cli: [
43 {
44 command: "codebook [action]", scope: ["tree"],
45 description: "Codebook dictionary. No action shows your dictionary. Actions: compress, clear, stats.",
46 method: "GET",
47 endpoint: "/node/:nodeId/codebook",
48 subcommands: {
49 "compress": {
50 method: "POST",
51 endpoint: "/node/:nodeId/codebook/compress",
52 description: "Force a compression pass now",
53 },
54 "clear": {
55 method: "DELETE",
56 endpoint: "/node/:nodeId/codebook",
57 description: "Wipe the dictionary. Start fresh.",
58 },
59 "stats": {
60 method: "GET",
61 endpoint: "/node/:nodeId/codebook/stats",
62 description: "Notes since last compression, dictionary size, timestamps",
63 },
64 },
65 },
66 ],
67
68 hooks: {
69 fires: [],
70 listens: ["afterNote", "enrichContext"],
71 },
72 },
73};
741import { page } from "../../html-rendering/html/layout.js";
2import { baseStyles, glassHeaderStyles, responsiveBase } from "../../html-rendering/html/baseStyles.js";
3import { escapeHtml } from "../../html-rendering/html/utils.js";
4
5export function renderCodebookPage({ rootId, rootName, entries, qs }) {
6 const css = `
7 ${baseStyles}
8 ${glassHeaderStyles}
9 ${responsiveBase}
10
11 .node-card {
12 margin-bottom: 24px;
13 padding: 20px;
14 background: rgba(255,255,255,0.04);
15 border: 1px solid rgba(255,255,255,0.08);
16 border-radius: 12px;
17 animation: fadeInUp 0.4s ease-out both;
18 }
19 .node-name {
20 font-size: 15px;
21 font-weight: 600;
22 color: rgba(255,255,255,0.6);
23 margin-bottom: 4px;
24 }
25 .node-path {
26 font-size: 11px;
27 color: rgba(255,255,255,0.3);
28 margin-bottom: 14px;
29 }
30 .node-stats {
31 display: flex;
32 gap: 12px;
33 flex-wrap: wrap;
34 margin-bottom: 14px;
35 }
36 .node-stat {
37 font-size: 11px;
38 color: rgba(255,255,255,0.4);
39 padding: 4px 10px;
40 background: rgba(255,255,255,0.04);
41 border-radius: 6px;
42 }
43 .node-stat strong { color: rgba(255,255,255,0.4); }
44
45 .dict-grid {
46 display: grid;
47 grid-template-columns: 1fr;
48 gap: 6px;
49 }
50 .dict-entry {
51 display: flex;
52 gap: 12px;
53 padding: 8px 12px;
54 background: rgba(255,255,255,0.03);
55 border-radius: 6px;
56 border-left: 3px solid rgba(102, 126, 234, 0.3);
57 font-size: 13px;
58 line-height: 1.5;
59 }
60 .dict-term {
61 color: rgba(102, 126, 234, 0.9);
62 font-weight: 600;
63 white-space: nowrap;
64 flex-shrink: 0;
65 min-width: 80px;
66 }
67 .dict-def {
68 color: rgba(255,255,255,0.35);
69 }
70
71 .empty-state {
72 text-align: center;
73 padding: 60px 20px;
74 color: rgba(255,255,255,0.3);
75 font-size: 14px;
76 line-height: 1.8;
77 }
78
79 .back-nav { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
80 .back-link {
81 display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px;
82 background: rgba(var(--glass-water-rgb), var(--glass-alpha)); backdrop-filter: blur(22px);
83 color: rgba(255,255,255,0.6); text-decoration: none; border-radius: 980px;
84 font-weight: 600; font-size: 14px; border: 1px solid rgba(255,255,255,0.12);
85 }
86 .back-link:hover { background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover)); }
87
88 .stats-row {
89 display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px;
90 }
91 .stat {
92 padding: 8px 16px;
93 background: rgba(255,255,255,0.04);
94 border-radius: 8px;
95 font-size: 13px;
96 color: rgba(255,255,255,0.5);
97 border: 1px solid rgba(255,255,255,0.06);
98 }
99 .stat strong { color: rgba(255,255,255,0.5); }
100 `;
101
102 const totalEntries = entries.reduce((sum, e) => sum + Object.keys(e.dictionary).length, 0);
103
104 let content;
105 if (entries.length === 0) {
106 content = `<div class="empty-state">
107 No codebook entries yet.<br>
108 They build after enough conversations at individual nodes.
109 </div>`;
110 } else {
111 content = entries.map((entry, i) => {
112 const dictEntries = Object.entries(entry.dictionary);
113 const lastDate = entry.lastCompressed ? new Date(entry.lastCompressed).toLocaleDateString() : "never";
114 return `<div class="node-card" style="animation-delay: ${i * 0.08}s">
115 <div class="node-name">${escapeHtml(entry.nodeName)}</div>
116 ${entry.path ? `<div class="node-path">${escapeHtml(entry.path)}</div>` : ''}
117 <div class="node-stats">
118 <div class="node-stat"><strong>${dictEntries.length}</strong> terms</div>
119 <div class="node-stat">Pending: <strong>${entry.notesSinceCompression || 0}</strong></div>
120 <div class="node-stat">Last compressed: <strong>${lastDate}</strong></div>
121 </div>
122 <div class="dict-grid">
123 ${dictEntries.map(([term, def]) => `<div class="dict-entry">
124 <div class="dict-term">${escapeHtml(term)}</div>
125 <div class="dict-def">${escapeHtml(String(def))}</div>
126 </div>`).join('')}
127 </div>
128 </div>`;
129 }).join('');
130 }
131
132 const body = `
133 <div class="container" style="max-width: 700px;">
134 <div class="back-nav">
135 <a href="/api/v1/root/${escapeHtml(rootId)}${qs}" class="back-link">Back to ${escapeHtml(rootName || 'Tree')}</a>
136 </div>
137
138 <div class="header">
139 <h1>Codebook</h1>
140 <div class="header-subtitle">${escapeHtml(rootName || 'Tree')} . Compressed language built from conversation.</div>
141 </div>
142
143 <div class="stats-row">
144 <div class="stat">Nodes: <strong>${entries.length}</strong></div>
145 <div class="stat">Total terms: <strong>${totalEntries}</strong></div>
146 </div>
147
148 ${content}
149 </div>
150 `;
151
152 return page({ title: `${rootName || 'Tree'} . Codebook`, css, body, js: '' });
153}
1541import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getCodebook, clearCodebook, runCompression } from "./core.js";
5import User from "../../seed/models/user.js";
6
7const router = express.Router();
8
9// GET /node/:nodeId/codebook - dictionary for current user
10router.get("/node/:nodeId/codebook", authenticate, async (req, res) => {
11 try {
12 const entry = await getCodebook(req.params.nodeId, req.userId);
13 if (!entry || !entry.dictionary) {
14 return sendOk(res, { message: "No codebook yet. It builds after enough conversations." });
15 }
16 sendOk(res, {
17 entries: Object.keys(entry.dictionary).length,
18 lastCompressed: entry.lastCompressed,
19 dictionary: entry.dictionary,
20 });
21 } catch (err) {
22 sendError(res, 500, ERR.INTERNAL, err.message);
23 }
24});
25
26// GET /node/:nodeId/codebook/stats
27router.get("/node/:nodeId/codebook/stats", authenticate, async (req, res) => {
28 try {
29 const entry = await getCodebook(req.params.nodeId, req.userId);
30 sendOk(res, {
31 notesSinceCompression: entry?.notesSinceCompression || 0,
32 dictionarySize: entry?.dictionary ? Object.keys(entry.dictionary).length : 0,
33 lastCompressed: entry?.lastCompressed || null,
34 });
35 } catch (err) {
36 sendError(res, 500, ERR.INTERNAL, err.message);
37 }
38});
39
40// POST /node/:nodeId/codebook/compress - force compression
41router.post("/node/:nodeId/codebook/compress", authenticate, async (req, res) => {
42 try {
43 const user = await User.findById(req.userId).select("username").lean();
44 const result = await runCompression(req.params.nodeId, req.userId, user?.username);
45 if (!result) return sendOk(res, { message: "Compression skipped. Not enough history." });
46 sendOk(res, { entries: Object.keys(result).length, dictionary: result });
47 } catch (err) {
48 sendError(res, 500, ERR.INTERNAL, err.message);
49 }
50});
51
52// DELETE /node/:nodeId/codebook - clear dictionary
53router.delete("/node/:nodeId/codebook", authenticate, async (req, res) => {
54 try {
55 await clearCodebook(req.params.nodeId, req.userId);
56 sendOk(res, { message: "Codebook cleared" });
57 } catch (err) {
58 sendError(res, 500, ERR.INTERNAL, err.message);
59 }
60});
61
62export default router;
631import { z } from "zod";
2import { getCodebook, clearCodebook, runCompression } from "./core.js";
3
4export default [
5 {
6 name: "get-codebook",
7 description:
8 "Get the codebook dictionary for a user at a node. Shows the compressed language, shorthand, and recurring concepts that have emerged from their interaction history.",
9 schema: {
10 nodeId: z.string().describe("The node to check."),
11 targetUserId: z.string().optional().describe("User ID to check. Defaults to the current user."),
12 userId: z.string().describe("Injected by server. Ignore."),
13 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
14 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
15 },
16 annotations: {
17 readOnlyHint: true,
18 destructiveHint: false,
19 idempotentHint: true,
20 openWorldHint: false,
21 },
22 handler: async ({ nodeId, targetUserId, userId }) => {
23 try {
24 const uid = targetUserId || userId;
25 const codebook = await getCodebook(nodeId, uid);
26 if (!codebook || !codebook.dictionary) {
27 return { content: [{ type: "text", text: "No codebook exists for this user at this node yet. It builds after enough conversations." }] };
28 }
29 return {
30 content: [{
31 type: "text",
32 text: JSON.stringify({
33 nodeId,
34 userId: uid,
35 lastCompressed: codebook.lastCompressed,
36 notesSinceCompression: codebook.notesSinceCompression || 0,
37 entries: Object.keys(codebook.dictionary).length,
38 dictionary: codebook.dictionary,
39 }, null, 2),
40 }],
41 };
42 } catch (err) {
43 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
44 }
45 },
46 },
47 {
48 name: "compress-codebook",
49 description:
50 "Force a compression pass for a user at a node. Analyzes recent conversation history and updates the codebook dictionary. Normally this happens automatically after enough notes accumulate.",
51 schema: {
52 nodeId: z.string().describe("The node to compress."),
53 userId: z.string().describe("Injected by server. Ignore."),
54 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
55 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
56 },
57 annotations: {
58 readOnlyHint: false,
59 destructiveHint: false,
60 idempotentHint: false,
61 openWorldHint: true,
62 },
63 handler: async ({ nodeId, userId }) => {
64 try {
65 const result = await runCompression(nodeId, userId, null);
66 if (!result) {
67 return { content: [{ type: "text", text: "Compression skipped. Not enough conversation history at this node." }] };
68 }
69 return {
70 content: [{
71 type: "text",
72 text: JSON.stringify({
73 message: "Codebook updated",
74 entries: Object.keys(result).length,
75 dictionary: result,
76 }, null, 2),
77 }],
78 };
79 } catch (err) {
80 return { content: [{ type: "text", text: `Compression failed: ${err.message}` }] };
81 }
82 },
83 },
84 {
85 name: "clear-codebook",
86 description: "Clear the codebook dictionary for a user at a node. The relationship starts fresh.",
87 schema: {
88 nodeId: z.string().describe("The node to clear."),
89 targetUserId: z.string().optional().describe("User ID to clear. Defaults to the current user."),
90 userId: z.string().describe("Injected by server. Ignore."),
91 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
92 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
93 },
94 annotations: {
95 readOnlyHint: false,
96 destructiveHint: true,
97 idempotentHint: true,
98 openWorldHint: false,
99 },
100 handler: async ({ nodeId, targetUserId, userId }) => {
101 try {
102 const uid = targetUserId || userId;
103 await clearCodebook(nodeId, uid);
104 return { content: [{ type: "text", text: `Codebook cleared for user ${uid} at node ${nodeId}.` }] };
105 } catch (err) {
106 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
107 }
108 },
109 },
110];
111
treeos ext star codebook
Post comments from the CLI: treeos ext comment codebook "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...