@knowall-ai/reverie 0.4.0 → 0.5.1
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 +79 -0
- package/build/brain.js +334 -0
- package/build/config.js +32 -0
- package/build/events.js +283 -0
- package/build/handlers/index.js +34 -0
- package/build/http-server.js +418 -0
- package/build/index.js +46 -48
- package/build/server.js +25 -37
- package/package.json +6 -3
package/build/events.js
ADDED
|
@@ -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
|
+
}
|
package/build/handlers/index.js
CHANGED
|
@@ -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: [
|