EXTENSION for treeos-cascade
gap-detection
Makes the tree know what it does not know. When cascade delivers a signal to a node, the signal metadata may reference extension namespaces that the local land does not have installed. Solana data arriving at a land without the solana extension. Understanding summaries arriving at a land without the understanding extension. The data is in metadata and the Mixed map preserves it, but nothing can act on it. Gap detection listens to onCascade. After delivery, it inspects the signal metadata keys. It compares them against the loaded extensions. For every metadata namespace that does not match a loaded extension, it writes a gap record to the receiving node. metadata.gaps as an array of objects with the namespace, when it was detected, and how many times signals with that namespace have arrived. enrichContext injects gap information so the AI at that node can see it. The AI says: I have received 15 signals with solana metadata but the solana extension is not installed on this land. Would you like to install it? The tree recommends extensions based on what it is actually receiving, not based on a curated store. Real demand from real data flowing through the network.
v1.0.1 by TreeOS Site 0 downloads 5 files 378 lines 12.2 KB published 38d ago
treeos ext install gap-detection
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • models: Node
  • extensions: propagation

Optional

  • extensions: perspective-filter
SHA256: 459f626397eb54f02eb5519b0b9490a4a2208df1052a6fdcf793c18c796b8e43

Dependents

1 package depend on this

PackageTypeRelationship
treeos-cascade v1.0.1bundleincludes

CLI Commands

CommandMethodDescription
gapsGETExtension gaps. No action shows gaps at this node. Actions: clear, land.
gaps clearDELETEClear gap records after installing the missing extension
gaps landGETAll gaps across the entire land, aggregated from every node

Hooks

Listens To

  • onCascade
  • enrichContext

Source Code

