EXTENSION for treeos-cascade
codebook
What makes two nodes faster together over time. Listens to afterNote on any node. Tracks conversation patterns between specific users and specific nodes. When patterns recur, when the same concepts are referenced repeatedly, when shorthand emerges, the extension periodically runs a compression pass. The compression pass is an AI call using runChat. The prompt says: here are the last N conversations at this node with this user, extract recurring concepts, shorthand, compressed references, and produce a dictionary. The dictionary writes to metadata.codebook on the node, namespaced per user. enrichContext injects the codebook into the AI system prompt when that user is active at that node. The AI picks up the compressed language without needing the full conversation history. The codebook is the relationship memory. Dense. Compact. Earned through repeated interaction. The dependency on long memory is soft but important. Long memory tells the codebook extension which relationships are active and how frequently nodes interact. Without long memory, codebook would have to track all of that itself. With long memory, it reads metadata.memory and knows which node-user pairs have enough history to justify a compression pass. The compression runs after a threshold of new conversations, not after every note. Most notes do not trigger compression. Only when enough new material has accumulated does the extension spend tokens to update the codebook.
v1.0.2 by TreeOS Site 0 downloads 7 files 847 lines 29.0 KB published 38d ago
treeos ext install codebook
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • services: llm
  • models: Node

Optional

  • extensions: long-memory
SHA256: efc010efca8f3ddb28236fc4200c8e3b5e8cbd29db86abc1908f762fddb84f98

Dependents

1 package depend on this

PackageTypeRelationship
treeos-cascade v1.0.1bundleincludes

CLI Commands

CommandMethodDescription
codebookGETCodebook dictionary. No action shows your dictionary. Actions: compress, clear, stats.
codebook compressPOSTForce a compression pass now
codebook clearDELETEWipe the dictionary. Start fresh.
codebook statsGETNotes since last compression, dictionary size, timestamps

Hooks

Listens To

  • afterNote
  • enrichContext

Source Code

1/**
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}
268
1import 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}
75
1import 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}
102
1export 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};
74
1import { 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}
154
1import 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;
63
1import { 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

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 codebook

Comments

Loading comments...

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