@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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Activation log: an append-only JSONL trail of what the graph is doing (recall, remember,
3
+ * connect, forget, dream.start/dream.end), so the Agents Portal Brain view can light up the
4
+ * nodes an agent is "thinking about". Nothing here touches Neo4j: the graph only holds curated
5
+ * entities, never a record of what was looked at.
6
+ *
7
+ * Path: $REVERIE_EVENTS_PATH (default ~/.reverie/events.jsonl); set it to an empty string to
8
+ * disable. The file is trimmed to the newest MAX_LINES whenever it passes MAX_BYTES.
9
+ */
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ export const MAX_BYTES = 5 * 1024 * 1024;
14
+ export const MAX_LINES = 5000;
15
+ export const MAX_RECORD_BYTES = 64 * 1024;
16
+ const LOCK_STALE_MS = 60_000;
17
+ // (lock tokens are `pid hrtime`; stale locks are reclaimed by rename so only one contender wins)
18
+ const LOCK_WAIT_MS = 2_000;
19
+ let warned = false;
20
+ export function eventsPath(env = process.env) {
21
+ const raw = env.REVERIE_EVENTS_PATH;
22
+ if (raw === undefined) {
23
+ return path.join(os.homedir(), '.reverie', 'events.jsonl');
24
+ }
25
+ const trimmed = raw.trim();
26
+ return trimmed ? expandHome(trimmed) : null;
27
+ }
28
+ export function expandHome(value) {
29
+ return value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value;
30
+ }
31
+ /** Append one event. Never throws: the graph must keep working when the log cannot be written. */
32
+ export function emit(kind, data = {}, env = process.env) {
33
+ const file = eventsPath(env);
34
+ if (!file) {
35
+ return;
36
+ }
37
+ try {
38
+ const record = { ts: Date.now() / 1000, kind, ...data };
39
+ if (Buffer.byteLength(JSON.stringify(record), 'utf8') > MAX_RECORD_BYTES) {
40
+ return; // one runaway record must not blow the log's budget
41
+ }
42
+ // The log names what the agent knows, so it is private to the user (0700 dir, 0600 file).
43
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
44
+ const append = () => {
45
+ // The sequence number is assigned under the lock, so it is strictly increasing in append
46
+ // order whatever the wall clock says; readers use it as their delivery cursor.
47
+ const { lines, lastSeq, gen } = readLog(file);
48
+ record.seq = lastSeq + 1;
49
+ record.gen = gen;
50
+ fs.appendFileSync(file, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 });
51
+ ensurePrivate(file);
52
+ // Both caps are checked on every append, so the log never holds more than MAX_LINES lines
53
+ // or MAX_BYTES bytes after a write returns.
54
+ if (lines + 1 > MAX_LINES || fs.statSync(file).size > MAX_BYTES) {
55
+ trim(file);
56
+ }
57
+ };
58
+ // Several Reverie processes (stdio + serve) may share one log: append and trim run under a
59
+ // lock so a trim never drops another process's append. Never append outside the lock; if it
60
+ // cannot be taken within LOCK_WAIT_MS the event is dropped and reported once.
61
+ if (!withLock(file, append)) {
62
+ throw new Error('activation log lock not acquired');
63
+ }
64
+ }
65
+ catch (error) {
66
+ if (!warned) {
67
+ warned = true;
68
+ console.error('Activation log not written:', error instanceof Error ? error.message : error);
69
+ }
70
+ }
71
+ }
72
+ function ensurePrivate(file) {
73
+ try {
74
+ if ((fs.statSync(file).mode & 0o077) !== 0) {
75
+ fs.chmodSync(file, 0o600);
76
+ }
77
+ }
78
+ catch {
79
+ // best effort (e.g. filesystems without POSIX modes)
80
+ }
81
+ }
82
+ /** Line count, the last record's seq and the log's generation, from one read of the file (~1 ms for a ≤5 MB log). */
83
+ function readLog(file) {
84
+ let buffer;
85
+ try {
86
+ buffer = fs.readFileSync(file);
87
+ }
88
+ catch {
89
+ return { lines: 0, lastSeq: 0, gen: newGeneration() };
90
+ }
91
+ let lines = 0;
92
+ let lastStart = 0;
93
+ for (let i = 0; i < buffer.length; i += 1) {
94
+ if (buffer[i] === 10) {
95
+ lines += 1;
96
+ if (i + 1 < buffer.length) {
97
+ lastStart = i + 1;
98
+ }
99
+ }
100
+ }
101
+ const last = parseLine(buffer.subarray(lastStart).toString('utf8'));
102
+ return {
103
+ lines,
104
+ lastSeq: typeof last?.seq === 'number' ? last.seq : 0,
105
+ gen: typeof last?.gen === 'number' ? last.gen : newGeneration()
106
+ };
107
+ }
108
+ /** A fresh generation id: the wall clock in microseconds, distinct across recreations and processes for practical purposes. */
109
+ function newGeneration() {
110
+ return Number(process.hrtime.bigint() / 1000n) + Date.now() * 1000;
111
+ }
112
+ function sleepMs(ms) {
113
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
114
+ }
115
+ function processAlive(pid) {
116
+ try {
117
+ process.kill(pid, 0);
118
+ return true;
119
+ }
120
+ catch (error) {
121
+ return error.code === 'EPERM'; // exists, not ours
122
+ }
123
+ }
124
+ /**
125
+ * Cross-process lock via an exclusive lock file holding the owner's token. A lock is only
126
+ * broken when its owner is gone, or when it is older than LOCK_STALE_MS (a hung holder), and
127
+ * breaking is done by renaming the stale file away: rename is atomic, so of several contenders
128
+ * exactly one reclaims and none can remove a lock a new owner has just created. Release only
129
+ * removes the lock if it still carries our token.
130
+ */
131
+ function withLock(file, fn) {
132
+ const lock = `${file}.lock`;
133
+ const token = `${process.pid} ${process.hrtime.bigint()}`;
134
+ const deadline = Date.now() + LOCK_WAIT_MS;
135
+ while (Date.now() < deadline) {
136
+ let fd;
137
+ try {
138
+ fd = fs.openSync(lock, 'wx', 0o600);
139
+ }
140
+ catch (error) {
141
+ if (error.code !== 'EEXIST') {
142
+ return false;
143
+ }
144
+ try {
145
+ const observed = fs.readFileSync(lock, 'utf8');
146
+ const owner = Number.parseInt(observed.trim().split(' ')[0], 10);
147
+ const age = Date.now() - fs.statSync(lock).mtimeMs;
148
+ if ((Number.isInteger(owner) && !processAlive(owner)) || age > LOCK_STALE_MS) {
149
+ const stale = `${lock}.stale.${process.pid}.${process.hrtime.bigint()}`;
150
+ fs.renameSync(lock, stale); // fails for all but one contender
151
+ if (fs.readFileSync(stale, 'utf8') === observed) {
152
+ fs.unlinkSync(stale); // it was the dead lock we saw: reclaimed
153
+ }
154
+ else {
155
+ // Another contender reclaimed first and a new owner took the path in between: give
156
+ // the live lock back untouched and keep waiting.
157
+ fs.renameSync(stale, lock);
158
+ }
159
+ continue;
160
+ }
161
+ }
162
+ catch {
163
+ continue; // released or reclaimed by someone else between our checks
164
+ }
165
+ sleepMs(5);
166
+ continue;
167
+ }
168
+ try {
169
+ fs.writeSync(fd, token);
170
+ fn();
171
+ return true;
172
+ }
173
+ finally {
174
+ fs.closeSync(fd);
175
+ try {
176
+ if (fs.readFileSync(lock, 'utf8') === token) {
177
+ fs.unlinkSync(lock);
178
+ }
179
+ }
180
+ catch {
181
+ // already gone
182
+ }
183
+ }
184
+ }
185
+ return false;
186
+ }
187
+ /** Keep the newest records that fit both caps: at most MAX_LINES lines and MAX_BYTES / 2 bytes (so trims stay rare). Call under the lock; seq numbers survive because order does. */
188
+ function trim(file) {
189
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter((line) => line.trim()).slice(-MAX_LINES);
190
+ const budget = MAX_BYTES / 2;
191
+ let bytes = lines.reduce((sum, line) => sum + Buffer.byteLength(line, 'utf8') + 1, 0);
192
+ let start = 0;
193
+ while (start < lines.length && bytes > budget) {
194
+ bytes -= Buffer.byteLength(lines[start], 'utf8') + 1;
195
+ start += 1;
196
+ }
197
+ const kept = lines.slice(start);
198
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
199
+ fs.writeFileSync(tmp, kept.length ? kept.join('\n') + '\n' : '', { encoding: 'utf8', mode: 0o600 });
200
+ fs.renameSync(tmp, file);
201
+ }
202
+ function parseLine(line) {
203
+ const trimmed = line.trim();
204
+ if (!trimmed) {
205
+ return null;
206
+ }
207
+ try {
208
+ const parsed = JSON.parse(trimmed);
209
+ return parsed && typeof parsed === 'object' && typeof parsed.kind === 'string' ? parsed : null;
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ export const START_CURSOR = { offset: 0, ino: null, lastSeq: 0, gen: null };
216
+ /**
217
+ * Events appended since `cursor`, and the advanced cursor. Survives trims (the file is replaced,
218
+ * so its inode changes): the rewritten file is re-read from the start and events not newer than
219
+ * the last delivered one are skipped rather than replayed or lost.
220
+ */
221
+ export function readSince(file, cursor) {
222
+ try {
223
+ return readSinceUnsafe(file, cursor);
224
+ }
225
+ catch {
226
+ return { events: [], cursor: { ...cursor, offset: 0, ino: null } }; // removed or rotated mid-read: start again next time
227
+ }
228
+ }
229
+ function readSinceUnsafe(file, cursor) {
230
+ if (!fs.existsSync(file)) {
231
+ return { events: [], cursor: { ...cursor, offset: 0, ino: null } };
232
+ }
233
+ const fd = fs.openSync(file, 'r');
234
+ try {
235
+ const stat = fs.fstatSync(fd);
236
+ const replaced = cursor.ino !== null && stat.ino !== cursor.ino;
237
+ const start = replaced || stat.size < cursor.offset ? 0 : cursor.offset;
238
+ if (stat.size === start) {
239
+ return { events: [], cursor: { ...cursor, offset: start, ino: stat.ino } };
240
+ }
241
+ const buffer = Buffer.alloc(stat.size - start);
242
+ fs.readSync(fd, buffer, 0, buffer.length, start);
243
+ const text = buffer.toString('utf8');
244
+ // Only consume complete lines; a partial trailing line is picked up on the next read.
245
+ const complete = text.endsWith('\n') ? text : text.slice(0, text.lastIndexOf('\n') + 1);
246
+ const all = complete.split('\n').map(parseLine).filter((event) => event !== null);
247
+ const seqOf = (event) => (typeof event.seq === 'number' ? event.seq : 0);
248
+ const genOf = (event) => (typeof event.gen === 'number' ? event.gen : null);
249
+ // Re-reading a rewritten file from the start: skip what was already delivered from the same
250
+ // generation. A different generation is a deleted-and-recreated log: everything in it is new,
251
+ // whatever its sequence numbers happen to be.
252
+ const events = start === 0 && cursor.lastSeq > 0
253
+ ? all.filter((event) => genOf(event) !== cursor.gen || seqOf(event) > cursor.lastSeq)
254
+ : all;
255
+ const newest = events.length > 0 ? events[events.length - 1] : null;
256
+ const next = {
257
+ offset: start + Buffer.byteLength(complete, 'utf8'),
258
+ ino: stat.ino,
259
+ lastSeq: newest ? seqOf(newest) : cursor.lastSeq,
260
+ gen: newest ? genOf(newest) : cursor.gen
261
+ };
262
+ return { events, cursor: next };
263
+ }
264
+ finally {
265
+ fs.closeSync(fd);
266
+ }
267
+ }
268
+ /** The newest `limit` events, oldest first. */
269
+ export function tail(file, limit = 50) {
270
+ let text;
271
+ try {
272
+ text = fs.readFileSync(file, 'utf8');
273
+ }
274
+ catch {
275
+ return []; // missing, unreadable or rotated away
276
+ }
277
+ return text
278
+ .split('\n')
279
+ .filter((line) => line.trim())
280
+ .slice(-limit)
281
+ .map(parseLine)
282
+ .filter((event) => event !== null);
283
+ }
@@ -2,6 +2,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
2
  import neo4j from 'neo4j-driver';
3
3
  import { embedNodes, scrub } from '../embeddings.js';
4
4
  import { readOnlyViolation } from '../cypher-guard.js';
5
+ import { emit } from '../events.js';
5
6
  import { bloatHint, contentKeys, factLikeKeys, lazyEmbedBatch, maxProperties } from '../hygiene.js';
6
7
  import { rank } from '../search.js';
7
8
  import { isCreateConnectionArgs, isCreateMemoryArgs, isDeleteConnectionArgs, isDeleteMemoryArgs, isDreamArgs, isListMemoryLabelsArgs, isMemoryStatsArgs, isQueryMemoriesArgs, isSearchMemoriesArgs, isUpdateConnectionArgs, isUpdateMemoryArgs } from '../types.js';
@@ -28,6 +29,7 @@ const EMBED_NODE_CYPHER = `
28
29
  n.embedding_model = $model,
29
30
  n.embedded_at = $embeddedAt`;
30
31
  export async function handleToolCall(name, args, neo4jClient, embedder) {
32
+ let activeDream = null;
31
33
  try {
32
34
  switch (name) {
33
35
  case 'search_memories': {
@@ -80,6 +82,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
80
82
  const topRanked = ranking.slice(0, limit);
81
83
  const rankedIds = topRanked.map((item) => item.id);
82
84
  if (rankedIds.length === 0) {
85
+ emit('recall', { ids: [], names: [], terms: searchTerms(query) });
83
86
  return jsonResult([]);
84
87
  }
85
88
  const result = await fetchMemories(neo4jClient, rankedIds, depth, orderBy, limit, Boolean(args.include_archived));
@@ -100,6 +103,11 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
100
103
  : rankedIds
101
104
  .map((id) => result.find((row) => row?.memory?._id === id))
102
105
  .filter((row) => Boolean(row));
106
+ emit('recall', {
107
+ ids: orderedResult.map((row) => String(row.memory._id)),
108
+ names: orderedResult.map((row) => (typeof row.memory?.name === 'string' ? row.memory.name : null)),
109
+ terms: searchTerms(query)
110
+ });
103
111
  return jsonResult(orderedResult);
104
112
  }
105
113
  case 'create_memory': {
@@ -112,6 +120,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
112
120
  };
113
121
  const result = await neo4jClient.createNode(args.label, properties);
114
122
  await embedNodeIfPossible(neo4jClient, embedder, result?.memory, args.label, 'create_memory');
123
+ emitRemember(result?.memory, args.label);
115
124
  return jsonResult(result);
116
125
  }
117
126
  case 'create_connection': {
@@ -119,6 +128,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
119
128
  throw new McpError(ErrorCode.InvalidParams, 'Invalid create_connection arguments');
120
129
  }
121
130
  const result = await neo4jClient.createRelationship(args.fromMemoryId, args.toMemoryId, args.type, args.properties || { created_at: new Date().toISOString() });
131
+ emit('connect', { ids: [String(args.fromMemoryId), String(args.toMemoryId)], type: args.type });
122
132
  return jsonResult(result);
123
133
  }
124
134
  case 'update_memory': {
@@ -137,6 +147,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
137
147
  result.memory._hint = bloatHint(String(result.memory.name ?? args.nodeId), count, limit);
138
148
  }
139
149
  }
150
+ emitRemember(result?.memory, label);
140
151
  return jsonResult(result);
141
152
  }
142
153
  case 'update_connection': {
@@ -144,6 +155,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
144
155
  throw new McpError(ErrorCode.InvalidParams, 'Invalid update_connection arguments');
145
156
  }
146
157
  const result = await neo4jClient.updateRelationship(args.fromMemoryId, args.toMemoryId, args.type, args.properties);
158
+ emit('connect', { ids: [String(args.fromMemoryId), String(args.toMemoryId)], type: args.type });
147
159
  return jsonResult(result);
148
160
  }
