EXTENSION for treeos-cascade
perspective-filter
What keeps cascade from being noise. Without it, every signal hits every node. With it, each node and each tree declares what it wants to receive. Stores configuration in metadata.perspective on any node. The simplest version is a list of accepted and rejected topic tags. A music tree sets perspective to accept signals tagged with music and creativity and reject fitness and finance. Propagation calls into perspective filter before delivering at each hop. Exports shouldDeliver(node, signal) that propagation imports through the extension dependency system. Propagation works without perspective. Perspective is useless without propagation. Perspective filters inherit down the tree unless overridden. If the root of a tree sets a perspective, every node below inherits it unless that node sets its own. This uses the same pattern as extension scoping, walking the parent chain and taking the closest override. The AI can modify perspective filters through tools. Set a branch to deep focus, mute everything except understanding signals. The AI writes to metadata.perspective on that node. Done. The filter is live.
v1.0.1 by TreeOS Site 0 downloads 5 files 442 lines 15.2 KB published 38d ago
treeos ext install perspective-filter
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • models: Node
  • extensions: propagation
SHA256: aa0389874404e86aacdb253b98106a64abd3ab025799ac5ed269a81d7812dfba

Dependents

1 package depend on this

PackageTypeRelationship
treeos-cascade v1.0.1bundleincludes

CLI Commands

CommandMethodDescription
perspectiveGETPerspective filter. No action shows effective filter. Actions: set, clear, test.
perspective setPOSTSet accept/reject lists. Pass accept and reject arrays in body.
perspective clearDELETERemove override, fall back to parent perspective
perspective testPOSTDry run: does this signal pass the filter here?

Hooks

Listens To

  • enrichContext

Source Code

