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
Loading comments...