149
161
  case 'delete_memory': {
@@ -151,6 +163,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
151
163
  throw new McpError(ErrorCode.InvalidParams, 'Invalid delete_memory arguments');
152
164
  }
153
165
  const result = await neo4jClient.deleteNode(args.nodeId);
166
+ emit('forget', { id: String(args.nodeId) });
154
167
  return jsonResult(result);
155
168
  }
156
169
  case 'delete_connection': {
@@ -158,6 +171,7 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
158
171
  throw new McpError(ErrorCode.InvalidParams, 'Invalid delete_connection arguments');
159
172
  }
160
173
  const result = await neo4jClient.deleteRelationship(args.fromMemoryId, args.toMemoryId, args.type);
174
+ emit('forget', { ids: [String(args.fromMemoryId), String(args.toMemoryId)], type: args.type });
161
175
  return jsonResult(result);
162
176
  }
163
177
  case 'list_memory_labels': {
@@ -234,6 +248,11 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
234
248
  }
235
249
  const dryRun = args.dry_run ?? false;
236
250
  const notes = [];
251
+ const dreamName = `dream-${new Date().toISOString().replace(/[-:]/g, '').replace(/T(\d{4}).*$/, '-$1')}`;
252
+ if (!dryRun) {
253
+ emit('dream.start', { name: dreamName });
254
+ activeDream = dreamName;
255
+ }
237
256
  const labelRows = await neo4jClient.executeQuery('CALL db.labels() YIELD label RETURN label ORDER BY label');