1/**
2 * Gap Detection Core
3 *
4 * Compares signal metadata namespaces against loaded extensions.
5 * Writes gap records to the receiving node when unrecognized namespaces arrive.
6 */
7
8import Node from "../../seed/models/node.js";
9import { getLoadedExtensionNames } from "../loader.js";
10
11let _metadata = null;
12export function setMetadata(m) { _metadata = m; }
13
14// Namespaces that are kernel or core, not extensions. Never flagged as gaps.
15const KERNEL_NAMESPACES = new Set([
16  "cascade", "circuit", "extensions", "nav", "tools", "modes",
17  "memory", "codebook", "perspective", "gaps", "pulse",
18  "_sealed", "_passthrough", "tags",
19]);
20
21/**
22 * Extract extension namespace keys from a cascade payload.
23 *
24 * Only inspects explicit extension data carriers, not arbitrary top-level
25 * payload keys. Local cascade payloads are writeContext (action, contentType,
26 * etc.) which are not extension namespaces. Cross-land signals carry node
27 * metadata in payload.metadata or payload.extensionData.
28 */
29export function extractNamespaces(payload) {
30  if (!payload || typeof payload !== "object") return [];
31
32  const namespaces = new Set();
33
34  // Cross-land signals carry the source node's metadata map.
35  // Each key in this map is an extension namespace.
36  if (payload.metadata && typeof payload.metadata === "object") {
37    for (const key of Object.keys(payload.metadata)) {
38      if (key.startsWith("_")) continue;
39      if (KERNEL_NAMESPACES.has(key)) continue;
40      namespaces.add(key);
41    }
42  }
43
44  // Some relay patterns pack extension data under a dedicated key
45  if (payload.extensionData && typeof payload.extensionData === "object") {
46    for (const key of Object.keys(payload.extensionData)) {
47      if (key.startsWith("_")) continue;
48      if (KERNEL_NAMESPACES.has(key)) continue;
49      namespaces.add(key);
50    }
51  }
52
53  // Signals may declare their extension origin via tags
54  if (Array.isArray(payload.tags)) {
55    for (const tag of payload.tags) {
56      if (typeof tag === "string" && !KERNEL_NAMESPACES.has(tag)) {
57        namespaces.add(tag);
58      }
59    }
60  }
61
62  return [...namespaces];
63}
64
65/**
66 * Find namespaces in a signal that don't match any loaded extension.
67 */
68export function findGaps(namespaces) {
69  const loaded = new Set(getLoadedExtensionNames());
70  return namespaces.filter((ns) => !loaded.has(ns));
71}
72
73/**
74 * Write gap records to a node's metadata.gaps.
75 * Each gap: { namespace, firstSeen, lastSeen, count }.
76 * Increments count if the gap already exists. Adds new entry if not.
77 */
78export async function writeGaps(nodeId, gapNamespaces) {
79  if (!gapNamespaces || gapNamespaces.length === 0) return;
80
81  const node = await Node.findById(nodeId).select("metadata").lean();
82  if (!node) return;
83
84  const meta = node.metadata instanceof Map
85    ? Object.fromEntries(node.metadata)
86    : (node.metadata || {});
87
88  const existing = Array.isArray(meta.gaps) ? meta.gaps : [];
89  const gapMap = new Map(existing.map((g) => [g.namespace, g]));
90  const now = new Date().toISOString();
91
92  for (const ns of gapNamespaces) {
93    const entry = gapMap.get(ns);
94    if (entry) {
95      entry.lastSeen = now;
96      entry.count = (entry.count || 1) + 1;
97    } else {
98      gapMap.set(ns, { namespace: ns, firstSeen: now, lastSeen: now, count: 1 });
99    }
100  }
101
102  await _metadata.setExtMeta(await Node.findById(nodeId), "gaps", [...gapMap.values()]);
103}
104
105/**
106 * Get gap records for a node.
107 */
108export async function getGaps(nodeId) {
109  const node = await Node.findById(nodeId).select("metadata").lean();
110  if (!node) return [];
111
112  const meta = node.metadata instanceof Map
113    ? Object.fromEntries(node.metadata)
114    : (node.metadata || {});
115
116  return Array.isArray(meta.gaps) ? meta.gaps : [];
117}
118
119/**
120 * Clear gap records for a node (e.g. after installing the missing extension).
121 */
122export async function clearGaps(nodeId) {
123  await _metadata.unsetExtMeta(nodeId, "gaps");
124}
125
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setMetadata, extractNamespaces, findGaps, writeGaps, getGaps, clearGaps } from "./core.js";
4
5export async function init(core) {
6  setMetadata(core.metadata);
7
8  // After each cascade delivery, inspect the signal for extension namespaces
9  // that don't match any loaded extension. Write gap records to the receiving node.
10  core.hooks.register("onCascade", async (hookData) => {
11    const { nodeId, payload, depth } = hookData;
12
13    // Only check on actual deliveries (depth > 0), not the originating write
14    if (!depth || depth === 0) return;
15    if (!payload || typeof payload !== "object") return;
16
17    const namespaces = extractNamespaces(payload);
18    if (namespaces.length === 0) return;
19
20    const gaps = findGaps(namespaces);
21    if (gaps.length === 0) return;
22
23    try {
24      await writeGaps(nodeId, gaps);
25      log.debug("GapDetection", `Detected ${gaps.length} gap(s) at node ${nodeId}: ${gaps.join(", ")}`);
26    } catch (err) {
27      log.debug("GapDetection", `Failed to write gaps at ${nodeId}: ${err.message}`);
28    }
29  }, "gap-detection");
30
31  // Inject gap info into AI context so the AI can recommend missing extensions
32  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
33    const gaps = meta.gaps;
34    if (Array.isArray(gaps) && gaps.length > 0) {
35      context.extensionGaps = gaps.map((g) => ({
36        namespace: g.namespace,
37        count: g.count,
38        lastSeen: g.lastSeen,
39      }));
40    }
41  }, "gap-detection");
42
43  const { default: router } = await import("./routes.js");
44
45  return {
46    router,
47    tools,
48    exports: {
49      getGaps,
50      clearGaps,
51      extractNamespaces,
52      findGaps,
53    },
54  };
55}
56
1export default {
2  name: "gap-detection",
3  version: "1.0.1",
4  builtFor: "treeos-cascade",
5  description:
6    "Makes the tree know what it does not know. When cascade delivers a signal to a node, the " +
7    "signal metadata may reference extension namespaces that the local land does not have " +
8    "installed. Solana data arriving at a land without the solana extension. Understanding " +
9    "summaries arriving at a land without the understanding extension. The data is in metadata " +
10    "and the Mixed map preserves it, but nothing can act on it. Gap detection listens to " +
11    "onCascade. After delivery, it inspects the signal metadata keys. It compares them against " +
12    "the loaded extensions. For every metadata namespace that does not match a loaded extension, " +
13    "it writes a gap record to the receiving node. metadata.gaps as an array of objects with the " +
14    "namespace, when it was detected, and how many times signals with that namespace have arrived. " +
15    "enrichContext injects gap information so the AI at that node can see it. The AI says: I have " +
16    "received 15 signals with solana metadata but the solana extension is not installed on this " +
17    "land. Would you like to install it? The tree recommends extensions based on what it is " +
18    "actually receiving, not based on a curated store. Real demand from real data flowing through " +
19    "the network.",
20
21  needs: {
22    models: ["Node"],
23    extensions: ["propagation"],
24  },
25
26  optional: {
27    extensions: ["perspective-filter"],
28  },
29
30  provides: {
31    models: {},
32    routes: "./routes.js",
33    tools: true,
34    jobs: false,
35    orchestrator: false,
36    energyActions: {},
37    sessionTypes: {},
38    env: [],
39    cli: [
40      {
41        command: "gaps [action]", scope: ["tree"],
42        description: "Extension gaps. No action shows gaps at this node. Actions: clear, land.",
43        method: "GET",
44        endpoint: "/node/:nodeId/gaps",
45        subcommands: {
46          "clear": {
47            method: "DELETE",
48            endpoint: "/node/:nodeId/gaps",
49            description: "Clear gap records after installing the missing extension",
50          },
51          "land": {
52            method: "GET",
53            endpoint: "/gaps/land",
54            description: "All gaps across the entire land, aggregated from every node",
55          },
56        },
57      },
58    ],
59
60    hooks: {
61      fires: [],
62      listens: ["onCascade", "enrichContext"],
63    },
64  },
65};
66
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import Node from "../../seed/models/node.js";
5import { getGaps, clearGaps } from "./core.js";
6import { getDescendantIds } from "../../seed/tree/treeFetch.js";
7
8const router = express.Router();
9
10// GET /node/:nodeId/gaps - gaps at this node
11router.get("/node/:nodeId/gaps", authenticate, async (req, res) => {
12  try {
13    const gaps = await getGaps(req.params.nodeId);
14    sendOk(res, { count: gaps.length, gaps: gaps.sort((a, b) => b.count - a.count) });
15  } catch (err) {
16    sendError(res, 500, ERR.INTERNAL, err.message);
17  }
18});
19
20// DELETE /node/:nodeId/gaps - clear gaps
21router.delete("/node/:nodeId/gaps", authenticate, async (req, res) => {
22  try {
23    await clearGaps(req.params.nodeId);
24    sendOk(res, { message: "Gap records cleared" });
25  } catch (err) {
26    sendError(res, 500, ERR.INTERNAL, err.message);
27  }
28});
29
30// GET /gaps/land - all gaps aggregated across the land
31router.get("/gaps/land", authenticate, async (req, res) => {
32  try {
33    // Find all tree roots
34    const roots = await Node.find({ rootOwner: { $ne: null }, systemRole: null })
35      .select("_id").lean();
36
37    const aggregated = {};
38
39    for (const root of roots) {
40      const nodeIds = await getDescendantIds(root._id);
41      for (const nid of nodeIds) {
42        const gaps = await getGaps(nid);
43        for (const gap of gaps) {
44          if (!aggregated[gap.namespace]) {
45            aggregated[gap.namespace] = { namespace: gap.namespace, totalCount: 0, nodeCount: 0, lastSeen: gap.lastSeen };
46          }
47          aggregated[gap.namespace].totalCount += gap.count;
48          aggregated[gap.namespace].nodeCount++;
49          if (gap.lastSeen > aggregated[gap.namespace].lastSeen) {
50            aggregated[gap.namespace].lastSeen = gap.lastSeen;
51          }
52        }
53      }
54    }
55
56    const sorted = Object.values(aggregated).sort((a, b) => b.totalCount - a.totalCount);
57    sendOk(res, { count: sorted.length, gaps: sorted });
58  } catch (err) {
59    sendError(res, 500, ERR.INTERNAL, err.message);
60  }
61});
62
63export default router;
64
1import { z } from "zod";
2import { getGaps, clearGaps } from "./core.js";
3
4export default [
5  {
6    name: "node-gaps",
7    description:
8      "Show extension gaps detected at a node. Lists extension namespaces that appeared in cascade signals but are not installed on this land.",
9    schema: {
10      nodeId: z.string().describe("The node to check."),
11      userId: z.string().describe("Injected by server. Ignore."),
12      chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
13      sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
14    },
15    annotations: {
16      readOnlyHint: true,
17      destructiveHint: false,
18      idempotentHint: true,
19      openWorldHint: false,
20    },
21    handler: async ({ nodeId }) => {
22      try {
23        const gaps = await getGaps(nodeId);
24        if (gaps.length === 0) {
25          return { content: [{ type: "text", text: "No extension gaps detected at this node." }] };
26        }
27        return {
28          content: [{
29            type: "text",
30            text: JSON.stringify({
31              nodeId,
32              gapCount: gaps.length,
33              gaps: gaps.sort((a, b) => b.count - a.count),
34            }, null, 2),
35          }],
36        };
37      } catch (err) {
38        return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
39      }
40    },
41  },
42  {
43    name: "clear-node-gaps",
44    description: "Clear gap records for a node. Use after installing the missing extension.",
45    schema: {
46      nodeId: z.string().describe("The node to clear."),
47      userId: z.string().describe("Injected by server. Ignore."),
48      chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
49      sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
50    },
51    annotations: {
52      readOnlyHint: false,
53      destructiveHint: false,
54      idempotentHint: true,
55      openWorldHint: false,
56    },
57    handler: async ({ nodeId }) => {
58      try {
59        await clearGaps(nodeId);
60        return { content: [{ type: "text", text: `Gap records cleared on ${nodeId}.` }] };
61      } catch (err) {
62        return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
63      }
64    },
65  },
66];
67

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star gap-detection

Comments

Loading comments...

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