1/**
2 * Long Memory Core
3 *
4 * Writes lightweight traces to node metadata on every cascade event.
5 * Each trace is small: sourceId, timestamp, status. The rolling array
6 * is capped so it never grows unbounded. MongoDB atomic update, one
7 * operation per cascade hop. Fast enough for the onCascade sequential chain.
8 */
9
10import Node from "../../seed/models/node.js";
11import { SYSTEM_ROLE } from "../../seed/protocol.js";
12
13let _metadata = null;
14export function setMetadata(m) { _metadata = m; }
15
16// ─────────────────────────────────────────────────────────────────────────
17// CONFIG (stored on .config metadata["long-memory"])
18// ─────────────────────────────────────────────────────────────────────────
19
20const DEFAULT_MAX_CONNECTIONS = 50;
21
22export async function getLongMemoryConfig() {
23 const configNode = await Node.findOne({ systemRole: SYSTEM_ROLE.CONFIG }).select("metadata").lean();
24 if (!configNode) return { maxConnections: DEFAULT_MAX_CONNECTIONS };
25
26 const meta = configNode.metadata instanceof Map
27 ? configNode.metadata.get("long-memory") || {}
28 : configNode.metadata?.["long-memory"] || {};
29
30 return {
31 maxConnections: meta.maxConnections ?? DEFAULT_MAX_CONNECTIONS,
32 };
33}
34
35// ─────────────────────────────────────────────────────────────────────────
36// TRACE WRITER
37// ─────────────────────────────────────────────────────────────────────────
38
39/**
40 * Write a cascade trace to a node's metadata.memory.
41 * Single atomic MongoDB update. Creates the structure if it doesn't exist.
42 *
43 * @param {string} nodeId - the node that received the signal
44 * @param {string} sourceId - the node that sent/originated the signal
45 * @param {string} status - cascade result status (succeeded, failed, etc.)
46 * @param {number} maxConnections - cap for the rolling connections array
47 */
48export async function writeTrace(nodeId, sourceId, status, maxConnections) {
49 const timestamp = new Date().toISOString();
50
51 await Node.findByIdAndUpdate(nodeId, {
52 $set: {
53 "metadata.memory.lastSeen": timestamp,
54 "metadata.memory.lastStatus": status,
55 "metadata.memory.lastSourceId": sourceId,
56 },
57 $inc: {
58 "metadata.memory.totalInteractions": 1,
59 },
60 $push: {
61 "metadata.memory.connections": {
62 $each: [{ sourceId, timestamp, status }],
63 $slice: -(maxConnections || DEFAULT_MAX_CONNECTIONS),
64 },
65 },
66 });
67}
68
69// ─────────────────────────────────────────────────────────────────────────
70// READER
71// ─────────────────────────────────────────────────────────────────────────
72
73/**
74 * Get the memory trace for a node.
75 *
76 * @param {string} nodeId
77 * @returns {object|null} memory trace or null
78 */
79export async function getMemory(nodeId) {
80 const node = await Node.findById(nodeId).select("metadata").lean();
81 if (!node) return null;
82
83 const meta = node.metadata instanceof Map
84 ? node.metadata.get("memory") || null
85 : node.metadata?.memory || null;
86
87 return meta;
88}
89
90/**
91 * Clear the memory trace for a node.
92 */
93export async function clearMemory(nodeId) {
94 await _metadata.unsetExtMeta(nodeId, "memory");
95}
96
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setMetadata, writeTrace, getMemory, getLongMemoryConfig } from "./core.js";
4
5export async function init(core) {
6 setMetadata(core.metadata);
7 const config = await getLongMemoryConfig();
8
9 // Listen to every cascade event and write a trace to the receiving node.
10 // onCascade fires at each node that receives a signal. hookData contains
11 // nodeId (target), source (sender), signalId, depth.
12 core.hooks.register("onCascade", async (hookData) => {
13 const { nodeId, source, signalId } = hookData;
14 if (!nodeId || !source) return;
15
16 // Don't trace self-origination (depth 0 is the node that wrote the content)
17 if (hookData.depth === 0) return;
18
19 const status = hookData._resultStatus || "succeeded";
20
21 try {
22 await writeTrace(nodeId, source, status, config.maxConnections);
23 } catch (err) {
24 log.debug("LongMemory", `Trace write failed at ${nodeId}: ${err.message}`);
25 }
26 }, "long-memory");
27
28 // Inject memory into AI context so the AI knows this node's relationship history
29 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
30 const memory = meta.memory;
31 if (memory && memory.totalInteractions > 0) {
32 context.memory = {
33 lastSeen: memory.lastSeen,
34 lastStatus: memory.lastStatus,
35 totalInteractions: memory.totalInteractions,
36 recentSources: (memory.connections || []).slice(-5).map((c) => c.sourceId),
37 };
38 }
39 }, "long-memory");
40
41 const { default: router } = await import("./routes.js");
42
43 return {
44 router,
45 tools,
46 exports: {
47 getMemory,
48 writeTrace,
49 },
50 };
51}
52
1export default {
2 name: "long-memory",
3 version: "1.0.1",
4 builtFor: "treeos-cascade",
5 description:
6 "The difference between a tree that forgets and a tree that remembers. .flow has resultTTL. " +
7 "Active results expire. That is correct for working memory. But some information should survive " +
8 "longer. Which nodes have talked to each other. When the last signal arrived. What the last " +
9 "status was. The ghost of a connection. For each cascade result, writes a lightweight trace to " +
10 "the involved nodes. metadata.memory.lastSeen with a timestamp. metadata.memory.lastStatus with " +
11 "the status. metadata.memory.connections as a small rolling array of the last N interactions " +
12 "with source IDs and timestamps. This is not the full payload. This is not the codebook. This " +
13 "is just the trace. This node heard from that node three months ago. The last exchange succeeded. " +
14 "They have talked 47 times total. Enough for the AI through enrichContext to know the " +
15 "relationship exists and has a history. Enough for a signal arriving after years of silence to " +
16 "land on a node that remembers something was here once. The traces live in metadata so they " +
17 "survive transit, never expire unless deliberately deleted, and persist through land restarts, " +
18 "extension reinstalls, and schema migrations.",
19
20 needs: {
21 models: ["Node"],
22 extensions: ["propagation"],
23 },
24
25 optional: {},
26
27 provides: {
28 models: {},
29 routes: "./routes.js",
30 tools: true,
31 jobs: false,
32 orchestrator: false,
33 energyActions: {},
34 sessionTypes: {},
35 env: [],
36 cli: [
37 {
38 command: "memory [action]", scope: ["tree"],
39 description: "Cascade memory. No action shows trace at this node. Actions: clear, connections.",
40 method: "GET",
41 endpoint: "/node/:nodeId/memory",
42 subcommands: {
43 "clear": {
44 method: "DELETE",
45 endpoint: "/node/:nodeId/memory",
46 description: "Wipe the trace. The node forgets its cascade history.",
47 },
48 "connections": {
49 method: "GET",
50 endpoint: "/node/:nodeId/memory/connections",
51 description: "Full connection list with counts and timestamps",
52 },
53 },
54 },
55 ],
56
57 hooks: {
58 fires: [],
59 listens: ["onCascade", "enrichContext"],
60 },
61 },
62};
63
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getMemory, clearMemory } from "./core.js";
5
6const router = express.Router();
7
8// GET /node/:nodeId/memory - trace summary
9router.get("/node/:nodeId/memory", authenticate, async (req, res) => {
10 try {
11 const memory = await getMemory(req.params.nodeId);
12 if (!memory) return sendOk(res, { message: "No cascade memory on this node." });
13 sendOk(res, {
14 lastSeen: memory.lastSeen,
15 lastStatus: memory.lastStatus,
16 lastSourceId: memory.lastSourceId,
17 totalInteractions: memory.totalInteractions || 0,
18 recentConnections: (memory.connections || []).length,
19 });
20 } catch (err) {
21 sendError(res, 500, ERR.INTERNAL, err.message);
22 }
23});
24
25// GET /node/:nodeId/memory/connections - full connection list
26router.get("/node/:nodeId/memory/connections", authenticate, async (req, res) => {
27 try {
28 const memory = await getMemory(req.params.nodeId);
29 if (!memory) return sendOk(res, { connections: [] });
30 sendOk(res, { connections: memory.connections || [] });
31 } catch (err) {
32 sendError(res, 500, ERR.INTERNAL, err.message);
33 }
34});
35
36// DELETE /node/:nodeId/memory - clear trace
37router.delete("/node/:nodeId/memory", authenticate, async (req, res) => {
38 try {
39 await clearMemory(req.params.nodeId);
40 sendOk(res, { message: "Memory cleared" });
41 } catch (err) {
42 sendError(res, 500, ERR.INTERNAL, err.message);
43 }
44});
45
46export default router;
47
1import { z } from "zod";
2import { getMemory, clearMemory } from "./core.js";
3
4export default [
5 {
6 name: "node-memory",
7 description:
8 "Get the long-term memory trace for a node. Shows when it last heard from another node, how many interactions total, and the rolling connection history.",
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 memory = await getMemory(nodeId);
24 if (!memory) {
25 return { content: [{ type: "text", text: "No memory traces on this node. It has not received any cascade signals." }] };
26 }
27 return {
28 content: [{
29 type: "text",
30 text: JSON.stringify({
31 lastSeen: memory.lastSeen,
32 lastStatus: memory.lastStatus,
33 lastSourceId: memory.lastSourceId,
34 totalInteractions: memory.totalInteractions || 0,
35 recentConnections: (memory.connections || []).length,
36 connections: memory.connections || [],
37 }, null, 2),
38 }],
39 };
40 } catch (err) {
41 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
42 }
43 },
44 },
45 {
46 name: "clear-node-memory",
47 description: "Clear the long-term memory trace for a node. The node forgets all cascade history.",
48 schema: {
49 nodeId: z.string().describe("The node to clear."),
50 userId: z.string().describe("Injected by server. Ignore."),
51 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
52 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
53 },
54 annotations: {
55 readOnlyHint: false,
56 destructiveHint: true,
57 idempotentHint: true,
58 openWorldHint: false,
59 },
60 handler: async ({ nodeId }) => {
61 try {
62 await clearMemory(nodeId);
63 return { content: [{ type: "text", text: `Memory cleared on ${nodeId}.` }] };
64 } catch (err) {
65 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
66 }
67 },
68 },
69];
70
Loading comments...