@hames-ai/connectors 0.1.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,437 @@
1
+ /**
2
+ * Graph Data Transformation Utilities
3
+ *
4
+ * Transforms Neo4j query results into Cytoscape.js-compatible format
5
+ * for graph visualization in the UI
6
+ */
7
+
8
+ /**
9
+ * Local stand-in for cytoscape's `ElementDefinition` — the fields this
10
+ * module's output actually touches (`data` and `classes`). Declared here
11
+ * rather than imported so a consumer who wants these helpers is never forced
12
+ * to install a graph rendering library (these packages ship raw TypeScript,
13
+ * so a consumer's `tsc` compiles this file directly and would report TS2307
14
+ * on an unresolved import). Structurally compatible with cytoscape's own
15
+ * `ElementDefinition` (its data is `{ id?: string; [key: string]: any }`),
16
+ * so a consumer holding the real type can consume ours unchanged.
17
+ */
18
+ export interface ElementDefinition {
19
+ /** Element payload — cytoscape allows arbitrary extra fields here. */
20
+ data: { [key: string]: unknown; id?: string }
21
+ /** Which collection the element belongs to (cytoscape: an explicit group always wins over its inference). */
22
+ group?: 'nodes' | 'edges'
23
+ /** A space-separated list of class names, for cytoscape styling selectors. */
24
+ classes?: string
25
+ }
26
+
27
+ // ============================================================================
28
+ // Type Definitions
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Neo4j Node structure from query results
33
+ */
34
+ export interface Neo4jNode {
35
+ identity: string | number
36
+ labels: string[]
37
+ properties: Record<string, unknown>
38
+ elementId?: string // Neo4j 5.x element ID
39
+ }
40
+
41
+ /**
42
+ * Neo4j Relationship structure from query results
43
+ */
44
+ export interface Neo4jRelationship {
45
+ identity: string | number
46
+ type: string
47
+ start: string | number
48
+ end: string | number
49
+ startNode?: string // Neo4j 5.x element ID (deprecated naming)
50
+ endNode?: string // Neo4j 5.x element ID (deprecated naming)
51
+ startNodeElementId?: string // Neo4j 5.x element ID
52
+ endNodeElementId?: string // Neo4j 5.x element ID
53
+ properties: Record<string, unknown>
54
+ elementId?: string
55
+ }
56
+
57
+ /**
58
+ * Neo4j query result structure
59
+ */
60
+ export interface Neo4jQueryResult {
61
+ nodes?: Neo4jNode[]
62
+ relationships?: Neo4jRelationship[]
63
+ records?: unknown[] // Raw Neo4j records
64
+ }
65
+
66
+ // ============================================================================
67
+ // Transform Functions
68
+ // ============================================================================
69
+
70
+ /**
71
+ * Transform Neo4j nodes and relationships to Cytoscape elements
72
+ *
73
+ * @param nodes - Array of Neo4j nodes
74
+ * @param relationships - Array of Neo4j relationships
75
+ * @returns Array of Cytoscape element definitions
76
+ */
77
+ export function transformNeo4jToCytoscape(
78
+ nodes: Neo4jNode[],
79
+ relationships: Neo4jRelationship[],
80
+ ): ElementDefinition[] {
81
+ const elements: ElementDefinition[] = []
82
+
83
+ // Transform nodes
84
+ for (const node of nodes) {
85
+ elements.push(transformNode(node))
86
+ }
87
+
88
+ // Transform relationships
89
+ for (const rel of relationships) {
90
+ elements.push(transformRelationship(rel))
91
+ }
92
+
93
+ return elements
94
+ }
95
+
96
+ /**
97
+ * Transform a single Neo4j node to Cytoscape node
98
+ */
99
+ function transformNode(node: Neo4jNode): ElementDefinition {
100
+ // Use elementId if available (Neo4j 5.x), otherwise use identity
101
+ const id = node.elementId?.toString() || node.identity.toString()
102
+
103
+ // Determine node label (prefer name, title, or first label)
104
+ const label = getNodeLabel(node)
105
+
106
+ return {
107
+ data: {
108
+ id,
109
+ label,
110
+ type: node.labels.join(','),
111
+ labels: node.labels,
112
+ properties: node.properties,
113
+ // Store original identity for reference
114
+ neo4jId: node.identity,
115
+ },
116
+ classes: node.labels.map((l) => `label-${l.toLowerCase()}`).join(' '),
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Transform a single Neo4j relationship to Cytoscape edge
122
+ */
123
+ function transformRelationship(rel: Neo4jRelationship): ElementDefinition {
124
+ // Use elementId if available (Neo4j 5.x), otherwise use identity
125
+ const id = rel.elementId?.toString() || rel.identity.toString()
126
+
127
+ // For source/target, we need to match the node IDs
128
+ // Neo4j 5.x uses startNodeElementId/endNodeElementId (full element IDs)
129
+ // Fall back to startNode/start for older formats
130
+ const source =
131
+ rel.startNodeElementId?.toString() || rel.startNode?.toString() || rel.start.toString()
132
+ const target = rel.endNodeElementId?.toString() || rel.endNode?.toString() || rel.end.toString()
133
+
134
+ console.log('[transform] Relationship source:', source, 'target:', target)
135
+
136
+ return {
137
+ data: {
138
+ id,
139
+ source,
140
+ target,
141
+ label: formatRelationshipLabel(rel.type),
142
+ type: rel.type,
143
+ properties: rel.properties,
144
+ // Store original identity for reference
145
+ neo4jId: rel.identity,
146
+ },
147
+ classes: `rel-${rel.type.toLowerCase().replace(/_/g, '-')}`,
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Extract a meaningful label from a Neo4j node
153
+ * Prefers: name > title > id > first property > first label
154
+ */
155
+ function getNodeLabel(node: Neo4jNode): string {
156
+ const props = node.properties
157
+
158
+ // Check common label properties
159
+ if (props.name) return String(props.name)
160
+ if (props.title) return String(props.title)
161
+ if (props.id) return String(props.id)
162
+ if (props.label) return String(props.label)
163
+
164
+ // Use first string property value
165
+ for (const [_key, value] of Object.entries(props)) {
166
+ if (typeof value === 'string' && value.length > 0 && value.length < 50) {
167
+ return value
168
+ }
169
+ }
170
+
171
+ // Fall back to first label or 'Node'
172
+ return node.labels[0] || 'Node'
173
+ }
174
+
175
+ /**
176
+ * Format relationship type for display
177
+ * Converts SNAKE_CASE to Title Case
178
+ */
179
+ function formatRelationshipLabel(type: string): string {
180
+ return type
181
+ .split('_')
182
+ .map((word) => word.charAt(0) + word.slice(1).toLowerCase())
183
+ .join(' ')
184
+ }
185
+
186
+ // ============================================================================
187
+ // Result Parsers
188
+ // ============================================================================
189
+
190
+ /**
191
+ * Parse Neo4j query results from MCP response
192
+ * Handles different result formats from neo4j-cypher MCP server
193
+ */
194
+ export function parseNeo4jResults(mcpResponse: unknown): Neo4jQueryResult {
195
+ console.log('[transform] parseNeo4jResults called')
196
+
197
+ // Type guard for response object
198
+ const response = mcpResponse as Record<string, unknown>
199
+
200
+ // If response is already in expected format
201
+ if (response?.nodes && response?.relationships) {
202
+ console.log('[transform] Response already in expected format')
203
+ return response as unknown as Neo4jQueryResult
204
+ }
205
+
206
+ // If response has records array
207
+ if (response?.records && Array.isArray(response.records)) {
208
+ console.log('[transform] Found records array, length:', response.records.length)
209
+ if (response.records.length > 0) {
210
+ // Log the structure of the first record
211
+ const firstRecord = response.records[0]
212
+ console.log('[transform] First record type:', typeof firstRecord)
213
+ console.log('[transform] First record constructor:', firstRecord?.constructor?.name)
214
+ // If it has keys() method (Neo4j Record), log keys
215
+ if (firstRecord && typeof firstRecord.keys === 'function') {
216
+ console.log('[transform] Record keys:', firstRecord.keys())
217
+ }
218
+ if (firstRecord && typeof firstRecord.toObject === 'function') {
219
+ console.log('[transform] Record toObject:', JSON.stringify(firstRecord.toObject(), null, 2))
220
+ }
221
+ }
222
+ return extractNodesAndRelsFromRecords(response.records)
223
+ }
224
+
225
+ // If response is a simple array
226
+ if (Array.isArray(mcpResponse)) {
227
+ console.log('[transform] Response is array, length:', mcpResponse.length)
228
+ return extractNodesAndRelsFromRecords(mcpResponse)
229
+ }
230
+
231
+ // Empty result
232
+ console.log('[transform] No valid format found, returning empty')
233
+ return { nodes: [], relationships: [] }
234
+ }
235
+
236
+ /**
237
+ * Extract nodes and relationships from Neo4j records
238
+ * Handles the case where MCP returns raw record arrays
239
+ */
240
+ function extractNodesAndRelsFromRecords(records: unknown[]): Neo4jQueryResult {
241
+ console.log('[transform] extractNodesAndRelsFromRecords called with', records.length, 'records')
242
+
243
+ const nodes = new Map<string, Neo4jNode>()
244
+ const relationships: Neo4jRelationship[] = []
245
+
246
+ for (const record of records) {
247
+ // Neo4j Record objects have a toObject() method
248
+ const recordObj =
249
+ record && typeof (record as { toObject?: () => unknown }).toObject === 'function'
250
+ ? (record as { toObject: () => unknown }).toObject()
251
+ : record
252
+
253
+ // Record can be an object with keys or an array
254
+ const values = Array.isArray(recordObj)
255
+ ? recordObj
256
+ : Object.values(recordObj as Record<string, unknown>)
257
+
258
+ console.log('[transform] Processing record with', values.length, 'values')
259
+
260
+ for (const value of values) {
261
+ console.log('[transform] Checking value:', typeof value, value?.constructor?.name)
262
+
263
+ if (isNode(value)) {
264
+ const id = value.elementId?.toString() || value.identity.toString()
265
+ console.log('[transform] Found node:', id)
266
+ nodes.set(id, value)
267
+ } else if (isRelationship(value)) {
268
+ console.log('[transform] Found relationship')
269
+ relationships.push(value)
270
+
271
+ // Also extract connected nodes if present
272
+ if (value.start && value.end) {
273
+ // These are node references - we might get the full nodes separately
274
+ }
275
+ } else if (isPath(value)) {
276
+ console.log('[transform] Found path')
277
+ // Path contains nodes and relationships
278
+ extractFromPath(value, nodes, relationships)
279
+ } else {
280
+ console.log('[transform] Value not recognized as node/rel/path')
281
+ }
282
+ }
283
+ }
284
+
285
+ console.log(
286
+ '[transform] Extracted',
287
+ nodes.size,
288
+ 'nodes and',
289
+ relationships.length,
290
+ 'relationships',
291
+ )
292
+
293
+ return {
294
+ nodes: Array.from(nodes.values()),
295
+ relationships,
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Check if value is a Neo4j node
301
+ */
302
+ function isNode(value: unknown): value is Neo4jNode {
303
+ const v = value as Record<string, unknown>
304
+ return !!v && (v.labels !== undefined || v.label !== undefined) && v.properties !== undefined
305
+ }
306
+
307
+ /**
308
+ * Check if value is a Neo4j relationship
309
+ */
310
+ function isRelationship(value: unknown): value is Neo4jRelationship {
311
+ const v = value as Record<string, unknown>
312
+ return (
313
+ !!v &&
314
+ v.type !== undefined &&
315
+ (v.start !== undefined || v.startNode !== undefined) &&
316
+ (v.end !== undefined || v.endNode !== undefined)
317
+ )
318
+ }
319
+
320
+ /**
321
+ * Check if value is a Neo4j path
322
+ */
323
+ function isPath(value: unknown): boolean {
324
+ const v = value as Record<string, unknown>
325
+ return !!v && v.segments !== undefined
326
+ }
327
+
328
+ /**
329
+ * Extract nodes and relationships from a Neo4j path
330
+ */
331
+ interface Neo4jPath {
332
+ start?: Neo4jNode
333
+ end?: Neo4jNode
334
+ segments?: Array<{
335
+ start?: Neo4jNode
336
+ end?: Neo4jNode
337
+ relationship?: Neo4jRelationship
338
+ }>
339
+ }
340
+
341
+ function extractFromPath(
342
+ path: Neo4jPath,
343
+ nodes: Map<string, Neo4jNode>,
344
+ relationships: Neo4jRelationship[],
345
+ ): void {
346
+ if (path.start && isNode(path.start)) {
347
+ const id = path.start.elementId?.toString() || path.start.identity.toString()
348
+ nodes.set(id, path.start)
349
+ }
350
+
351
+ if (path.end && isNode(path.end)) {
352
+ const id = path.end.elementId?.toString() || path.end.identity.toString()
353
+ nodes.set(id, path.end)
354
+ }
355
+
356
+ if (path.segments && Array.isArray(path.segments)) {
357
+ for (const segment of path.segments) {
358
+ if (segment.start && isNode(segment.start)) {
359
+ const id = segment.start.elementId?.toString() || segment.start.identity.toString()
360
+ nodes.set(id, segment.start)
361
+ }
362
+ if (segment.end && isNode(segment.end)) {
363
+ const id = segment.end.elementId?.toString() || segment.end.identity.toString()
364
+ nodes.set(id, segment.end)
365
+ }
366
+ if (segment.relationship && isRelationship(segment.relationship)) {
367
+ relationships.push(segment.relationship)
368
+ }
369
+ }
370
+ }
371
+ }
372
+
373
+ // ============================================================================
374
+ // Utility Functions
375
+ // ============================================================================
376
+
377
+ /**
378
+ * Create a sample graph for testing
379
+ * Useful for development and demonstrations
380
+ */
381
+ export function createSampleGraph(): ElementDefinition[] {
382
+ return [
383
+ // Nodes
384
+ {
385
+ data: {
386
+ id: '1',
387
+ label: 'Alice',
388
+ type: 'Person',
389
+ properties: { name: 'Alice', age: 30 },
390
+ },
391
+ },
392
+ {
393
+ data: {
394
+ id: '2',
395
+ label: 'Bob',
396
+ type: 'Person',
397
+ properties: { name: 'Bob', age: 25 },
398
+ },
399
+ },
400
+ {
401
+ data: {
402
+ id: '3',
403
+ label: 'Company X',
404
+ type: 'Company',
405
+ properties: { name: 'Company X', founded: 2020 },
406
+ },
407
+ },
408
+ // Relationships
409
+ {
410
+ data: {
411
+ id: 'e1',
412
+ source: '1',
413
+ target: '2',
414
+ label: 'Knows',
415
+ type: 'KNOWS',
416
+ },
417
+ },
418
+ {
419
+ data: {
420
+ id: 'e2',
421
+ source: '1',
422
+ target: '3',
423
+ label: 'Works At',
424
+ type: 'WORKS_AT',
425
+ },
426
+ },
427
+ {
428
+ data: {
429
+ id: 'e3',
430
+ source: '2',
431
+ target: '3',
432
+ label: 'Works At',
433
+ type: 'WORKS_AT',
434
+ },
435
+ },
436
+ ]
437
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@hames-ai/connectors",
3
+ "version": "0.1.0",
4
+ "description": "The connectors companion for @hames-ai/harness-patterns — Microsoft Graph app-side tools, the Neo4j non-agentic layer, and the MCP-gateway namespace catalog, all behind injected seams.",
5
+ "keywords": [
6
+ "connectors",
7
+ "neo4j",
8
+ "cypher",
9
+ "graph",
10
+ "mcp",
11
+ "tools",
12
+ "harness"
13
+ ],
14
+ "homepage": "https://github.com/mknw/hames-playground/tree/main/packages/connectors#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/mknw/hames-playground/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/mknw/hames-playground.git",
21
+ "directory": "packages/connectors"
22
+ },
23
+ "license": "MIT",
24
+ "author": {
25
+ "name": "Michael Accetto",
26
+ "url": "https://github.com/mknw"
27
+ },
28
+ "type": "module",
29
+ "main": "./index.ts",
30
+ "exports": {
31
+ ".": "./index.ts",
32
+ "./package.json": "./package.json",
33
+ "./neo4j": "./neo4j/index.ts",
34
+ "./*": "./*.ts"
35
+ },
36
+ "files": [
37
+ "*.ts",
38
+ "!vitest.config.ts",
39
+ "neo4j",
40
+ "app-tools",
41
+ "graph",
42
+ "LICENSE",
43
+ "README.md"
44
+ ],
45
+ "engines": {
46
+ "node": ">=22"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "dependencies": {
52
+ "neo4j-driver": "^6.0.1"
53
+ },
54
+ "peerDependencies": {
55
+ "@hames-ai/harness-patterns": "^0.1.0"
56
+ },
57
+ "devDependencies": {
58
+ "seroval": "~1.5.0",
59
+ "vitest": "^4.1.5",
60
+ "@hames-ai/harness-patterns": "^0.1.0"
61
+ },
62
+ "scripts": {
63
+ "test": "vitest run",
64
+ "test:watch": "vitest"
65
+ }
66
+ }