1/**
2 * Contradiction Core
3 *
4 * Detects conflicting truths across tree branches.
5 * Writes contradiction records to both involved nodes.
6 * Fires cascade signals so the tree propagates awareness.
7 */
8
9import log from "../../seed/log.js";
10import Node from "../../seed/models/node.js";
11import Note from "../../seed/models/note.js";
12import { SYSTEM_ROLE, CONTENT_TYPE } from "../../seed/protocol.js";
13import { getContextForAi } from "../../seed/tree/treeFetch.js";
14import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
15import { v4 as uuidv4 } from "uuid";
16
17let _runChat = null;
18let _checkCascade = null;
19let _metadata = null;
20export function setServices(services) {
21 _runChat = services.runChat;
22 _checkCascade = services.checkCascade;
23 _metadata = services.metadata;
24}
25
26// ─────────────────────────────────────────────────────────────────────────
27// CONFIG
28// ─────────────────────────────────────────────────────────────────────────
29
30const DEFAULTS = {
31 maxContextChars: 8000,
32 maxContradictionsPerNode: 50,
33 cascadeOnDetection: true,
34 minNotesBetweenScans: 5,
35};
36
37export async function getContradictionConfig() {
38 const configNode = await Node.findOne({ systemRole: SYSTEM_ROLE.CONFIG }).select("metadata").lean();
39 if (!configNode) return { ...DEFAULTS };
40 const meta = configNode.metadata instanceof Map
41 ? configNode.metadata.get("contradiction") || {}
42 : configNode.metadata?.contradiction || {};
43 return { ...DEFAULTS, ...meta };
44}
45
46// ─────────────────────────────────────────────────────────────────────────
47// THROTTLE
48// ─────────────────────────────────────────────────────────────────────────
49
50/**
51 * Increment the note counter for a node. Returns the new count.
52 * Same pattern as codebook's compressionThreshold.
53 */
54export async function incrementNoteCount(nodeId) {
55 await _metadata.incExtMeta(nodeId, "contradiction", "notesSinceLastScan", 1);
56 const node = await Node.findById(nodeId).select("metadata").lean();
57 if (!node) return 0;
58 const meta = node.metadata instanceof Map
59 ? node.metadata.get("contradiction") || {}
60 : node.metadata?.contradiction || {};
61 return meta.notesSinceLastScan || 0;
62}
63
64/**
65 * Reset the note counter after a scan.
66 */
67export async function resetNoteCount(nodeId) {
68 await _metadata.batchSetExtMeta(nodeId, "contradiction", { notesSinceLastScan: 0 });
69}
70
71// ─────────────────────────────────────────────────────────────────────────
72// DETECTION
73// ─────────────────────────────────────────────────────────────────────────
74
75/**
76 * Check a new note against its node's context for contradictions.
77 * Returns array of contradiction objects or empty array.
78 */
79export async function detectContradictions(nodeId, noteContent, userId, username) {
80 if (!_runChat || !noteContent || noteContent.trim().length === 0) return [];
81
82 const config = await getContradictionConfig();
83
84 // Build context snapshot for this node
85 let contextSummary;
86 try {
87 const ctx = await getContextForAi(nodeId, {
88 includeNotes: true,
89 includeChildren: true,
90 includeParentChain: true,
91 userId,
92 });
93 contextSummary = JSON.stringify(ctx, null, 0);
94 if (contextSummary.length > config.maxContextChars) {
95 contextSummary = contextSummary.slice(0, config.maxContextChars);
96 }
97 } catch (err) {
98 log.debug("Contradiction", `Failed to build context for ${nodeId}: ${err.message}`);
99 return [];
100 }
101
102 // Find the root for runChat
103 let rootId = null;
104 try {
105 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
106 const root = await resolveRootNode(nodeId);
107 rootId = root?._id;
108 } catch (err) {
109 log.debug("Contradiction", "resolveRootNode failed:", err.message);
110 }
111
112 const prompt =
113 `You are a contradiction detector for a knowledge tree.\n\n` +
114 `EXISTING CONTEXT at this position:\n${contextSummary}\n\n` +
115 `NEW NOTE just written:\n${noteContent}\n\n` +
116 `Does this new note contradict anything in the existing context?\n` +
117 `Only report confirmed contradictions, not differences in scope or perspective.\n\n` +
118 `If contradictions found, return JSON array:\n` +
119 `[\n` +
120 ` {\n` +
121 ` "claim": "what the new note says",\n` +
122 ` "conflictsWith": "what it contradicts in the context",\n` +
123 ` "sourceNodeName": "name of the node containing the conflicting info (if identifiable)",\n` +
124 ` "severity": "factual" | "intentional" | "temporal",\n` +
125 ` "explanation": "brief explanation of the conflict"\n` +
126 ` }\n` +
127 `]\n\n` +
128 `Severity types:\n` +
129 `- factual: wrong data, cannot both be true\n` +
130 `- intentional: a deliberate change that has not been propagated to related nodes\n` +
131 `- temporal: something that was true before but is not now\n\n` +
132 `If no contradictions found, return an empty array: []`;
133
134 try {
135 const { answer } = await _runChat({
136 userId,
137 username: username || "system",
138 message: prompt,
139 mode: "tree:respond",
140 rootId,
141 nodeId,
142 slot: "contradiction",
143 });
144
145 if (!answer) return [];
146
147 const parsed = parseJsonSafe(answer);
148 if (!Array.isArray(parsed)) return [];
149
150 // Validate and clean
151 return parsed
152 .filter((c) => c && typeof c.claim === "string" && typeof c.conflictsWith === "string")
153 .map((c) => ({
154 id: uuidv4(),
155 claim: c.claim,
156 conflictsWith: c.conflictsWith,
157 sourceNodeName: c.sourceNodeName || null,
158 severity: ["factual", "intentional", "temporal"].includes(c.severity) ? c.severity : "factual",
159 explanation: c.explanation || null,
160 detectedAt: new Date().toISOString(),
161 nodeId,
162 status: "active",
163 }));
164 } catch (err) {
165 log.warn("Contradiction", `Detection failed at ${nodeId}: ${err.message}`);
166 return [];
167 }
168}
169
170// ─────────────────────────────────────────────────────────────────────────
171// RECORD WRITING
172// ─────────────────────────────────────────────────────────────────────────
173
174/**
175 * Write contradiction records to a node's metadata.contradictions.
176 * Caps at maxContradictionsPerNode, dropping oldest resolved first.
177 */
178export async function writeContradictions(nodeId, contradictions) {
179 if (!contradictions || contradictions.length === 0) return;
180
181 const config = await getContradictionConfig();
182 const node = await Node.findById(nodeId).select("metadata").lean();
183 if (!node) return;
184
185 const meta = node.metadata instanceof Map
186 ? Object.fromEntries(node.metadata)
187 : (node.metadata || {});
188
189 const existing = Array.isArray(meta.contradictions) ? meta.contradictions : [];
190
191 // Merge new contradictions
192 const all = [...existing, ...contradictions];
193
194 // Cap: drop oldest resolved first, then oldest active
195 if (all.length > config.maxContradictionsPerNode) {
196 const resolved = all.filter((c) => c.status === "resolved");
197 const active = all.filter((c) => c.status !== "resolved");
198 const trimmed = [
199 ...active,
200 ...resolved.slice(-(config.maxContradictionsPerNode - active.length)),
201 ].slice(-config.maxContradictionsPerNode);
202 await _metadata.setExtMeta(await Node.findById(nodeId), "contradictions", trimmed);
203 } else {
204 await _metadata.setExtMeta(await Node.findById(nodeId), "contradictions", all);
205 }
206}
207
208/**
209 * Fire cascade signal for detected contradictions so related nodes become aware.
210 */
211export async function cascadeContradictions(nodeId, contradictions) {
212 if (!_checkCascade || contradictions.length === 0) return;
213
214 const config = await getContradictionConfig();
215 if (!config.cascadeOnDetection) return;
216
217 try {
218 await _checkCascade(nodeId, {
219 action: "contradiction:detected",
220 tags: ["contradiction"],
221 contradictions: contradictions.map((c) => ({
222 claim: c.claim,
223 conflictsWith: c.conflictsWith,
224 severity: c.severity,
225 sourceNodeName: c.sourceNodeName,
226 })),
227 });
228 } catch (err) {
229 log.debug("Contradiction", `Cascade failed for contradictions at ${nodeId}: ${err.message}`);
230 }
231}
232
233// ─────────────────────────────────────────────────────────────────────────
234// RESOLUTION
235// ─────────────────────────────────────────────────────────────────────────
236
237/**
238 * Resolve a contradiction by ID. Marks it as resolved with a timestamp.
239 */
240export async function resolveContradiction(nodeId, contradictionId) {
241 const node = await Node.findById(nodeId).select("metadata").lean();
242 if (!node) throw new Error("Node not found");
243
244 const meta = node.metadata instanceof Map
245 ? Object.fromEntries(node.metadata)
246 : (node.metadata || {});
247
248 const contradictions = Array.isArray(meta.contradictions) ? meta.contradictions : [];
249 const entry = contradictions.find((c) => c.id === contradictionId);
250 if (!entry) throw new Error("Contradiction not found");
251
252 entry.status = "resolved";
253 entry.resolvedAt = new Date().toISOString();
254
255 await _metadata.setExtMeta(await Node.findById(nodeId), "contradictions", contradictions);
256
257 return entry;
258}
259
260// ─────────────────────────────────────────────────────────────────────────
261// READING
262// ─────────────────────────────────────────────────────────────────────────
263
264/**
265 * Get contradictions for a node.
266 */
267export async function getContradictions(nodeId) {
268 const node = await Node.findById(nodeId).select("metadata").lean();
269 if (!node) return [];
270 const meta = node.metadata instanceof Map
271 ? Object.fromEntries(node.metadata)
272 : (node.metadata || {});
273 return Array.isArray(meta.contradictions) ? meta.contradictions : [];
274}
275
276// ─────────────────────────────────────────────────────────────────────────
277// FULL SCAN
278// ─────────────────────────────────────────────────────────────────────────
279
280/**
281 * Scan all notes in a tree for contradictions.
282 * Processes each node with notes, checking each note against context.
283 * Returns total contradictions found.
284 */
285export async function scanTree(rootId, userId, username) {
286 const { getDescendantIds } = await import("../../seed/tree/treeFetch.js");
287 const nodeIds = await getDescendantIds(rootId);
288 let totalFound = 0;
289
290 for (const nodeId of nodeIds) {
291 const node = await Node.findById(nodeId).select("systemRole status").lean();
292 if (!node || node.systemRole) continue;
293 if (node.status === "trimmed") continue;
294
295 const notes = await Note.find({ nodeId, contentType: CONTENT_TYPE.TEXT })
296 .sort({ createdAt: -1 })
297 .limit(5)
298 .select("content")
299 .lean();
300
301 if (notes.length === 0) continue;
302
303 // Check the most recent note against context
304 const contradictions = await detectContradictions(nodeId, notes[0].content, userId, username);
305 if (contradictions.length > 0) {
306 await writeContradictions(nodeId, contradictions);
307 await cascadeContradictions(nodeId, contradictions);
308 totalFound += contradictions.length;
309 }
310 }
311
312 return { nodesScanned: nodeIds.length, contradictionsFound: totalFound };
313}
314
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setServices, detectContradictions, writeContradictions, cascadeContradictions, incrementNoteCount, resetNoteCount, getContradictionConfig } from "./core.js";
4
5export async function init(core) {
6 const { checkCascade } = await import("../../seed/tree/cascade.js");
7 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
8
9 core.llm.registerRootLlmSlot("contradiction");
10
11 setServices({
12 runChat: async (opts) => {
13 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
14 return core.llm.runChat({ ...opts, llmPriority: BG });
15 },
16 checkCascade,
17 metadata: core.metadata,
18 });
19
20 // ── afterNote: throttled contradiction scanning ─────────────────────
21 // Increments a counter on every text note. Only fires the AI detection
22 // when the count hits minNotesBetweenScans. Resets on scan. Ten notes
23 // in a minute don't trigger ten LLM calls. They trigger two (at default 5).
24 const config = await getContradictionConfig();
25
26 core.hooks.register("afterNote", async ({ note, nodeId, userId, contentType, action }) => {
27 if (contentType !== "text") return;
28 if (action !== "create" && action !== "edit") return;
29 if (!userId || userId === "SYSTEM") return;
30
31 // Skip system nodes
32 try {
33 const Node = core.models.Node;
34 const node = await Node.findById(nodeId).select("systemRole").lean();
35 if (node?.systemRole) return;
36 } catch (err) {
37 log.debug("Contradiction", "systemRole check failed:", err.message);
38 return;
39 }
40
41 // Throttle: increment counter, only scan when threshold reached
42 const count = await incrementNoteCount(nodeId);
43 if (count < config.minNotesBetweenScans) return;
44
45 // Reset counter before scanning (not after, so concurrent notes don't double-fire)
46 await resetNoteCount(nodeId);
47
48 // Look up username
49 let username = null;
50 try {
51 const user = await core.models.User.findById(userId).select("username").lean();
52 username = user?.username;
53 } catch (err) {
54 log.debug("Contradiction", "username lookup failed:", err.message);
55 }
56
57 // Detect in background so we don't block the note write response
58 detectContradictions(nodeId, note.content || "", userId, username)
59 .then(async (contradictions) => {
60 if (contradictions.length === 0) return;
61
62 log.verbose("Contradiction",
63 `Detected ${contradictions.length} contradiction(s) at node ${nodeId}`,
64 );
65
66 await writeContradictions(nodeId, contradictions);
67 await cascadeContradictions(nodeId, contradictions);
68 })
69 .catch((err) => {
70 log.debug("Contradiction", `Background detection failed: ${err.message}`);
71 });
72 }, "contradiction");
73
74 // ── enrichContext: surface active contradictions to the AI ──────────
75 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
76 const contradictions = meta.contradictions;
77 if (!Array.isArray(contradictions)) return;
78
79 const active = contradictions.filter((c) => c.status === "active");
80 if (active.length === 0) return;
81
82 context.contradictions = active.map((c) => ({
83 id: c.id,
84 claim: c.claim,
85 conflictsWith: c.conflictsWith,
86 severity: c.severity,
87 sourceNodeName: c.sourceNodeName,
88 detectedAt: c.detectedAt,
89 }));
90 }, "contradiction");
91
92 const { default: router } = await import("./routes.js");
93
94 return {
95 router,
96 tools,
97 exports: {
98 detectContradictions,
99 writeContradictions,
100 },
101 };
102}
103
1export default {
2 name: "contradiction",
3 version: "1.0.1",
4 builtFor: "treeos-intelligence",
5 description:
6 "The tree's immune system. Notes on different nodes might contradict each other. Both valid " +
7 "in isolation. Together a conflict the user has not noticed. Listens to afterNote. On every " +
8 "note write, reads the current node enrichContext snapshot including codebook dictionaries, " +
9 "perspective tags, and parent summaries. Sends the new note plus contextual summary to the AI: " +
10 "does this note contradict anything in the existing context? When a contradiction is found, " +
11 "writes to metadata.contradictions on both nodes. The entry contains what conflicts, which " +
12 "nodes, when detected, severity (factual vs intentional vs temporal). Factual is wrong data. " +
13 "Intentional is a deliberate change not propagated. Temporal is something that was true before " +
14 "but is not now. enrichContext injects active contradictions at every position. The AI sees " +
15 "them and can surface them. The cascade integration is what makes it architectural. When a " +
16 "contradiction is detected locally, the extension fires a cascade signal with the contradiction " +
17 "payload. Propagation carries it to related nodes. Perspective filters determine which nodes " +
18 "care. The tree becomes aware of its own inconsistencies across branches. The AI does not " +
19 "resolve contradictions. It surfaces them. The user decides. But the tree cannot hold " +
20 "conflicting truths silently anymore. The immune system detects infection. The operator treats it.",
21
22 needs: {
23 services: ["llm"],
24 models: ["Node"],
25 },
26
27 optional: {
28 extensions: ["propagation", "perspective-filter", "codebook"],
29 },
30
31 provides: {
32 models: {},
33 routes: "./routes.js",
34 tools: true,
35 jobs: false,
36 orchestrator: false,
37 energyActions: {},
38 sessionTypes: {},
39 env: [],
40
41 cli: [
42 {
43 command: "contradictions [action] [args...]", scope: ["tree"],
44 description: "Active conflicts at this position. Actions: resolve, scan.",
45 method: "GET",
46 endpoint: "/node/:nodeId/contradictions",
47 subcommands: {
48 "resolve": { method: "POST", endpoint: "/node/:nodeId/contradictions/resolve", args: ["id"], description: "Mark as intentionally resolved" },
49 "scan": { method: "POST", endpoint: "/root/:rootId/contradictions/scan", description: "Full tree scan" },
50 },
51 },
52 ],
53
54 hooks: {
55 fires: [],
56 listens: ["afterNote", "enrichContext"],
57 },
58 },
59};
60
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import User from "../../seed/models/user.js";
5import { getContradictions, resolveContradiction, scanTree } from "./core.js";
6
7const router = express.Router();
8
9// GET /node/:nodeId/contradictions
10router.get("/node/:nodeId/contradictions", authenticate, async (req, res) => {
11 try {
12 const all = await getContradictions(req.params.nodeId);
13 const active = all.filter((c) => c.status === "active");
14 const resolved = all.filter((c) => c.status === "resolved");
15 sendOk(res, { active: active.length, resolved: resolved.length, contradictions: active });
16 } catch (err) {
17 sendError(res, 500, ERR.INTERNAL, err.message);
18 }
19});
20
21// POST /node/:nodeId/contradictions/resolve
22router.post("/node/:nodeId/contradictions/resolve", authenticate, async (req, res) => {
23 try {
24 const { contradictionId } = req.body;
25 if (!contradictionId) return sendError(res, 400, ERR.INVALID_INPUT, "contradictionId required");
26 const entry = await resolveContradiction(req.params.nodeId, contradictionId);
27 sendOk(res, entry);
28 } catch (err) {
29 sendError(res, 500, ERR.INTERNAL, err.message);
30 }
31});
32
33// POST /root/:rootId/contradictions/scan
34router.post("/root/:rootId/contradictions/scan", authenticate, async (req, res) => {
35 try {
36 const user = await User.findById(req.userId).select("username").lean();
37 const result = await scanTree(req.params.rootId, req.userId, user?.username);
38 sendOk(res, result);
39 } catch (err) {
40 sendError(res, 500, ERR.INTERNAL, err.message);
41 }
42});
43
44export default router;
45
1import { z } from "zod";
2import log from "../../seed/log.js";
3import { getContradictions, resolveContradiction, scanTree, detectContradictions, writeContradictions, cascadeContradictions } from "./core.js";
4
5export default [
6 {
7 name: "node-contradictions",
8 description: "Show active contradictions at a node. Surfaces conflicts between this node's content and the broader tree.",
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: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
16 handler: async ({ nodeId }) => {
17 try {
18 const all = await getContradictions(nodeId);
19 const active = all.filter((c) => c.status === "active");
20 if (active.length === 0) {
21 return { content: [{ type: "text", text: "No active contradictions at this node." }] };
22 }
23 return { content: [{ type: "text", text: JSON.stringify({ active: active.length, contradictions: active }, null, 2) }] };
24 } catch (err) {
25 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
26 }
27 },
28 },
29 {
30 name: "resolve-contradiction",
31 description: "Mark a contradiction as intentionally resolved. The user has decided this conflict is acceptable or has been addressed.",
32 schema: {
33 nodeId: z.string().describe("The node with the contradiction."),
34 contradictionId: z.string().describe("The contradiction ID to resolve."),
35 userId: z.string().describe("Injected by server. Ignore."),
36 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
37 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
38 },
39 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
40 handler: async ({ nodeId, contradictionId }) => {
41 try {
42 const entry = await resolveContradiction(nodeId, contradictionId);
43 return { content: [{ type: "text", text: `Contradiction resolved: "${entry.claim}" vs "${entry.conflictsWith}"` }] };
44 } catch (err) {
45 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
46 }
47 },
48 },
49 {
50 name: "scan-contradictions",
51 description: "Force a full-tree contradiction scan. Checks the most recent note on every active node against its context. Token-intensive for large trees. Returns a cost estimate before scanning.",
52 schema: {
53 rootId: z.string().describe("The tree root to scan."),
54 confirm: z.boolean().optional().default(false).describe("Set to true to actually run the scan. Without it, returns only the cost estimate."),
55 userId: z.string().describe("Injected by server. Ignore."),
56 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
57 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
58 },
59 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
60 handler: async ({ rootId, confirm, userId }) => {
61 try {
62 // Estimate cost before scanning
63 const { getDescendantIds } = await import("../../seed/tree/treeFetch.js");
64 const nodeIds = await getDescendantIds(rootId);
65 const { getContradictionConfig } = await import("./core.js");
66 const config = await getContradictionConfig();
67 const estimatedCalls = nodeIds.length;
68 const estimatedTokens = estimatedCalls * Math.round(config.maxContextChars * 0.3); // rough: ~0.3 tokens per char
69
70 if (!confirm) {
71 return {
72 content: [{
73 type: "text",
74 text: JSON.stringify({
75 warning: "Full tree scan is token-intensive. Review the estimate and call again with confirm: true to proceed.",
76 nodes: nodeIds.length,
77 estimatedLLMCalls: estimatedCalls,
78 estimatedTokens,
79 estimatedContextPerCall: `~${config.maxContextChars} chars`,
80 }, null, 2),
81 }],
82 };
83 }
84
85 let username = null;
86 try {
87 const User = (await import("../../seed/models/user.js")).default;
88 const user = await User.findById(userId).select("username").lean();
89 username = user?.username;
90 } catch (err) {
91 // Non-critical: scan proceeds without username
92 log.debug("Contradiction", "username lookup failed:", err.message);
93 }
94 const result = await scanTree(rootId, userId, username);
95 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
96 } catch (err) {
97 return { content: [{ type: "text", text: `Scan failed: ${err.message}` }] };
98 }
99 },
100 },
101];
102
Loading comments...