238
257
  let relabelled = 0;
239
258
  for (const row of labelRows) {
@@ -385,6 +404,10 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
385
404
  if (bloated.length > 0) {
386
405
  notes.push(`${bloated.length} node(s) exceed ${maxProperties()} properties; split their fact-like keys into their own memories (see bloated)`);
387
406
  }
407
+ if (!dryRun) {
408
+ emit('dream.end', { name: dreamName, relabelled, merged, reembedded });
409
+ activeDream = null;
410
+ }
388
411
  return jsonResult({
389
412
  dry_run: dryRun,
390
413
  relabelled,
@@ -416,6 +439,9 @@ export async function handleToolCall(name, args, neo4jClient, embedder) {
416
439
  }
417
440
  }
418
441
  catch (error) {
442
+ if (activeDream) {
443
+ emit('dream.end', { name: activeDream, failed: true });
444
+ }
419
445
  console.error('Error executing tool:', error);
420
446
  return {
421
447
  content: [
@@ -584,6 +610,14 @@ async function findBloatedNodes(neo4jClient, limit) {
584
610
  .sort((left, right) => right.properties - left.properties)
585
611
  .slice(0, 20);
586
612
  }
613
+ function searchTerms(query) {
614
+ return query.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 10);
615
+ }
616
+ function emitRemember(memory, label) {
617
+ if (memory && typeof memory._id === 'number') {
618
+ emit('remember', { id: String(memory._id), name: typeof memory.name === 'string' ? memory.name : null, label });
619
+ }
620
+ }
587
621
  function jsonResult(value) {
588
622
  return {
589
623
  content: [