1/**
2 * Perspective Filter Core
3 *
4 * Each node or tree declares what cascade signals it wants to receive
5 * via metadata.perspective. Signals carry tags in payload.tags[].
6 * The perspective filter checks those tags against accept/reject lists.
7 *
8 * Inheritance: walk up the parent chain. The closest node with a
9 * perspective config wins. If root sets accept: ["music", "creativity"],
10 * every node below inherits it unless overridden.
11 *
12 * metadata.perspective shape:
13 * {
14 *   accept: ["music", "creativity"],   // only accept signals tagged with these
15 *   reject: ["fitness", "finance"],     // reject signals tagged with these
16 * }
17 *
18 * Rules:
19 * - No perspective anywhere in the chain: accept everything
20 * - Signal has no tags: accept (untagged signals pass through)
21 * - reject list checked first: any matching tag rejects the signal
22 * - accept list checked second: if set, at least one tag must match
23 */
24
25import Node from "../../seed/models/node.js";
26
27/**
28 * Resolve the effective perspective for a node.
29 * Walks up the parent chain. Closest override wins.
30 *
31 * @param {object} node - node document (lean, with metadata and parent)
32 * @returns {object|null} - perspective config or null if none set
33 */
34export async function resolvePerspective(node) {
35  const meta = node.metadata instanceof Map
36    ? Object.fromEntries(node.metadata)
37    : (node.metadata || {});
38
39  if (meta.perspective && hasPerspectiveRules(meta.perspective)) {
40    return meta.perspective;
41  }
42
43  // Walk up parent chain
44  let cursor = node.parent;
45  let depth = 0;
46  const maxDepth = 50;
47
48  while (cursor && depth < maxDepth) {
49    const parent = await Node.findById(cursor)
50      .select("metadata parent systemRole")
51      .lean();
52    if (!parent || parent.systemRole) break;
53
54    const parentMeta = parent.metadata instanceof Map
55      ? Object.fromEntries(parent.metadata)
56      : (parent.metadata || {});
57
58    if (parentMeta.perspective && hasPerspectiveRules(parentMeta.perspective)) {
59      return parentMeta.perspective;
60    }
61
62    cursor = parent.parent;
63    depth++;
64  }
65
66  return null;
67}
68
69/**
70 * Check whether a perspective config has actual rules.
71 */
72function hasPerspectiveRules(perspective) {
73  if (!perspective || typeof perspective !== "object") return false;
74  const hasAccept = Array.isArray(perspective.accept) && perspective.accept.length > 0;
75  const hasReject = Array.isArray(perspective.reject) && perspective.reject.length > 0;
76  return hasAccept || hasReject;
77}
78
79/**
80 * Determine whether a cascade signal should be delivered to a node.
81 * Called by propagation before each hop.
82 *
83 * @param {object} node - target node document (lean, with metadata and parent)
84 * @param {object} payload - the cascade signal payload
85 * @returns {boolean} true if the signal should be delivered
86 */
87export async function shouldDeliver(node, payload) {
88  const perspective = await resolvePerspective(node);
89
90  // No perspective set anywhere in the chain: accept everything
91  if (!perspective) return true;
92
93  // No tags on the signal: accept (untagged signals always pass)
94  const tags = payload?.tags;
95  if (!Array.isArray(tags) || tags.length === 0) return true;
96
97  // Reject list: if any tag matches, reject
98  if (Array.isArray(perspective.reject) && perspective.reject.length > 0) {
99    for (const tag of tags) {
100      if (perspective.reject.includes(tag)) return false;
101    }
102  }
103
104  // Accept list: if set, at least one tag must match
105  if (Array.isArray(perspective.accept) && perspective.accept.length > 0) {
106    const hasMatch = tags.some((tag) => perspective.accept.includes(tag));
107    if (!hasMatch) return false;
108  }
109
110  return true;
111}
112
113/**
114 * Set the perspective filter on a node.
115 *
116 * @param {string} nodeId
117 * @param {object} perspective - { accept?: string[], reject?: string[] }
118 */
119let _metadata = null;
120export function setMetadata(metadata) { _metadata = metadata; }
121
122export async function setPerspective(nodeId, perspective) {
123  const node = await Node.findById(nodeId);
124  if (!node) throw new Error("Node not found");
125
126  const clean = {};
127  if (Array.isArray(perspective.accept) && perspective.accept.length > 0) {
128    clean.accept = perspective.accept.map(String);
129  }
130  if (Array.isArray(perspective.reject) && perspective.reject.length > 0) {
131    clean.reject = perspective.reject.map(String);
132  }
133
134  await _metadata.setExtMeta(node, "perspective", clean);
135  return clean;
136}
137
138/**
139 * Clear the perspective filter on a node (inherit from parent again).
140 */
141export async function clearPerspective(nodeId) {
142  const node = await Node.findById(nodeId);
143  if (!node) throw new Error("Node not found");
144
145  await _metadata.setExtMeta(node, "perspective", {});
146}
147
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { shouldDeliver, resolvePerspective, setPerspective, clearPerspective, setMetadata } from "./core.js";
4
5export async function init(core) {
6  setMetadata(core.metadata);
7  // Inject perspective info into AI context so the AI knows what this node cares about
8  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
9    const perspective = meta.perspective || meta["perspective-filter"];
10    if (perspective && (perspective.accept?.length || perspective.reject?.length)) {
11      context.perspective = perspective;
12    }
13  }, "perspective-filter");
14
15  const { default: router } = await import("./routes.js");
16
17  return {
18    router,
19    tools,
20    exports: {
21      shouldDeliver,
22      resolvePerspective,
23      setPerspective,
24      clearPerspective,
25    },
26  };
27}
28
1export default {
2  name: "perspective-filter",
3  version: "1.0.1",
4  builtFor: "treeos-cascade",
5  description:
6    "What keeps cascade from being noise. Without it, every signal hits every node. With it, each " +
7    "node and each tree declares what it wants to receive. Stores configuration in metadata.perspective " +
8    "on any node. The simplest version is a list of accepted and rejected topic tags. A music tree " +
9    "sets perspective to accept signals tagged with music and creativity and reject fitness and finance. " +
10    "Propagation calls into perspective filter before delivering at each hop. Exports shouldDeliver(node, " +
11    "signal) that propagation imports through the extension dependency system. Propagation works without " +
12    "perspective. Perspective is useless without propagation. Perspective filters inherit down the tree " +
13    "unless overridden. If the root of a tree sets a perspective, every node below inherits it unless " +
14    "that node sets its own. This uses the same pattern as extension scoping, walking the parent chain " +
15    "and taking the closest override. The AI can modify perspective filters through tools. Set a branch " +
16    "to deep focus, mute everything except understanding signals. The AI writes to metadata.perspective " +
17    "on that node. Done. The filter is live.",
18
19  needs: {
20    models: ["Node"],
21    extensions: ["propagation"],
22  },
23
24  optional: {},
25
26  provides: {
27    models: {},
28    routes: "./routes.js",
29    tools: true,
30    jobs: false,
31    orchestrator: false,
32    energyActions: {},
33    sessionTypes: {},
34    env: [],
35
36    cli: [
37      {
38        command: "perspective [action] [args...]", scope: ["tree"],
39        description: "Perspective filter. No action shows effective filter. Actions: set, clear, test.",
40        method: "GET",
41        endpoint: "/node/:nodeId/perspective",
42        subcommands: {
43          "set": {
44            method: "POST",
45            endpoint: "/node/:nodeId/perspective",
46            description: "Set accept/reject lists. Pass accept and reject arrays in body.",
47          },
48          "clear": {
49            method: "DELETE",
50            endpoint: "/node/:nodeId/perspective",
51            description: "Remove override, fall back to parent perspective",
52          },
53          "test": {
54            method: "POST",
55            endpoint: "/node/:nodeId/perspective/test",
56            args: ["signal"],
57            description: "Dry run: does this signal pass the filter here?",
58          },
59        },
60      },
61    ],
62
63    hooks: {
64      fires: [],
65      listens: ["enrichContext"],
66    },
67  },
68};
69
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 { resolvePerspective, setPerspective, clearPerspective, shouldDeliver } from "./core.js";
6
7const router = express.Router();
8
9// GET /node/:nodeId/perspective - effective perspective for a node (CLI endpoint)
10router.get("/node/:nodeId/perspective", authenticate, async (req, res) => {
11  try {
12    const node = await Node.findById(req.params.nodeId)
13      .select("name metadata parent systemRole")
14      .lean();
15    if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
16
17    const perspective = await resolvePerspective(node);
18
19    const meta = node.metadata instanceof Map
20      ? Object.fromEntries(node.metadata)
21      : (node.metadata || {});
22    const hasOwn = !!(meta.perspective?.accept?.length || meta.perspective?.reject?.length);
23
24    sendOk(res, {
25      nodeId: req.params.nodeId,
26      nodeName: node.name,
27      hasOwnPerspective: hasOwn,
28      effectivePerspective: perspective || { accept: [], reject: [] },
29      inherited: !hasOwn && perspective !== null,
30    });
31  } catch (err) {
32    sendError(res, 500, ERR.INTERNAL, err.message);
33  }
34});
35
36// POST /node/:nodeId/perspective - set accept/reject
37router.post("/node/:nodeId/perspective", authenticate, async (req, res) => {
38  try {
39    const { accept, reject } = req.body;
40    if (!accept?.length && !reject?.length) {
41      return sendError(res, 400, ERR.INVALID_INPUT, "Provide at least one of accept or reject arrays");
42    }
43    const result = await setPerspective(req.params.nodeId, { accept, reject });
44    sendOk(res, { message: "Perspective set", perspective: result });
45  } catch (err) {
46    sendError(res, 400, ERR.INVALID_INPUT, err.message);
47  }
48});
49
50// DELETE /node/:nodeId/perspective - clear override
51router.delete("/node/:nodeId/perspective", authenticate, async (req, res) => {
52  try {
53    await clearPerspective(req.params.nodeId);
54    sendOk(res, { message: "Perspective cleared. Inheriting from parent." });
55  } catch (err) {
56    sendError(res, 500, ERR.INTERNAL, err.message);
57  }
58});
59
60// POST /node/:nodeId/perspective/test - dry run
61router.post("/node/:nodeId/perspective/test", authenticate, async (req, res) => {
62  try {
63    const { signal } = req.body;
64    const tags = typeof signal === "string" ? signal.split(":") : (Array.isArray(signal) ? signal : []);
65    const node = await Node.findById(req.params.nodeId).select("metadata parent systemRole").lean();
66    if (!node) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
67    const passes = await shouldDeliver(node, { tags });
68    const perspective = await resolvePerspective(node);
69    sendOk(res, { tags, passes, effectivePerspective: perspective || { accept: [], reject: [] } });
70  } catch (err) {
71    sendError(res, 500, ERR.INTERNAL, err.message);
72  }
73});
74
75export default router;
76
1import { z } from "zod";
2import { resolvePerspective, setPerspective, clearPerspective } from "./core.js";
3import Node from "../../seed/models/node.js";
4
5export default [
6  {
7    name: "get-perspective",
8    description:
9      "Get the effective perspective filter for a node. Shows what cascade signals this node accepts, including rules inherited from parent nodes.",
10    schema: {
11      nodeId: z.string().describe("The node to check."),
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 }) => {
23      try {
24        const node = await Node.findById(nodeId).select("name metadata parent systemRole").lean();
25        if (!node) {
26          return { content: [{ type: "text", text: "Node not found." }] };
27        }
28
29        const perspective = await resolvePerspective(node);
30
31        const meta = node.metadata instanceof Map
32          ? Object.fromEntries(node.metadata)
33          : (node.metadata || {});
34        const hasOwn = !!(meta.perspective?.accept?.length || meta.perspective?.reject?.length);
35
36        return {
37          content: [{
38            type: "text",
39            text: JSON.stringify({
40              nodeId,
41              nodeName: node.name,
42              hasOwnPerspective: hasOwn,
43              effectivePerspective: perspective || { accept: [], reject: [] },
44              inherited: !hasOwn && perspective !== null,
45            }, null, 2),
46          }],
47        };
48      } catch (err) {
49        return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
50      }
51    },
52  },
53  {
54    name: "set-perspective",
55    description:
56      "Set the perspective filter on a node. Controls which cascade signals are accepted. Overrides any inherited perspective. Pass accept and/or reject arrays of topic tags.",
57    schema: {
58      nodeId: z.string().describe("The node to configure."),
59      accept: z.array(z.string()).optional().describe("Accept signals tagged with these topics. If set, only matching signals pass."),
60      reject: z.array(z.string()).optional().describe("Reject signals tagged with these topics. Checked before accept list."),
61      userId: z.string().describe("Injected by server. Ignore."),
62      chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
63      sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
64    },
65    annotations: {
66      readOnlyHint: false,
67      destructiveHint: false,
68      idempotentHint: true,
69      openWorldHint: false,
70    },
71    handler: async ({ nodeId, accept, reject }) => {
72      try {
73        if (!accept?.length && !reject?.length) {
74          return { content: [{ type: "text", text: "Provide at least one of accept or reject arrays." }] };
75        }
76
77        const result = await setPerspective(nodeId, { accept, reject });
78
79        return {
80          content: [{
81            type: "text",
82            text: JSON.stringify({
83              message: "Perspective set",
84              nodeId,
85              perspective: result,
86            }, null, 2),
87          }],
88        };
89      } catch (err) {
90        return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
91      }
92    },
93  },
94  {
95    name: "clear-perspective",
96    description:
97      "Clear the perspective filter on a node so it inherits from its parent again.",
98    schema: {
99      nodeId: z.string().describe("The node to clear."),
100      userId: z.string().describe("Injected by server. Ignore."),
101      chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
102      sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
103    },
104    annotations: {
105      readOnlyHint: false,
106      destructiveHint: false,
107      idempotentHint: true,
108      openWorldHint: false,
109    },
110    handler: async ({ nodeId }) => {
111      try {
112        await clearPerspective(nodeId);
113        return {
114          content: [{ type: "text", text: `Perspective cleared on ${nodeId}. Node now inherits from parent.` }],
115        };
116      } catch (err) {
117        return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
118      }
119    },
120  },
121];
122

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 perspective-filter

Comments

Loading comments...

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