@knowall-ai/reverie 0.4.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,43 @@
1
+ import { EMBEDDING_FIELDS } from './embeddings.js';
2
+ /** Above this many real properties a node is "bloated": facts are being piled on instead of modelled. */
3
+ export const DEFAULT_MAX_PROPERTIES = 30;
4
+ export const MAX_PROPERTIES_CAP = 500;
5
+ const HOUSEKEEPING = new Set([...EMBEDDING_FIELDS, 'created_at', 'updated_at', 'status', 'archived_at']);
6
+ const DATE_SUFFIX_RE = /(_|-)\d{4}(_|-)\d{2}(_|-)\d{2}$/;
7
+ const LONG_TEXT = 120;
8
+ /** REVERIE_MAX_PROPERTIES must be a whole number in 1..MAX_PROPERTIES_CAP; anything else falls back to the default. */
9
+ export function maxProperties(env = process.env) {
10
+ const raw = (env.REVERIE_MAX_PROPERTIES ?? '').trim();
11
+ if (!/^\d+$/.test(raw)) {
12
+ return DEFAULT_MAX_PROPERTIES;
13
+ }
14
+ const value = Number(raw);
15
+ return value >= 1 && value <= MAX_PROPERTIES_CAP ? value : DEFAULT_MAX_PROPERTIES;
16
+ }
17
+ /** User-visible properties only: no `_id`-style metadata, timestamps, status or embedding fields. */
18
+ export function contentKeys(props) {
19
+ return Object.keys(props).filter((key) => !key.startsWith('_') && !HOUSEKEEPING.has(key));
20
+ }
21
+ /** Keys that look like facts or episodes rather than attributes: dated names, or long prose values. */
22
+ export function factLikeKeys(props) {
23
+ return contentKeys(props).filter((key) => {
24
+ const value = props[key];
25
+ return DATE_SUFFIX_RE.test(key) || (typeof value === 'string' && value.length > LONG_TEXT);
26
+ });
27
+ }
28
+ export const DEFAULT_LAZY_EMBED_BATCH = 100;
29
+ export const MAX_LAZY_EMBED_BATCH = 1000;
30
+ /** REVERIE_LAZY_EMBED_BATCH: stale candidates embedded per search; whole number 1..1000, else the default. */
31
+ export function lazyEmbedBatch(env = process.env) {
32
+ const raw = (env.REVERIE_LAZY_EMBED_BATCH ?? '').trim();
33
+ if (!/^\d+$/.test(raw)) {
34
+ return DEFAULT_LAZY_EMBED_BATCH;
35
+ }
36
+ const value = Number(raw);
37
+ return value >= 1 && value <= MAX_LAZY_EMBED_BATCH ? value : DEFAULT_LAZY_EMBED_BATCH;
38
+ }
39
+ export function bloatHint(name, count, limit) {
40
+ return `"${name}" now has ${count} properties (limit ${limit}). The graph maps entities and relationships; ` +
41
+ 'keep only durable attributes here. Fold dated facts and episodes into a short attribute, a relationship, ' +
42
+ 'or your notes. See get_guidance("best-practices").';
43
+ }
package/build/index.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { Neo4jServer } from './server.js';
3
+ // Debug environment for Smithery
4
+ if (process.env.DEBUG_SMITHERY) {
5
+ console.error('Environment variables:', {
6
+ NEO4J_URI: process.env.NEO4J_URI,
7
+ NEO4J_USERNAME: process.env.NEO4J_USERNAME,
8
+ NEO4J_PASSWORD: process.env.NEO4J_PASSWORD,
9
+ NEO4J_DATABASE: process.env.NEO4J_DATABASE
10
+ });
11
+ }
12
+ // Check if we have database configuration (ignore empty strings)
13
+ const uri = process.env.NEO4J_URI?.trim();
14
+ const username = process.env.NEO4J_USERNAME?.trim();
15
+ const password = process.env.NEO4J_PASSWORD?.trim();
16
+ const hasDbConfig = uri && username && password;
17
+ // Only validate environment variables if at least one non-empty value is provided
18
+ if ((uri || username || password) && !hasDbConfig) {
19
+ // If any Neo4j env var is set, all required ones must be set
20
+ if (!password) {
21
+ console.error('Error: NEO4J_PASSWORD environment variable is required');
22
+ process.exit(1);
23
+ }
24
+ if (!uri) {
25
+ console.error('Error: NEO4J_URI environment variable is required');
26
+ process.exit(1);
27
+ }
28
+ if (!username) {
29
+ console.error('Error: NEO4J_USERNAME environment variable is required');
30
+ process.exit(1);
31
+ }
32
+ }
33
+ const config = hasDbConfig ? {
34
+ uri: uri,
35
+ username: username,
36
+ password: password,
37
+ database: process.env.NEO4J_DATABASE?.trim() || undefined, // Optional for Neo4j Community Edition
38
+ } : undefined;
39
+ // サーバーの起動
40
+ const server = new Neo4jServer(config);
41
+ server.run().catch((error) => {
42
+ console.error('Failed to start Neo4j MCP server:', error);
43
+ process.exit(1);
44
+ });
45
+ // 終了時のクリーンアップ
46
+ process.on('SIGINT', async () => {
47
+ try {
48
+ await server.close();
49
+ process.exit(0);
50
+ }
51
+ catch (error) {
52
+ console.error('Error during shutdown:', error);
53
+ process.exit(1);
54
+ }
55
+ });
56
+ process.on('SIGTERM', async () => {
57
+ try {
58
+ await server.close();
59
+ process.exit(0);
60
+ }
61
+ catch (error) {
62
+ console.error('Error during shutdown:', error);
63
+ process.exit(1);
64
+ }
65
+ });
@@ -0,0 +1,182 @@
1
+ import { cypherIdentifier } from './types.js';
2
+ import neo4j, { Integer, Node, Relationship } from 'neo4j-driver';
3
+ const CLOSE_TIMEOUT_MS = 5_000;
4
+ /** session.close() awaits connection release with no deadline; a stuck release must not hang a tool call. */
5
+ async function closeBounded(session) {
6
+ let timer;
7
+ const deadline = new Promise((resolve) => { timer = setTimeout(resolve, CLOSE_TIMEOUT_MS); });
8
+ try {
9
+ await Promise.race([session.close().catch(() => undefined), deadline]);
10
+ }
11
+ finally {
12
+ clearTimeout(timer);
13
+ }
14
+ }
15
+ export class Neo4jClient {
16
+ driver;
17
+ database;
18
+ constructor(uri, username, password, database) {
19
+ this.driver = neo4j.driver(uri, neo4j.auth.basic(username, password));
20
+ this.database = database;
21
+ }
22
+ convertNestedIntegers(value) {
23
+ if (value instanceof Integer) {
24
+ return value.toNumber();
25
+ }
26
+ if (value instanceof Node) {
27
+ return {
28
+ ...this.convertNestedIntegers(value.properties),
29
+ _id: value.identity.toNumber(),
30
+ _labels: value.labels,
31
+ };
32
+ }
33
+ if (value instanceof Relationship) {
34
+ return {
35
+ ...this.convertNestedIntegers(value.properties),
36
+ _id: value.identity.toNumber(),
37
+ _type: value.type,
38
+ _start: value.start.toNumber(),
39
+ _end: value.end.toNumber(),
40
+ };
41
+ }
42
+ if (neo4j.isPath(value)) {
43
+ return {
44
+ start: this.convertNestedIntegers(value.start),
45
+ end: this.convertNestedIntegers(value.end),
46
+ segments: value.segments.map((segment) => ({
47
+ start: this.convertNestedIntegers(segment.start),
48
+ relationship: this.convertNestedIntegers(segment.relationship),
49
+ end: this.convertNestedIntegers(segment.end),
50
+ })),
51
+ };
52
+ }
53
+ if (neo4j.isPoint(value)) {
54
+ return { srid: this.convertNestedIntegers(value.srid), x: value.x, y: value.y, z: value.z };
55
+ }
56
+ if (neo4j.isDate(value) || neo4j.isDateTime(value) || neo4j.isLocalDateTime(value) ||
57
+ neo4j.isTime(value) || neo4j.isLocalTime(value) || neo4j.isDuration(value)) {
58
+ return value.toString();
59
+ }
60
+ if (Array.isArray(value)) {
61
+ return value.map((item) => this.convertNestedIntegers(item));
62
+ }
63
+ if (value && typeof value === 'object' && value.constructor === Object) {
64
+ const converted = {};
65
+ for (const [key, val] of Object.entries(value)) {
66
+ converted[key] = this.convertNestedIntegers(val);
67
+ }
68
+ return converted;
69
+ }
70
+ return value;
71
+ }
72
+ recordToObject(record) {
73
+ const obj = {};
74
+ for (const key of record.keys) {
75
+ obj[key] = this.convertNestedIntegers(record.get(key));
76
+ }
77
+ return obj;
78
+ }
79
+ async executeQuery(query, params = {}) {
80
+ const session = this.driver.session({
81
+ database: this.database
82
+ });
83
+ try {
84
+ const result = await session.run(query, params);
85
+ return result.records.map((record) => this.recordToObject(record));
86
+ }
87
+ finally {
88
+ await closeBounded(session);
89
+ }
90
+ }
91
+ /**
92
+ * Caller-supplied Cypher: runs in a READ transaction (the server rejects writes), stops
93
+ * consuming after `limit` records instead of materialising the whole result, and is
94
+ * bounded by a transaction timeout.
95
+ */
96
+ async executeReadQuery(query, params, options) {
97
+ if (!Number.isSafeInteger(options.limit) || options.limit < 0) {
98
+ throw new Error('Query limit must be a non-negative integer');
99
+ }
100
+ if (options.limit === 0) {
101
+ return [];
102
+ }
103
+ const session = this.driver.session({
104
+ database: this.database,
105
+ defaultAccessMode: neo4j.session.READ
106
+ });
107
+ try {
108
+ return await session.executeRead(async (tx) => {
109
+ const rows = [];
110
+ for await (const record of tx.run(query, params)) {
111
+ rows.push(this.recordToObject(record));
112
+ if (rows.length >= options.limit) {
113
+ break;
114
+ }
115
+ }
116
+ return rows;
117
+ }, { timeout: options.timeoutMs });
118
+ }
119
+ finally {
120
+ await closeBounded(session);
121
+ }
122
+ }
123
+ async getNodes(label) {
124
+ return this.executeQuery(`MATCH (n:${cypherIdentifier(label)}) RETURN n as memory`);
125
+ }
126
+ async createNode(label, properties) {
127
+ const result = await this.executeQuery(`CREATE (n:${cypherIdentifier(label)} $props) RETURN n as memory`, { props: properties });
128
+ return result[0];
129
+ }
130
+ async createRelationship(fromNodeId, toNodeId, relationType, properties = {}) {
131
+ const result = await this.executeQuery(`MATCH (a), (b)
132
+ WHERE id(a) = $fromId AND id(b) = $toId
133
+ CREATE (a)-[r:${cypherIdentifier(relationType)} $props]->(b)
134
+ RETURN r as relationship`, {
135
+ fromId: neo4j.int(fromNodeId),
136
+ toId: neo4j.int(toNodeId),
137
+ props: properties,
138
+ });
139
+ return result[0];
140
+ }
141
+ async updateNode(nodeId, properties) {
142
+ const result = await this.executeQuery(`MATCH (n) WHERE id(n) = $nodeId
143
+ SET n += $props
144
+ RETURN n as memory`, {
145
+ nodeId: neo4j.int(nodeId),
146
+ props: properties,
147
+ });
148
+ return result[0];
149
+ }
150
+ async updateRelationship(fromNodeId, toNodeId, relationType, properties) {
151
+ const result = await this.executeQuery(`MATCH (a)-[r:${cypherIdentifier(relationType)}]->(b)
152
+ WHERE id(a) = $fromId AND id(b) = $toId
153
+ SET r += $props
154
+ RETURN r as relationship`, {
155
+ fromId: neo4j.int(fromNodeId),
156
+ toId: neo4j.int(toNodeId),
157
+ props: properties,
158
+ });
159
+ return result[0];
160
+ }
161
+ async deleteNode(nodeId) {
162
+ const result = await this.executeQuery(`MATCH (n) WHERE id(n) = $nodeId
163
+ DETACH DELETE n
164
+ RETURN count(n) as deletedCount`, {
165
+ nodeId: neo4j.int(nodeId),
166
+ });
167
+ return result[0];
168
+ }
169
+ async deleteRelationship(fromNodeId, toNodeId, relationType) {
170
+ const result = await this.executeQuery(`MATCH (a)-[r:${cypherIdentifier(relationType)}]->(b)
171
+ WHERE id(a) = $fromId AND id(b) = $toId
172
+ DELETE r
173
+ RETURN count(r) as deletedCount`, {
174
+ fromId: neo4j.int(fromNodeId),
175
+ toId: neo4j.int(toNodeId),
176
+ });
177
+ return result[0];
178
+ }
179
+ async close() {
180
+ await this.driver.close();
181
+ }
182
+ }
@@ -0,0 +1,92 @@
1
+ import { cosine } from './embeddings.js';
2
+ import { contentKeys } from './hygiene.js';
3
+ /** Case-insensitive equality on name, aliases or email: the lookup to run before creating a memory. */
4
+ export function exactMatches(query, props) {
5
+ const wanted = query.trim().toLowerCase();
6
+ if (!wanted)
7
+ return false;
8
+ const values = [props.name, props.email, ...(Array.isArray(props.aliases) ? props.aliases : [])];
9
+ return values.some((v) => typeof v === 'string' && v.trim().toLowerCase() === wanted);
10
+ }
11
+ export function keywordMatches(query, props) {
12
+ const trimmedQuery = query.trim().toLowerCase();
13
+ if (!trimmedQuery) {
14
+ return true;
15
+ }
16
+ const words = trimmedQuery.split(/\s+/);
17
+ // Only user content is searchable: timestamps, status and embedding fields never match.
18
+ for (const key of contentKeys(props)) {
19
+ const value = props[key];
20
+ if (value === null || value === undefined) {
21
+ continue;
22
+ }
23
+ const haystack = Array.isArray(value)
24
+ ? value.map((item) => item?.toString() || '').join(' ').toLowerCase()
25
+ : value.toString().toLowerCase();
26
+ if (words.some((word) => haystack.includes(word))) {
27
+ return true;
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+ export function rank(candidates, opts) {
33
+ const trimmedQuery = opts.query.trim();
34
+ if (!trimmedQuery) {
35
+ return [...candidates]
36
+ .map((candidate) => ({ id: candidate.id, score: 0, match: 'keyword' }))
37
+ .sort(compareRankedBy(candidates));
38
+ }
39
+ const results = [];
40
+ const seen = new Set();
41
+ if (opts.mode === 'exact') {
42
+ for (const candidate of candidates) {
43
+ if (exactMatches(trimmedQuery, candidate.props)) {
44
+ results.push({ id: candidate.id, score: 1, match: 'exact' });
45
+ }
46
+ }
47
+ return results.sort(compareRankedBy(candidates));
48
+ }
49
+ if (opts.mode === 'hybrid' || opts.mode === 'keyword') {
50
+ for (const candidate of candidates) {
51
+ if (keywordMatches(trimmedQuery, candidate.props)) {
52
+ results.push({ id: candidate.id, score: 1, match: 'keyword' });
53
+ seen.add(candidate.id);
54
+ }
55
+ }
56
+ }
57
+ if ((opts.mode === 'hybrid' || opts.mode === 'semantic') && opts.queryEmbedding && opts.modelId) {
58
+ for (const candidate of candidates) {
59
+ if (seen.has(candidate.id)) {
60
+ continue;
61
+ }
62
+ if (candidate.props.embedding_model !== opts.modelId) {
63
+ continue;
64
+ }
65
+ const score = Math.max(similarity(opts.queryEmbedding, candidate.props.embedding), similarity(opts.queryEmbedding, candidate.props.name_embedding));
66
+ if (score >= opts.threshold) {
67
+ results.push({ id: candidate.id, score, match: 'semantic' });
68
+ }
69
+ }
70
+ }
71
+ return results.sort(compareRankedBy(candidates));
72
+ }
73
+ function similarity(query, vector) {
74
+ return Array.isArray(vector) ? cosine(query, vector.map((item) => Number(item))) : 0;
75
+ }
76
+ function compareRankedBy(candidates) {
77
+ const createdAt = new Map(candidates.map((candidate) => [
78
+ candidate.id,
79
+ typeof candidate.props.created_at === 'string' ? candidate.props.created_at : ''
80
+ ]));
81
+ return (left, right) => {
82
+ if (right.score !== left.score) {
83
+ return right.score - left.score;
84
+ }
85
+ const leftCreatedAt = createdAt.get(left.id) ?? '';
86
+ const rightCreatedAt = createdAt.get(right.id) ?? '';
87
+ if (rightCreatedAt !== leftCreatedAt) {
88
+ return rightCreatedAt.localeCompare(leftCreatedAt);
89
+ }
90
+ return left.id - right.id;
91
+ };
92
+ }
@@ -0,0 +1,71 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import { createEmbedder } from './embeddings.js';
5
+ import { handleToolCall } from './handlers/index.js';
6
+ import { Neo4jClient } from './neo4j-client.js';
7
+ import { tools } from './tools/definitions.js';
8
+ /** A misconfigured provider (missing key, http endpoint…) must not stop the server; search degrades to keyword. */
9
+ function safeCreateEmbedder() {
10
+ try {
11
+ return createEmbedder();
12
+ }
13
+ catch (error) {
14
+ console.error('Embeddings disabled:', error instanceof Error ? error.message : error);
15
+ return null;
16
+ }
17
+ }
18
+ export class Neo4jServer {
19
+ server;
20
+ neo4j;
21
+ embedder;
22
+ constructor(config) {
23
+ this.server = new Server({
24
+ name: 'reverie',
25
+ version: '0.4.0',
26
+ }, {
27
+ capabilities: {
28
+ tools: {},
29
+ },
30
+ });
31
+ this.neo4j = config ? new Neo4jClient(config.uri, config.username, config.password, config.database) : null;
32
+ this.embedder = config ? safeCreateEmbedder() : null;
33
+ this.setupToolHandlers();
34
+ this.server.onerror = (error) => console.error('[MCP Error]', error);
35
+ process.on('SIGINT', async () => {
36
+ await this.close();
37
+ process.exit(0);
38
+ });
39
+ }
40
+ setupToolHandlers() {
41
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
42
+ tools,
43
+ }));
44
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
45
+ if (!this.neo4j) {
46
+ return {
47
+ content: [
48
+ {
49
+ type: 'text',
50
+ text: 'Neo4j connection not configured. Please set NEO4J_URI, NEO4J_USERNAME, and NEO4J_PASSWORD environment variables.',
51
+ },
52
+ ],
53
+ isError: true,
54
+ };
55
+ }
56
+ const { name, arguments: args } = request.params;
57
+ return handleToolCall(name, args, this.neo4j, this.embedder);
58
+ });
59
+ }
60
+ async run() {
61
+ const transport = new StdioServerTransport();
62
+ await this.server.connect(transport);
63
+ console.error('Neo4j MCP server running on stdio');
64
+ }
65
+ async close() {
66
+ if (this.neo4j) {
67
+ await this.neo4j.close();
68
+ }
69
+ await this.server.close();
70
+ }
71
+ }