@knowall-ai/reverie 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -523,6 +523,81 @@ The inspector provides a web UI to:
523
523
  - Validate your Neo4j connection
524
524
  - Debug tool parameters and responses
525
525
 
526
+ ## HTTP mode (`reverie serve`)
527
+
528
+ `reverie` on its own is the stdio MCP server. `reverie serve` runs the same server over
529
+ [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http)
530
+ and adds a read-only **Brain API** that the [KnowAll Agents Portal](https://github.com/knowall-ai/agents-portal)
531
+ Brain tab uses to draw the graph live. One bearer token protects both; only the health check is open.
532
+
533
+ ```bash
534
+ REVERIE_SERVE_TOKEN=$(openssl rand -hex 24) NEO4J_PASSWORD=… reverie serve
535
+ # Reverie HTTP server listening on http://127.0.0.1:8643
536
+ ```
537
+
538
+ | Variable | Default | Purpose |
539
+ |---|---|---|
540
+ | `REVERIE_SERVE_TOKEN` | (required) | Bearer token clients must send as `Authorization: Bearer …` |
541
+ | `REVERIE_HTTP_HOST` / `REVERIE_HTTP_PORT` | `127.0.0.1` / `8643` | Bind address; keep it on loopback behind a reverse proxy |
542
+ | `REVERIE_ALLOWED_ORIGINS` | none | Comma-separated browser origins allowed to call the API (they get CORS headers and preflight answers); requests carrying any other `Origin` header get 403. Server-to-server clients send no Origin and need nothing here |
543
+ | `REVERIE_EVENTS_PATH` | `~/.reverie/events.jsonl` | Activation log (see below); empty string disables it |
544
+ | `REVERIE_DREAMS_DIR` | unset | Optional dream diary folder (`*.md`); the newest file names the last dream |
545
+ | `REVERIE_USAGE_STATS_PATH` / `REVERIE_BOOST_STATE_PATH` | `~/call-transcripts/usage-stats.json` / `boost-state.json` | Presence HUD files passed through in `state` (usage only when written within 15 min) |
546
+ | `REVERIE_GRAPH_POLL_SECONDS` / `REVERIE_STATE_SECONDS` / `REVERIE_EVENT_POLL_SECONDS` | `5` / `5` / `1` | How often the event stream re-reads the graph, host state and activation log |
547
+
548
+ `NEO4J_*` and `REVERIE_EMBEDDINGS` work exactly as in stdio mode.
549
+
550
+ | Endpoint | Auth | Returns |
551
+ |---|---|---|
552
+ | `GET /health` (alias `/brain/health`) | none | `{ ok, neo4j, ts }` |
553
+ | `POST /mcp` | bearer | MCP Streamable HTTP (stateless; JSON responses). Any MCP client that speaks Streamable HTTP can use it |
554
+ | `GET /brain/graph?limit=400` | bearer | The most connected / most recent nodes (`limit` ≤ 1500), the relationships among them, totals per label and relationship type, and `state` |
555
+ | `GET /brain/state` | bearer | `dreaming`, `lastActivityAt`, `lastDreamAt`, recent read/write counts, host CPU / load / memory, Presence `usage` and `boost` |
556
+ | `GET /brain/events?limit=400` | bearer | Server-Sent Events: the last 30 `activation` events, then `state`; afterwards every new `activation` as it happens, a `graph` diff (`nodesAdded`, `nodesUpdated`, `nodesRemoved`, `relsAdded`, `relsRemoved`, `stats`) whenever the graph changed, `state` every few seconds and `error` if a poll fails |
557
+
558
+ Node ids in the Brain API are the same numeric ids the tools return, as strings, so activation
559
+ events and snapshot nodes line up. Archived memories are never shown. Embedding vectors never leave
560
+ the server.
561
+
562
+ **Activation log.** Every `search_memories` (`recall`), `create_memory` / `update_memory`
563
+ (`remember`), `create_connection` / `update_connection` (`connect`), `delete_*` (`forget`) and real
564
+ `dream` run (`dream.start` / `dream.end`) appends one JSON line to `REVERIE_EVENTS_PATH`, whichever
565
+ transport served it. It holds ids, names and search terms only, is private to the user (mode 0600), is capped at
566
+ 5 MB / 5,000 lines, and is what makes the Brain view light up. The graph itself never records what was looked at.
567
+
568
+ Behind Caddy on an agent's VM:
569
+
570
+ ```caddyfile
571
+ agent.example.com {
572
+ # … the agent's other routes …
573
+ handle /reverie/* {
574
+ uri strip_prefix /reverie
575
+ reverse_proxy 127.0.0.1:8643
576
+ }
577
+ }
578
+ ```
579
+
580
+ and as a service:
581
+
582
+ ```ini
583
+ [Unit]
584
+ Description=Reverie graph memory (HTTP mode)
585
+ After=network-online.target
586
+
587
+ [Service]
588
+ User=agent
589
+ EnvironmentFile=/home/agent/.config/reverie.env # NEO4J_*, REVERIE_SERVE_TOKEN, REVERIE_EMBEDDINGS…
590
+ ExecStart=/usr/bin/reverie serve
591
+ Restart=always
592
+ RestartSec=3
593
+
594
+ [Install]
595
+ WantedBy=multi-user.target
596
+ ```
597
+
598
+ Give the portal `https://<agent host>/reverie` as the agent's `brainUrl` and the same token as
599
+ `REVERIE_TOKEN`.
600
+
526
601
  ## License
527
602
 
528
603
  MIT
package/build/brain.js ADDED
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Brain view data for the Agents Portal: graph snapshots, snapshot diffs and the agent's
3
+ * awake/dreaming state. No HTTP here; `http-server.ts` exposes it, which keeps this testable
4
+ * without a server or a database. Field names are the portal's contract, so they stay camelCase.
5
+ */
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import neo4j from 'neo4j-driver';
10
+ import { MAX_LINES, eventsPath, expandHome, tail } from './events.js';
11
+ export const MAX_LIMIT = 1500;
12
+ export const DEFAULT_LIMIT = 400;
13
+ const DREAM_WINDOW_SECONDS = 30 * 60;
14
+ const RECENT_WINDOW_SECONDS = 15 * 60;
15
+ const USAGE_MAX_AGE_SECONDS = 900;
16
+ const PROP_MAX_CHARS = 300;
17
+ const PROP_MAX_KEYS = 24;
18
+ const PROP_MAX_ITEMS = 12;
19
+ const HIDDEN_PROPS = new Set(['embedding', 'name_embedding', 'vector', 'embedding_model', 'embedded_at']);
20
+ // Ids are the same numeric ids the MCP tools expose (as strings), so activation events and
21
+ // snapshot nodes line up in the portal.
22
+ const NODE_QUERY = `
23
+ MATCH (n)
24
+ WHERE n.status IS NULL OR n.status <> 'archived'
25
+ OPTIONAL MATCH (n)-[r]-()
26
+ WITH n, count(r) AS degree
27
+ ORDER BY degree DESC, coalesce(n.updated_at, n.created_at) DESC
28
+ LIMIT $limit
29
+ RETURN toString(id(n)) AS id, labels(n) AS labels, n.name AS name, degree,
30
+ n.updated_at AS updated_at, n.created_at AS created_at, properties(n) AS props`;
31
+ const REL_QUERY = `
32
+ MATCH (a)-[r]->(b)
33
+ WHERE toString(id(a)) IN $ids AND toString(id(b)) IN $ids
34
+ RETURN toString(id(r)) AS id, type(r) AS type, toString(id(a)) AS source, toString(id(b)) AS target,
35
+ r.updated_at AS updated_at, r.created_at AS created_at`;
36
+ const LABEL_STATS_QUERY = `
37
+ MATCH (n) WHERE n.status IS NULL OR n.status <> 'archived'
38
+ RETURN head(labels(n)) AS label, count(*) AS n`;
39
+ const REL_STATS_QUERY = `
40
+ MATCH (a)-[r]->(b)
41
+ WHERE (a.status IS NULL OR a.status <> 'archived') AND (b.status IS NULL OR b.status <> 'archived')
42
+ RETURN type(r) AS type, count(*) AS n`;
43
+ /** A query-string `limit`: absent → the default; otherwise it must be a base-10 integer in 1..MAX_LIMIT, else null. */
44
+ export function parseLimit(raw) {
45
+ if (raw === null || raw === undefined || raw === '') {
46
+ return DEFAULT_LIMIT;
47
+ }
48
+ if (!/^\d{1,5}$/.test(raw)) {
49
+ return null;
50
+ }
51
+ const value = Number.parseInt(raw, 10);
52
+ return value >= 1 && value <= MAX_LIMIT ? value : null;
53
+ }
54
+ export function clampLimit(value, fallback = DEFAULT_LIMIT) {
55
+ const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
56
+ if (!Number.isFinite(parsed)) {
57
+ return fallback;
58
+ }
59
+ return Math.max(1, Math.min(Math.trunc(parsed), MAX_LIMIT));
60
+ }
61
+ /** Epoch seconds from whatever a graph stores: epoch seconds or milliseconds, ISO strings, Neo4j temporals (already strings). */
62
+ export function toEpochSeconds(value) {
63
+ if (typeof value === 'number' && Number.isFinite(value)) {
64
+ return Math.round(value >= 1e12 ? value / 1000 : value);
65
+ }
66
+ if (typeof value === 'string') {
67
+ const trimmed = value.trim();
68
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
69
+ return toEpochSeconds(Number(trimmed));
70
+ }
71
+ const parsed = Date.parse(trimmed);
72
+ return Number.isFinite(parsed) ? Math.round(parsed / 1000) : 0;
73
+ }
74
+ if (value && typeof value === 'object' && typeof value.toString === 'function') {
75
+ const text = String(value);
76
+ return text && text !== '[object Object]' ? toEpochSeconds(text) : 0;
77
+ }
78
+ return 0;
79
+ }
80
+ /** Primitives only, short strings only, a bounded number of keys; vectors never leave the server. */
81
+ export function cleanProps(props) {
82
+ const out = {};
83
+ for (const [key, value] of Object.entries(props ?? {})) {
84
+ if (HIDDEN_PROPS.has(key) || Object.keys(out).length >= PROP_MAX_KEYS) {
85
+ continue;
86
+ }
87
+ if (value === null || typeof value === 'boolean') {
88
+ out[key] = value;
89
+ }
90
+ else if (typeof value === 'number' && Number.isFinite(value)) {
91
+ out[key] = value;
92
+ }
93
+ else if (typeof value === 'string') {
94
+ out[key] = value.length <= PROP_MAX_CHARS ? value : `${value.slice(0, PROP_MAX_CHARS)}…`;
95
+ }
96
+ else if (Array.isArray(value) && value.every((item) => typeof item === 'string' || typeof item === 'number')) {
97
+ out[key] = value.slice(0, PROP_MAX_ITEMS);
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ /** Nodes (most connected / most recent first), the relationships among them, and totals. */
103
+ export async function snapshot(run, limit = DEFAULT_LIMIT) {
104
+ const nodeRows = await run(NODE_QUERY, { limit: neo4j.int(clampLimit(limit)) });
105
+ const nodes = nodeRows.map((row) => {
106
+ const labels = Array.isArray(row.labels) ? row.labels.map(String) : [];
107
+ const id = String(row.id);
108
+ const createdAt = toEpochSeconds(row.created_at);
109
+ return {
110
+ id,
111
+ labels,
112
+ label: labels[0] ?? 'Unknown',
113
+ name: row.name === null || row.name === undefined || row.name === '' ? id : String(row.name),
114
+ degree: Number(row.degree ?? 0),
115
+ updatedAt: row.updated_at === null || row.updated_at === undefined ? createdAt : toEpochSeconds(row.updated_at),
116
+ createdAt,
117
+ props: cleanProps(row.props ?? {})
118
+ };
119
+ });
120
+ const ids = nodes.map((node) => node.id);
121
+ const relRows = ids.length > 0 ? await run(REL_QUERY, { ids }) : [];
122
+ const rels = relRows.map((row) => ({
123
+ id: String(row.id),
124
+ type: String(row.type),
125
+ source: String(row.source),
126
+ target: String(row.target),
127
+ updatedAt: toEpochSeconds(row.updated_at ?? row.created_at)
128
+ }));
129
+ const labels = {};
130
+ for (const row of await run(LABEL_STATS_QUERY)) {
131
+ labels[row.label ? String(row.label) : 'Unknown'] = Number(row.n ?? 0);
132
+ }
133
+ const relTypes = {};
134
+ for (const row of await run(REL_STATS_QUERY)) {
135
+ relTypes[String(row.type)] = Number(row.n ?? 0);
136
+ }
137
+ return {
138
+ nodes,
139
+ rels,
140
+ stats: {
141
+ nodeCount: Object.values(labels).reduce((sum, n) => sum + n, 0),
142
+ relCount: Object.values(relTypes).reduce((sum, n) => sum + n, 0),
143
+ labels,
144
+ relTypes,
145
+ shown: nodes.length
146
+ },
147
+ generatedAt: Date.now() / 1000
148
+ };
149
+ }
150
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
151
+ /**
152
+ * What changed between two snapshots. Nodes count as updated when any exposed field differs
153
+ * (not just degree or the rounded timestamp); a changed relationship is a removal plus an
154
+ * addition, since the contract has no "relsUpdated"; `stats` is set only when the totals changed.
155
+ */
156
+ export function diffSnapshot(prev, curr) {
157
+ const prevNodes = new Map(prev.nodes.map((node) => [node.id, node]));
158
+ const currNodes = new Map(curr.nodes.map((node) => [node.id, node]));
159
+ const prevRels = new Map(prev.rels.map((rel) => [rel.id, rel]));
160
+ const currRels = new Map(curr.rels.map((rel) => [rel.id, rel]));
161
+ const changedRels = curr.rels.filter((rel) => {
162
+ const before = prevRels.get(rel.id);
163
+ return before !== undefined && !same(before, rel);
164
+ });
165
+ const diff = {
166
+ nodesAdded: curr.nodes.filter((node) => !prevNodes.has(node.id)),
167
+ nodesUpdated: curr.nodes.filter((node) => {
168
+ const before = prevNodes.get(node.id);
169
+ return before !== undefined && !same(before, node);
170
+ }),
171
+ nodesRemoved: prev.nodes.filter((node) => !currNodes.has(node.id)).map((node) => node.id),
172
+ relsAdded: [...curr.rels.filter((rel) => !prevRels.has(rel.id)), ...changedRels],
173
+ relsRemoved: [...prev.rels.filter((rel) => !currRels.has(rel.id)).map((rel) => rel.id), ...changedRels.map((rel) => rel.id)]
174
+ };
175
+ if (!same(prev.stats, curr.stats)) {
176
+ diff.stats = curr.stats;
177
+ }
178
+ return diff;
179
+ }
180
+ /** A diff with nothing to apply: no node or relationship changes and no changed totals. */
181
+ export function isEmptyDiff(diff) {
182
+ return (diff.nodesAdded.length === 0 &&
183
+ diff.nodesUpdated.length === 0 &&
184
+ diff.nodesRemoved.length === 0 &&
185
+ diff.relsAdded.length === 0 &&
186
+ diff.relsRemoved.length === 0 &&
187
+ diff.stats === undefined);
188
+ }
189
+ /** The whole snapshot expressed as a diff against nothing: what a fresh stream sends first. */
190
+ export function snapshotAsDiff(snap) {
191
+ return { nodesAdded: snap.nodes, nodesUpdated: [], nodesRemoved: [], relsAdded: snap.rels, relsRemoved: [], stats: snap.stats };
192
+ }
193
+ /** Newest dream diary entry (*.md in REVERIE_DREAMS_DIR) by modification time; null when the variable is unset. */
194
+ export function latestDream(env = process.env) {
195
+ const raw = env.REVERIE_DREAMS_DIR?.trim();
196
+ if (!raw) {
197
+ return null;
198
+ }
199
+ const dir = expandHome(raw);
200
+ try {
201
+ if (!fs.statSync(dir).isDirectory()) {
202
+ return null;
203
+ }
204
+ let newest = null;
205
+ for (const entry of fs.readdirSync(dir)) {
206
+ if (!entry.endsWith('.md')) {
207
+ continue;
208
+ }
209
+ const at = fs.statSync(path.join(dir, entry)).mtimeMs / 1000;
210
+ if (!newest || at > newest.at) {
211
+ newest = { name: entry.slice(0, -3), at };
212
+ }
213
+ }
214
+ return newest;
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ }
220
+ let cpuLast = null;
221
+ /** Overall CPU busy percentage since the previous call (Linux /proc/stat); null on the first call or elsewhere. */
222
+ export function cpuPercent() {
223
+ let fields;
224
+ try {
225
+ fields = fs.readFileSync('/proc/stat', 'utf8').split('\n')[0].trim().split(/\s+/).slice(1, 8).map(Number);
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ if (fields.length < 5 || fields.some((n) => !Number.isFinite(n))) {
231
+ return null;
232
+ }
233
+ const idle = fields[3] + fields[4];
234
+ const total = fields.reduce((sum, n) => sum + n, 0);
235
+ const prev = cpuLast;
236
+ cpuLast = { idle, total };
237
+ if (!prev || total === prev.total) {
238
+ return null;
239
+ }
240
+ return round((100 * (1 - (idle - prev.idle) / (total - prev.total))), 1);
241
+ }
242
+ /** CPU %, 1-minute load and memory use of the agent's VM, for the telemetry panel. Nulls where unavailable. */
243
+ export function hostStats() {
244
+ const out = { cpuPercent: cpuPercent(), load1: null, memPercent: null, memUsedGb: null, memTotalGb: null };
245
+ try {
246
+ out.load1 = round(os.loadavg()[0], 2);
247
+ }
248
+ catch {
249
+ out.load1 = null;
250
+ }
251
+ try {
252
+ const info = {};
253
+ for (const line of fs.readFileSync('/proc/meminfo', 'utf8').split('\n')) {
254
+ const [key, rest] = line.split(':');
255
+ if (key && rest) {
256
+ info[key.trim()] = Number(rest.trim().split(/\s+/)[0]);
257
+ }
258
+ }
259
+ const total = info.MemTotal;
260
+ const available = info.MemAvailable;
261
+ if (total > 0 && Number.isFinite(available)) {
262
+ out.memPercent = round(100 * (1 - available / total), 1);
263
+ out.memTotalGb = round(total / 1048576, 1);
264
+ out.memUsedGb = round((total - available) / 1048576, 1);
265
+ }
266
+ }
267
+ catch {
268
+ // not Linux, or /proc unreadable
269
+ }
270
+ return out;
271
+ }
272
+ function readJsonFile(env, variable, fallback, maxAgeSeconds) {
273
+ const raw = env[variable] === undefined ? fallback : env[variable].trim();
274
+ if (!raw) {
275
+ return null;
276
+ }
277
+ const file = expandHome(raw);
278
+ try {
279
+ const stat = fs.statSync(file);
280
+ if (!stat.isFile()) {
281
+ return null;
282
+ }
283
+ if (maxAgeSeconds !== undefined && Date.now() / 1000 - stat.mtimeMs / 1000 > maxAgeSeconds) {
284
+ return null;
285
+ }
286
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
287
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
288
+ }
289
+ catch {
290
+ return null;
291
+ }
292
+ }
293
+ /** usage-stats.json (fresh within 15 min) and boost-state.json written by the agent's Presence stack. */
294
+ export function presenceStats(env = process.env) {
295
+ const home = os.homedir();
296
+ return {
297
+ usage: readJsonFile(env, 'REVERIE_USAGE_STATS_PATH', path.join(home, 'call-transcripts', 'usage-stats.json'), USAGE_MAX_AGE_SECONDS),
298
+ boost: readJsonFile(env, 'REVERIE_BOOST_STATE_PATH', path.join(home, 'call-transcripts', 'boost-state.json'))
299
+ };
300
+ }
301
+ /**
302
+ * Awake/dreaming signals from the activation log (and the dream diary when one is configured):
303
+ * dreaming = a dream.start newer than the last dream.end within the last 30 minutes;
304
+ * lastActivityAt = the newest event of any kind; recent counts cover the last 15 minutes.
305
+ */
306
+ export function state(events, now, env = process.env) {
307
+ const at = now ?? Date.now() / 1000;
308
+ const file = eventsPath(env);
309
+ const list = events ?? (file ? tail(file, MAX_LINES) : []); // the whole retained log, so windows are never truncated
310
+ const ts = (event) => (typeof event.ts === 'number' && Number.isFinite(event.ts) ? event.ts : 0);
311
+ const newest = (kind) => list.filter((event) => kind === undefined || event.kind === kind).reduce((max, event) => (max === null || ts(event) > max ? ts(event) : max), null);
312
+ const lastActivity = newest();
313
+ const dreamStart = newest('dream.start');
314
+ const dreamEnd = newest('dream.end');
315
+ const dreaming = dreamStart !== null && at - dreamStart < DREAM_WINDOW_SECONDS && (dreamEnd === null || dreamEnd < dreamStart);
316
+ const diary = latestDream(env);
317
+ const lastDreamEvent = [...list].reverse().find((event) => event.kind === 'dream.end') ?? [...list].reverse().find((event) => event.kind === 'dream.start');
318
+ const recent = list.filter((event) => at - ts(event) < RECENT_WINDOW_SECONDS);
319
+ return {
320
+ dreaming,
321
+ lastActivityAt: lastActivity,
322
+ lastDreamAt: diary ? diary.at : dreamEnd ?? dreamStart,
323
+ lastDreamName: diary ? diary.name : typeof lastDreamEvent?.name === 'string' ? lastDreamEvent.name : null,
324
+ recentReads: recent.filter((event) => event.kind === 'recall').length,
325
+ recentWrites: recent.filter((event) => event.kind === 'remember' || event.kind === 'connect' || event.kind === 'forget').length,
326
+ eventsAvailable: file !== null,
327
+ ...hostStats(),
328
+ ...presenceStats(env)
329
+ };
330
+ }
331
+ function round(value, digits) {
332
+ const factor = 10 ** digits;
333
+ return Math.round(value * factor) / factor;
334
+ }
@@ -0,0 +1,32 @@
1
+ function isSet(value) {
2
+ return typeof value === 'string' && value.trim() !== '';
3
+ }
4
+ /** Neo4j connection from NEO4J_URI / NEO4J_USERNAME / NEO4J_PASSWORD (+ optional NEO4J_DATABASE); undefined when unset. */
5
+ export function neo4jConfigFromEnv(env = process.env) {
6
+ const uri = env.NEO4J_URI?.trim();
7
+ const username = env.NEO4J_USERNAME?.trim();
8
+ const password = env.NEO4J_PASSWORD; // never trimmed: whitespace can be part of a password
9
+ if (!uri || !username || !isSet(password)) {
10
+ return undefined;
11
+ }
12
+ return {
13
+ uri,
14
+ username,
15
+ password,
16
+ database: env.NEO4J_DATABASE?.trim() || undefined // optional for Neo4j Community Edition
17
+ };
18
+ }
19
+ /** A partial configuration is a mistake worth refusing to start on; returns the message to print, or null. */
20
+ export function neo4jConfigError(env = process.env) {
21
+ const uri = env.NEO4J_URI?.trim();
22
+ const username = env.NEO4J_USERNAME?.trim();
23
+ const password = isSet(env.NEO4J_PASSWORD);
24
+ if (!(uri || username || password) || (uri && username && password)) {
25
+ return null;
26
+ }
27
+ if (!password)
28
+ return 'Error: NEO4J_PASSWORD environment variable is required';
29
+ if (!uri)
30
+ return 'Error: NEO4J_URI environment variable is required';
31
+ return 'Error: NEO4J_USERNAME environment variable is required';
32
+ }