@rubytech/create-maxy 1.0.693 → 1.0.694

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.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-search/dist/index.d.ts +127 -0
  3. package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -0
  4. package/payload/platform/lib/graph-search/dist/index.js +393 -0
  5. package/payload/platform/lib/graph-search/dist/index.js.map +1 -0
  6. package/payload/platform/lib/graph-search/src/__tests__/bm25-only.test.ts +129 -0
  7. package/payload/platform/lib/graph-search/src/__tests__/escape-and-normalise.test.ts +53 -0
  8. package/payload/platform/lib/graph-search/src/__tests__/hybrid.test.ts +190 -0
  9. package/payload/platform/lib/graph-search/src/index.ts +498 -0
  10. package/payload/platform/lib/graph-search/tsconfig.json +9 -0
  11. package/payload/platform/lib/graph-search/vitest.config.ts +9 -0
  12. package/payload/platform/lib/graph-write/dist/index.d.ts +61 -0
  13. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -0
  14. package/payload/platform/lib/graph-write/dist/index.js +97 -0
  15. package/payload/platform/lib/graph-write/dist/index.js.map +1 -0
  16. package/payload/platform/lib/graph-write/src/index.ts +167 -0
  17. package/payload/platform/lib/graph-write/tsconfig.json +8 -0
  18. package/payload/platform/package.json +2 -2
  19. package/payload/platform/plugins/admin/mcp/dist/index.js +19 -8
  20. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  21. package/payload/platform/plugins/contacts/mcp/dist/index.js +27 -3
  22. package/payload/platform/plugins/contacts/mcp/dist/index.js.map +1 -1
  23. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.d.ts +4 -0
  24. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.d.ts.map +1 -1
  25. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.js +10 -6
  26. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.js.map +1 -1
  27. package/payload/platform/plugins/contacts/mcp/dist/tools/group-create.d.ts +2 -0
  28. package/payload/platform/plugins/contacts/mcp/dist/tools/group-create.d.ts.map +1 -1
  29. package/payload/platform/plugins/contacts/mcp/dist/tools/group-create.js +43 -36
  30. package/payload/platform/plugins/contacts/mcp/dist/tools/group-create.js.map +1 -1
  31. package/payload/platform/plugins/docs/references/memory-guide.md +6 -0
  32. package/payload/platform/plugins/memory/mcp/dist/index.js +44 -3
  33. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  34. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts +3 -32
  35. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts.map +1 -1
  36. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js +18 -381
  37. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js.map +1 -1
  38. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts +9 -5
  39. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  40. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +10 -23
  41. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  42. package/payload/platform/plugins/memory/references/graph-primitives.md +1 -1
  43. package/payload/platform/plugins/scheduling/mcp/dist/index.js +8 -1
  44. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  45. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +2 -0
  46. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  47. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +24 -10
  48. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  49. package/payload/platform/plugins/tasks/mcp/dist/index.js +8 -2
  50. package/payload/platform/plugins/tasks/mcp/dist/index.js.map +1 -1
  51. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts +2 -0
  52. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts.map +1 -1
  53. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js +45 -18
  54. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js.map +1 -1
  55. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js +12 -2
  56. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js.map +1 -1
  57. package/payload/server/chunk-IAIGB5WN.js +11406 -0
  58. package/payload/server/maxy-edge.js +1 -1
  59. package/payload/server/server.js +656 -21
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Shared hybrid search primitive over the Neo4j knowledge graph.
3
+ *
4
+ * Pre-Task-675 there were two BM25 implementations over the same
5
+ * `knowledge_fulltext` index — one in the memory MCP tool, one in the admin
6
+ * Hono route — with divergent filter semantics (soft-delete primitive) and
7
+ * divergent ranking (agent ran hybrid vector+BM25, UI ran BM25-only). Task
8
+ * 675 collapses both into this lib; memory MCP + admin route now share
9
+ * a single code path so `/graph` UI ranking matches what the agent sees.
10
+ *
11
+ * QUERY --> EMBED --> VECTOR SEARCH (per index) --> ┐
12
+ * │ ├--> MERGE --> EXPAND --> RESULTS
13
+ * └--> BM25 FULL-TEXT SEARCH -------------------> ┘
14
+ *
15
+ * Hybrid merge: normalise BM25 scores to [0,1] via min-max, then combine
16
+ * combined = 0.7 * vector_score + 0.3 * normalised_bm25_score
17
+ * Dedup by nodeId, keeping the higher combined score.
18
+ *
19
+ * Trashed nodes (`:Trashed` label + legacy `deletedAt` property) are
20
+ * excluded from every path via `notTrashed()` from `graph-trash`.
21
+ *
22
+ * The lib is STATELESS. Callers pass a `Session` and, for hybrid, an
23
+ * `embed` function. This keeps the lib independent of Ollama config and
24
+ * free of circular imports against each caller's own getSession/embed
25
+ * (memory MCP and the Hono ui build each have their own copies for
26
+ * cross-build-boundary reasons — see neo4j-store.ts:68 comment).
27
+ *
28
+ * Log emission is the caller's responsibility — the lib throws or returns
29
+ * `mode` in the result so the caller can format its own `[graph-search]`
30
+ * line with the prefix and fields it wants.
31
+ */
32
+
33
+ import { int, type Session } from "neo4j-driver";
34
+ import { notTrashed } from "../../graph-trash/dist/index.js";
35
+
36
+ const VECTOR_WEIGHT = 0.7;
37
+ const BM25_WEIGHT = 0.3;
38
+ const FULLTEXT_INDEX_NAME = "knowledge_fulltext";
39
+
40
+ export interface SearchHit {
41
+ nodeId: string;
42
+ labels: string[];
43
+ properties: Record<string, unknown>;
44
+ score: number;
45
+ }
46
+
47
+ export interface SearchResult extends SearchHit {
48
+ related: Array<{
49
+ relationship: string;
50
+ direction: string;
51
+ labels: string[];
52
+ properties: Record<string, unknown>;
53
+ }>;
54
+ }
55
+
56
+ export interface ScoredNode {
57
+ nodeId: string;
58
+ labels: string[];
59
+ properties: Record<string, unknown>;
60
+ vectorScore: number;
61
+ bm25Score: number;
62
+ }
63
+
64
+ export type SearchMode = "hybrid" | "bm25";
65
+
66
+ export interface Bm25OnlyParams {
67
+ query: string;
68
+ accountId?: string;
69
+ limit: number;
70
+ allowedScopes?: string[];
71
+ agentSlug?: string;
72
+ keywords?: string[];
73
+ keywordMatch?: "any" | "all";
74
+ }
75
+
76
+ export interface HybridParams extends Bm25OnlyParams {
77
+ labels?: string[];
78
+ expandHops?: number;
79
+ keywordSubscriptions?: string[];
80
+ /**
81
+ * When true, a failing `embed()` does NOT throw — the lib falls back to
82
+ * `bm25Only()` and returns `mode: "bm25"`. Admin-route callers set this
83
+ * true so Ollama-down degrades to BM25-only; MCP tool callers leave it
84
+ * false so embed failure surfaces loudly.
85
+ */
86
+ degradeOnEmbedFailure?: boolean;
87
+ }
88
+
89
+ export interface HybridResponse {
90
+ mode: SearchMode;
91
+ results: SearchResult[];
92
+ /** Populated when degradeOnEmbedFailure fired. Caller logs it. */
93
+ embedError?: string;
94
+ }
95
+
96
+ export type EmbedFn = (text: string) => Promise<number[]>;
97
+
98
+ /**
99
+ * Lucene-escape special characters per Neo4j's fulltext query grammar.
100
+ * `/[+\-&|!(){}[\]^"~*?:\\/]/g` — matches memory-search.ts:127 and
101
+ * neo4j-store.ts:2521 verbatim; consolidation preserves escape set.
102
+ */
103
+ export function escapeLucene(query: string): string {
104
+ return query.replace(/[+\-&|!(){}[\]^"~*?:\\/]/g, "\\$&");
105
+ }
106
+
107
+ /**
108
+ * Normalise BM25 scores to [0, 1] using min-max within the result set.
109
+ * If all scores are equal (or single result), returns 1.0 for all —
110
+ * the range-zero branch prevents divide-by-zero and keeps the combined
111
+ * score meaningful.
112
+ */
113
+ export function normaliseBm25Scores(scores: number[]): number[] {
114
+ if (scores.length === 0) return [];
115
+ const min = Math.min(...scores);
116
+ const max = Math.max(...scores);
117
+ const range = max - min;
118
+ if (range === 0) return scores.map(() => 1.0);
119
+ return scores.map((s) => (s - min) / range);
120
+ }
121
+
122
+ // Module-scope vector-index cache — discovered from Neo4j at first query.
123
+ // Per-process singleton matches memory-search.ts behaviour.
124
+ let indexCache: Map<string, string> | null = null;
125
+
126
+ export async function discoverIndexes(session: Session): Promise<Map<string, string>> {
127
+ if (indexCache) return indexCache;
128
+ const result = await session.run(
129
+ `SHOW INDEXES YIELD name, labelsOrTypes, type WHERE type = 'VECTOR' RETURN name, labelsOrTypes`
130
+ );
131
+ const fresh = new Map<string, string>();
132
+ for (const record of result.records) {
133
+ const name = record.get("name") as string;
134
+ const labels = record.get("labelsOrTypes") as string[];
135
+ for (const label of labels) fresh.set(label, name);
136
+ }
137
+ indexCache = fresh;
138
+ return fresh;
139
+ }
140
+
141
+ /** Clear the index cache — call after schema changes. */
142
+ export function clearIndexCache(): void {
143
+ indexCache = null;
144
+ }
145
+
146
+ interface KeywordFilter {
147
+ clause: string;
148
+ params: Record<string, unknown>;
149
+ }
150
+
151
+ function buildKeywordFilter(
152
+ keywords: string[] | undefined,
153
+ keywordMatch: "any" | "all" = "any",
154
+ ): KeywordFilter | undefined {
155
+ if (!keywords || keywords.length === 0) return undefined;
156
+ const fn = keywordMatch === "all" ? "ALL" : "ANY";
157
+ return {
158
+ clause: `AND (node.keywords IS NULL OR ${fn}(kw IN $keywords WHERE kw IN node.keywords))`,
159
+ params: { keywords: keywords.map((k) => k.toLowerCase().trim()) },
160
+ };
161
+ }
162
+
163
+ /**
164
+ * BM25 full-text search against the `knowledge_fulltext` index.
165
+ * Returns [] when the index doesn't exist — matches memory-search.ts
166
+ * graceful-fallback semantics so a fresh account with no documents
167
+ * doesn't 500 the caller.
168
+ */
169
+ export async function bm25Only(
170
+ session: Session,
171
+ params: Bm25OnlyParams,
172
+ ): Promise<SearchHit[]> {
173
+ const { query, accountId, limit, allowedScopes, agentSlug, keywords, keywordMatch } = params;
174
+ const scopeClause = allowedScopes
175
+ ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
176
+ : "";
177
+ const agentClause = agentSlug
178
+ ? "AND node.agents IS NOT NULL AND $agentSlug IN node.agents"
179
+ : "";
180
+ const keywordFilter = buildKeywordFilter(keywords, keywordMatch);
181
+ const kwClause = keywordFilter?.clause ?? "";
182
+ const escaped = escapeLucene(query);
183
+
184
+ try {
185
+ const result = await session.run(
186
+ `CALL db.index.fulltext.queryNodes($indexName, $query)
187
+ YIELD node, score
188
+ WHERE node.accountId = $accountId
189
+ ${scopeClause}
190
+ ${agentClause}
191
+ AND ${notTrashed("node")}
192
+ ${kwClause}
193
+ RETURN node, score, labels(node) AS nodeLabels, elementId(node) AS nodeId
194
+ ORDER BY score DESC
195
+ LIMIT $limit`,
196
+ {
197
+ indexName: FULLTEXT_INDEX_NAME,
198
+ query: escaped,
199
+ accountId,
200
+ limit: int(limit),
201
+ ...(allowedScopes ? { allowedScopes } : {}),
202
+ ...(agentSlug ? { agentSlug } : {}),
203
+ ...(keywordFilter?.params ?? {}),
204
+ },
205
+ );
206
+ return result.records.map((r) => {
207
+ const scoreRaw = r.get("score");
208
+ const score = typeof scoreRaw === "number" ? scoreRaw : Number(scoreRaw);
209
+ const node = r.get("node") as { properties: Record<string, unknown> };
210
+ return {
211
+ nodeId: r.get("nodeId") as string,
212
+ labels: r.get("nodeLabels") as string[],
213
+ properties: plainProperties(node.properties),
214
+ score,
215
+ };
216
+ });
217
+ } catch (err) {
218
+ const msg = err instanceof Error ? err.message : String(err);
219
+ if (msg.includes("index") || msg.includes("fulltext") || msg.includes("not found")) {
220
+ return [];
221
+ }
222
+ throw err;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Hybrid vector + BM25 search with graph expansion.
228
+ *
229
+ * Sequence:
230
+ * 1. embed(query) — may throw if Ollama is unreachable. When
231
+ * `degradeOnEmbedFailure=true`, caller receives bm25Only() result
232
+ * with `mode: "bm25"`; otherwise the throw propagates.
233
+ * 2. Vector search per label (one query per vector index discovered at
234
+ * boot). Nodes-by-label filter short-circuits when the requested
235
+ * labels have no index.
236
+ * 3. BM25 search on knowledge_fulltext — same filter semantics as
237
+ * vector half (scope/agent/keyword/trashed).
238
+ * 4. Keyword subscriptions (when set): BM25 per keyword + property
239
+ * lookup against `node.keywords` array. Both bypass the agentSlug
240
+ * filter — subscriptions are scope-inclusive by design (matches
241
+ * memory-search.ts comment L319).
242
+ * 5. Merge: 0.7*vector + 0.3*bm25_norm, dedup by nodeId, sort.
243
+ * 6. Graph expand (1 hop by default, 0 to skip) — notTrashed filter +
244
+ * scope + agent clauses mirrored.
245
+ */
246
+ export async function hybrid(
247
+ session: Session,
248
+ embed: EmbedFn,
249
+ params: HybridParams,
250
+ ): Promise<HybridResponse> {
251
+ const {
252
+ query,
253
+ labels,
254
+ accountId,
255
+ limit,
256
+ allowedScopes,
257
+ keywords,
258
+ keywordMatch = "any",
259
+ agentSlug,
260
+ keywordSubscriptions,
261
+ expandHops = 1,
262
+ degradeOnEmbedFailure = false,
263
+ } = params;
264
+
265
+ let queryEmbedding: number[];
266
+ try {
267
+ queryEmbedding = await embed(query);
268
+ } catch (err) {
269
+ if (!degradeOnEmbedFailure) throw err;
270
+ const msg = err instanceof Error ? err.message : String(err);
271
+ const bm25Hits = await bm25Only(session, params);
272
+ const results: SearchResult[] = bm25Hits.map((h) => ({ ...h, related: [] }));
273
+ return { mode: "bm25", results, embedError: msg };
274
+ }
275
+
276
+ const labelToIndex = await discoverIndexes(session);
277
+
278
+ const keywordFilter = buildKeywordFilter(keywords, keywordMatch);
279
+ const keywordClause = keywordFilter?.clause ?? "";
280
+ const keywordParams = keywordFilter?.params ?? {};
281
+
282
+ const scoreMap = new Map<string, ScoredNode>();
283
+
284
+ // --- Vector search (per label) ---
285
+ let indexesToQuery: string[];
286
+ if (labels && labels.length > 0) {
287
+ indexesToQuery = labels
288
+ .map((l) => labelToIndex.get(l))
289
+ .filter((idx): idx is string => idx !== undefined);
290
+ if (indexesToQuery.length === 0) {
291
+ return { mode: "hybrid", results: [] };
292
+ }
293
+ } else {
294
+ indexesToQuery = [...new Set(labelToIndex.values())];
295
+ }
296
+
297
+ const scopeClause = allowedScopes
298
+ ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
299
+ : "";
300
+ const scopeParams = allowedScopes ? { allowedScopes } : {};
301
+ const agentClause = agentSlug
302
+ ? "AND node.agents IS NOT NULL AND $agentSlug IN node.agents"
303
+ : "";
304
+ const agentParams = agentSlug ? { agentSlug } : {};
305
+
306
+ for (const indexName of indexesToQuery) {
307
+ const vectorResult = await session.run(
308
+ `CALL db.index.vector.queryNodes($indexName, $limit, $embedding)
309
+ YIELD node, score
310
+ WHERE node.accountId = $accountId
311
+ ${scopeClause}
312
+ ${agentClause}
313
+ AND ${notTrashed("node")}
314
+ ${keywordClause}
315
+ RETURN node, score, labels(node) AS nodeLabels, elementId(node) AS nodeId
316
+ ORDER BY score DESC
317
+ LIMIT $limit`,
318
+ {
319
+ indexName,
320
+ embedding: queryEmbedding,
321
+ limit: int(limit),
322
+ accountId,
323
+ ...scopeParams,
324
+ ...agentParams,
325
+ ...keywordParams,
326
+ },
327
+ );
328
+ for (const record of vectorResult.records) {
329
+ const nodeId = record.get("nodeId") as string;
330
+ const scoreRaw = record.get("score");
331
+ const score = typeof scoreRaw === "number" ? scoreRaw : Number(scoreRaw);
332
+ const existing = scoreMap.get(nodeId);
333
+ if (existing) {
334
+ existing.vectorScore = Math.max(existing.vectorScore, score);
335
+ } else {
336
+ const node = record.get("node") as { properties: Record<string, unknown> };
337
+ scoreMap.set(nodeId, {
338
+ nodeId,
339
+ labels: record.get("nodeLabels") as string[],
340
+ properties: plainProperties(node.properties),
341
+ vectorScore: score,
342
+ bm25Score: 0,
343
+ });
344
+ }
345
+ }
346
+ }
347
+
348
+ // --- BM25 half ---
349
+ const bm25Hits = await bm25Only(session, params);
350
+ if (bm25Hits.length > 0) {
351
+ const rawScores = bm25Hits.map((h) => h.score);
352
+ const normalised = normaliseBm25Scores(rawScores);
353
+ for (let i = 0; i < bm25Hits.length; i++) {
354
+ mergeBm25Hit(scoreMap, bm25Hits[i], normalised[i]);
355
+ }
356
+ }
357
+
358
+ // --- Keyword subscriptions (scope-inclusive, agentSlug-bypassing) ---
359
+ if (keywordSubscriptions && keywordSubscriptions.length > 0) {
360
+ for (const kw of keywordSubscriptions) {
361
+ const kwHits = await bm25Only(session, {
362
+ query: kw,
363
+ accountId,
364
+ limit,
365
+ allowedScopes,
366
+ });
367
+ if (kwHits.length === 0) continue;
368
+ const rawScores = kwHits.map((h) => h.score);
369
+ const normalised = normaliseBm25Scores(rawScores);
370
+ for (let i = 0; i < kwHits.length; i++) {
371
+ mergeBm25Hit(scoreMap, kwHits[i], normalised[i]);
372
+ }
373
+ }
374
+
375
+ const propScopeClause = allowedScopes
376
+ ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
377
+ : "";
378
+ const propResult = await session.run(
379
+ `MATCH (node)
380
+ WHERE node.accountId = $accountId
381
+ AND ${notTrashed("node")}
382
+ AND node.keywords IS NOT NULL
383
+ AND ANY(kw IN $kwSubs WHERE ANY(nk IN node.keywords WHERE toLower(nk) = kw))
384
+ ${propScopeClause}
385
+ RETURN node, labels(node) AS nodeLabels, elementId(node) AS nodeId
386
+ LIMIT $limit`,
387
+ {
388
+ accountId,
389
+ kwSubs: keywordSubscriptions,
390
+ limit: int(limit),
391
+ ...(allowedScopes ? { allowedScopes } : {}),
392
+ },
393
+ );
394
+ for (const record of propResult.records) {
395
+ const nodeId = record.get("nodeId") as string;
396
+ const existing = scoreMap.get(nodeId);
397
+ if (existing) {
398
+ existing.bm25Score = Math.max(existing.bm25Score, 1.0);
399
+ } else {
400
+ const node = record.get("node") as { properties: Record<string, unknown> };
401
+ scoreMap.set(nodeId, {
402
+ nodeId,
403
+ labels: record.get("nodeLabels") as string[],
404
+ properties: plainProperties(node.properties),
405
+ vectorScore: 0,
406
+ bm25Score: 1.0,
407
+ });
408
+ }
409
+ }
410
+ }
411
+
412
+ // --- Merge & rank ---
413
+ const merged = [...scoreMap.values()]
414
+ .map((node) => ({
415
+ ...node,
416
+ combinedScore: VECTOR_WEIGHT * node.vectorScore + BM25_WEIGHT * node.bm25Score,
417
+ }))
418
+ .sort((a, b) => b.combinedScore - a.combinedScore)
419
+ .slice(0, limit);
420
+
421
+ // --- Graph expand ---
422
+ const results: SearchResult[] = [];
423
+ for (const node of merged) {
424
+ const result: SearchResult = {
425
+ nodeId: node.nodeId,
426
+ labels: node.labels,
427
+ properties: node.properties,
428
+ score: node.combinedScore,
429
+ related: [],
430
+ };
431
+ if (expandHops > 0) {
432
+ const expandScopeClause = allowedScopes
433
+ ? "AND (related.scope IS NULL OR related.scope IN $allowedScopes)"
434
+ : "";
435
+ const expandAgentClause = agentSlug
436
+ ? "AND (related.agents IS NULL OR $agentSlug IN related.agents)"
437
+ : "";
438
+ const expandResult = await session.run(
439
+ `MATCH (n)-[r]-(related)
440
+ WHERE elementId(n) = $nodeId
441
+ AND ${notTrashed("related")}
442
+ ${expandScopeClause}
443
+ ${expandAgentClause}
444
+ RETURN type(r) AS relType,
445
+ CASE WHEN startNode(r) = n THEN 'outgoing' ELSE 'incoming' END AS direction,
446
+ labels(related) AS relatedLabels,
447
+ related
448
+ LIMIT 20`,
449
+ { nodeId: node.nodeId, ...scopeParams, ...agentParams },
450
+ );
451
+ for (const rec of expandResult.records) {
452
+ const related = rec.get("related") as { properties: Record<string, unknown> };
453
+ result.related.push({
454
+ relationship: rec.get("relType") as string,
455
+ direction: rec.get("direction") as string,
456
+ labels: rec.get("relatedLabels") as string[],
457
+ properties: plainProperties(related.properties),
458
+ });
459
+ }
460
+ }
461
+ results.push(result);
462
+ }
463
+
464
+ return { mode: "hybrid", results };
465
+ }
466
+
467
+ function mergeBm25Hit(
468
+ map: Map<string, ScoredNode>,
469
+ hit: SearchHit,
470
+ normalisedScore: number,
471
+ ): void {
472
+ const existing = map.get(hit.nodeId);
473
+ if (existing) {
474
+ existing.bm25Score = Math.max(existing.bm25Score, normalisedScore);
475
+ } else {
476
+ map.set(hit.nodeId, {
477
+ nodeId: hit.nodeId,
478
+ labels: hit.labels,
479
+ properties: hit.properties,
480
+ vectorScore: 0,
481
+ bm25Score: normalisedScore,
482
+ });
483
+ }
484
+ }
485
+
486
+ /** Strip `embedding` and unwrap Neo4j Integers into JS numbers. */
487
+ function plainProperties(properties: Record<string, unknown>): Record<string, unknown> {
488
+ const plain: Record<string, unknown> = {};
489
+ for (const [key, value] of Object.entries(properties)) {
490
+ if (key === "embedding") continue;
491
+ if (value && typeof value === "object" && "toNumber" in value) {
492
+ plain[key] = (value as { toNumber(): number }).toNumber();
493
+ } else {
494
+ plain[key] = value;
495
+ }
496
+ }
497
+ return plain;
498
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/__tests__"]
9
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ globals: false,
7
+ include: ['src/__tests__/**/*.test.ts'],
8
+ },
9
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Write doctrine (Task 673): a node without at least one adjacency is noise,
3
+ * not knowledge. `writeNodeWithEdges` is the single primitive every graph
4
+ * writer should call; it rejects zero-relationship writes, verifies every
5
+ * relationship target resolves before the node is created, and commits the
6
+ * node + all edges in one managed transaction. Provenance is stamped on the
7
+ * node as flattened `createdBy*` properties (Neo4j does not persist nested
8
+ * maps, and flat props are queryable — `MATCH (n) WHERE n.createdBySession
9
+ * = $id RETURN n` is the forensic entry point).
10
+ *
11
+ * Rejection paths (every one emits a stderr log the admin server pipes to
12
+ * server.log, so orphan pressure is visible per-write not just in the
13
+ * hourly [graph-health] signal):
14
+ * - zero relationships → `[graph-write] reject reason=zero-relationships`
15
+ * - unresolved target id → `[graph-write] reject reason=unresolved-target`
16
+ *
17
+ * The `createdBy.agent` field is advisory, not authoritative — it is sourced
18
+ * from the MCP server's AGENT_SLUG env var, which is set by the trusted
19
+ * spawning code (admin server / workflow runner). A misconfigured spawner
20
+ * will write "unknown"; that's the signal something is wrong. Do not use
21
+ * this field for access control — it is forensic, not a security boundary.
22
+ */
23
+ import type { Session } from "neo4j-driver";
24
+ export interface GraphRelationship {
25
+ type: string;
26
+ direction: "outgoing" | "incoming";
27
+ targetNodeId: string;
28
+ }
29
+ export interface CreatedBy {
30
+ /** Agent slug (e.g. "maxy-admin", "whatsapp-public") for LLM-tool writes. */
31
+ agent?: string;
32
+ /** Session correlation id — same string across every node written in a conversation turn. */
33
+ session?: string;
34
+ /** MCP tool name ("memory-write", "contact-create", ...) for LLM-tool writes. */
35
+ tool?: string;
36
+ /** Subsystem name ("workflow-execute", "persist-tool-call", ...) for system writes. */
37
+ source?: string;
38
+ }
39
+ export interface WriteNodeWithEdgesParams {
40
+ session: Session;
41
+ labels: string[];
42
+ props: Record<string, unknown>;
43
+ /** At least one relationship is required — zero-rel writes are rejected. */
44
+ relationships: GraphRelationship[];
45
+ createdBy: CreatedBy;
46
+ }
47
+ export interface WriteNodeResult {
48
+ nodeId: string;
49
+ labels: string[];
50
+ edgesCreated: number;
51
+ }
52
+ /** Stamp flattened provenance into node properties. */
53
+ export declare function stampCreatedBy(props: Record<string, unknown>, createdBy: CreatedBy): Record<string, unknown>;
54
+ /**
55
+ * Enforce the write doctrine: node + ≥1 edge, transactional, provenance stamped.
56
+ * The label and relationship type strings are interpolated into Cypher — the
57
+ * caller must constrain them via Zod/schema before calling. Backticks are
58
+ * stripped to defeat the only Cypher escape char.
59
+ */
60
+ export declare function writeNodeWithEdges(params: WriteNodeWithEdgesParams): Promise<WriteNodeResult>;
61
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAE5C,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,UAAU,GAAG,UAAU,CAAC;IACnC,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,SAAS;IACxB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6FAA6F;IAC7F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,uFAAuF;IACvF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,4EAA4E;IAC5E,aAAa,EAAE,iBAAiB,EAAE,CAAC;IACnC,SAAS,EAAE,SAAS,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,uDAAuD;AACvD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,SAAS,EAAE,SAAS,GACnB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,wBAAwB,GAC/B,OAAO,CAAC,eAAe,CAAC,CAuF1B"}
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ /**
3
+ * Write doctrine (Task 673): a node without at least one adjacency is noise,
4
+ * not knowledge. `writeNodeWithEdges` is the single primitive every graph
5
+ * writer should call; it rejects zero-relationship writes, verifies every
6
+ * relationship target resolves before the node is created, and commits the
7
+ * node + all edges in one managed transaction. Provenance is stamped on the
8
+ * node as flattened `createdBy*` properties (Neo4j does not persist nested
9
+ * maps, and flat props are queryable — `MATCH (n) WHERE n.createdBySession
10
+ * = $id RETURN n` is the forensic entry point).
11
+ *
12
+ * Rejection paths (every one emits a stderr log the admin server pipes to
13
+ * server.log, so orphan pressure is visible per-write not just in the
14
+ * hourly [graph-health] signal):
15
+ * - zero relationships → `[graph-write] reject reason=zero-relationships`
16
+ * - unresolved target id → `[graph-write] reject reason=unresolved-target`
17
+ *
18
+ * The `createdBy.agent` field is advisory, not authoritative — it is sourced
19
+ * from the MCP server's AGENT_SLUG env var, which is set by the trusted
20
+ * spawning code (admin server / workflow runner). A misconfigured spawner
21
+ * will write "unknown"; that's the signal something is wrong. Do not use
22
+ * this field for access control — it is forensic, not a security boundary.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.stampCreatedBy = stampCreatedBy;
26
+ exports.writeNodeWithEdges = writeNodeWithEdges;
27
+ /** Stamp flattened provenance into node properties. */
28
+ function stampCreatedBy(props, createdBy) {
29
+ return {
30
+ ...props,
31
+ createdByAgent: createdBy.agent ?? "unknown",
32
+ createdBySession: createdBy.session ?? "unknown",
33
+ createdByTool: createdBy.tool ?? null,
34
+ createdBySource: createdBy.source ?? null,
35
+ };
36
+ }
37
+ /**
38
+ * Enforce the write doctrine: node + ≥1 edge, transactional, provenance stamped.
39
+ * The label and relationship type strings are interpolated into Cypher — the
40
+ * caller must constrain them via Zod/schema before calling. Backticks are
41
+ * stripped to defeat the only Cypher escape char.
42
+ */
43
+ async function writeNodeWithEdges(params) {
44
+ const { session, labels, props, relationships, createdBy } = params;
45
+ const agentLabel = createdBy.agent ?? createdBy.source ?? "unknown";
46
+ const labelCsv = labels.join(",");
47
+ if (!relationships || relationships.length < 1) {
48
+ process.stderr.write(`[graph-write] reject reason=zero-relationships labels=${labelCsv} agent=${agentLabel}\n`);
49
+ throw new Error("Write doctrine violated: a node must be created with at least one relationship. See .docs/neo4j.md (Write doctrine).");
50
+ }
51
+ const labelStr = labels.map((l) => `\`${l.replace(/`/g, "")}\``).join(":");
52
+ const nodeProps = stampCreatedBy(props, createdBy);
53
+ return await session.executeWrite(async (tx) => {
54
+ const targetIds = relationships.map((r) => r.targetNodeId);
55
+ const check = await tx.run(`UNWIND $ids AS id MATCH (t) WHERE elementId(t) = id RETURN count(DISTINCT t) AS found`, { ids: targetIds });
56
+ const found = check.records[0].get("found").toNumber();
57
+ const uniqueRequested = new Set(targetIds).size;
58
+ if (found !== uniqueRequested) {
59
+ process.stderr.write(`[graph-write] reject reason=unresolved-target labels=${labelCsv} agent=${agentLabel} requested=${uniqueRequested} found=${found}\n`);
60
+ throw new Error(`Write doctrine violated: ${uniqueRequested - found} of ${uniqueRequested} relationship target(s) did not resolve (elementId mismatch). No node created.`);
61
+ }
62
+ const nodeRes = await tx.run(`CREATE (n:${labelStr} $props) RETURN elementId(n) AS nodeId, labels(n) AS nodeLabels`, { props: nodeProps });
63
+ const nodeId = nodeRes.records[0].get("nodeId");
64
+ const nodeLabels = nodeRes.records[0].get("nodeLabels");
65
+ // Neo4j Community's default isolation is read-committed (not snapshot)
66
+ // — a target elementId that resolved during the pre-check can be
67
+ // deleted by a concurrent transaction before the per-edge CREATE below
68
+ // runs. If that happens, `MATCH (a), (b)` returns 0 rows and the CREATE
69
+ // quietly produces no edge. Per-edge counter inspection catches the
70
+ // race: any zero-result CREATE throws inside the transaction callback,
71
+ // so `executeWrite` rolls the node back — no silent-orphan path.
72
+ let edgesCreated = 0;
73
+ for (const rel of relationships) {
74
+ const type = rel.type.replace(/`/g, "");
75
+ const q = rel.direction === "outgoing"
76
+ ? `MATCH (a), (b) WHERE elementId(a) = $from AND elementId(b) = $to CREATE (a)-[:\`${type}\`]->(b)`
77
+ : `MATCH (a), (b) WHERE elementId(a) = $from AND elementId(b) = $to CREATE (b)-[:\`${type}\`]->(a)`;
78
+ const r = await tx.run(q, { from: nodeId, to: rel.targetNodeId });
79
+ const created = r.summary.counters.updates().relationshipsCreated;
80
+ if (created === 0) {
81
+ process.stderr.write(`[graph-write] reject reason=unresolved-target-on-create labels=${labelCsv} agent=${agentLabel} relType=${rel.type} targetId=${rel.targetNodeId}\n`);
82
+ throw new Error(`Write doctrine violated: relationship CREATE to target ${rel.targetNodeId} produced 0 edges (target likely deleted concurrently after pre-check). Transaction rolled back.`);
83
+ }
84
+ edgesCreated += created;
85
+ }
86
+ if (edgesCreated !== relationships.length) {
87
+ // Defensive: should be unreachable given the per-edge check above.
88
+ // The rollback throws loudly so regression shows rather than a
89
+ // silent commit with edgesCreated < requested.
90
+ process.stderr.write(`[graph-write] reject reason=edge-count-mismatch labels=${labelCsv} agent=${agentLabel} requested=${relationships.length} created=${edgesCreated}\n`);
91
+ throw new Error(`Write doctrine violated: expected ${relationships.length} edges, created ${edgesCreated}. Transaction rolled back.`);
92
+ }
93
+ process.stderr.write(`[graph-write] accepted labels=${labelCsv} edges=${edgesCreated} createdByAgent=${createdBy.agent ?? "unknown"} createdByTool=${createdBy.tool ?? createdBy.source ?? "unknown"}\n`);
94
+ return { nodeId, labels: nodeLabels, edgesCreated };
95
+ });
96
+ }
97
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}