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