@pentatonic-ai/ai-agent-sdk 0.7.13 → 0.8.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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/packages/memory/openclaw-plugin/index.js +7 -0
  3. package/packages/memory/openclaw-plugin/openclaw.plugin.json +9 -1
  4. package/packages/memory/openclaw-plugin/package.json +1 -1
  5. package/packages/memory/src/__tests__/engine.test.js +142 -0
  6. package/packages/memory/src/engine.js +65 -0
  7. package/packages/memory-engine/compat/server.py +90 -5
  8. package/packages/memory-engine/docker-compose.yml +18 -8
  9. package/packages/memory-engine/engine/services/_shared/__init__.py +1 -0
  10. package/packages/memory-engine/engine/services/_shared/embed_provider.py +431 -0
  11. package/packages/memory-engine/engine/services/l2/Dockerfile +4 -2
  12. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +640 -81
  13. package/packages/memory-engine/engine/services/l4/Dockerfile +5 -1
  14. package/packages/memory-engine/engine/services/l4/server.py +19 -57
  15. package/packages/memory-engine/engine/services/l5/Dockerfile +3 -1
  16. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +24 -32
  17. package/packages/memory-engine/engine/services/l6/Dockerfile +3 -1
  18. package/packages/memory-engine/engine/services/l6/l6-document-store.py +24 -29
  19. package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +128 -0
  20. package/packages/memory-engine/tests/e2e_arena.sh +28 -4
  21. package/packages/memory-engine/tests/test_aggregate.py +333 -0
  22. package/packages/memory-engine/tests/test_arena_safety.py +232 -0
  23. package/packages/memory-engine/tests/test_channel_stat_reader.py +437 -0
  24. package/packages/memory-engine/tests/test_channel_stat_rollups.py +308 -0
  25. package/packages/memory-engine/tests/test_embed_provider.py +354 -0
  26. package/packages/memory-engine/tests/test_l3_arena_isolation.py +412 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.7.13",
3
+ "version": "0.8.1",
4
4
  "description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -500,6 +500,13 @@ export default {
500
500
  description: "Persistent, searchable memory with multi-signal retrieval and HyDE query expansion",
501
501
  kind: "context-engine",
502
502
 
503
+ // Tool contracts (names + parameter schemas) live in openclaw.plugin.json
504
+ // under the top-level "contracts.tools" key — that's what the openclaw
505
+ // loader reads at install time. The execute bodies are registered at
506
+ // runtime via api.registerTool(...) inside register() below; names and
507
+ // schemas there MUST match the manifest contracts or 5.7+ will refuse
508
+ // to register the tool.
509
+
503
510
  register(api) {
504
511
  const config = api.pluginConfig || api.config?.plugins?.entries?.["pentatonic-memory"]?.config || api.config || {};
505
512
  const hosted = !!(config.tes_endpoint && config.tes_api_key);
@@ -2,7 +2,7 @@
2
2
  "id": "pentatonic-memory",
3
3
  "name": "Pentatonic Memory",
4
4
  "description": "Persistent, searchable memory with multi-signal retrieval and HyDE query expansion. Local (Docker + Ollama) or hosted (Pentatonic TES).",
5
- "version": "0.5.3",
5
+ "version": "0.8.5",
6
6
  "kind": "context-engine",
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -75,5 +75,13 @@
75
75
  },
76
76
  "setup": {
77
77
  "description": "Set up persistent memory — local (Docker + Ollama, fully private) or hosted (Pentatonic TES, team-wide)."
78
+ },
79
+ "contracts": {
80
+ "tools": [
81
+ "pentatonic_memory_search",
82
+ "pentatonic_memory_store",
83
+ "pentatonic_memory_status",
84
+ "pentatonic_memory_setup"
85
+ ]
78
86
  }
79
87
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/openclaw-memory-plugin",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Pentatonic Memory plugin for OpenClaw — persistent, searchable memory with multi-signal retrieval and HyDE query expansion",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -3,6 +3,7 @@ import {
3
3
  fetchEngine,
4
4
  engineStore,
5
5
  engineSearch,
6
+ engineAggregate,
6
7
  engineForget,
7
8
  composeArena,
8
9
  composeArenas,
@@ -315,6 +316,147 @@ describe("engine HTTP client", () => {
315
316
  });
316
317
  });
317
318
 
319
+ describe("engineAggregate", () => {
320
+ it("posts to /aggregate with single arena (tenant-only when no userId)", async () => {
321
+ mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
322
+ await engineAggregate("https://e", {
323
+ clientId: "acme",
324
+ contactEmail: "alex@x.io",
325
+ });
326
+ const body = JSON.parse(calls[0].init.body);
327
+ expect(calls[0].url).toBe("https://e/aggregate");
328
+ expect(body).toEqual({
329
+ arena: "acme",
330
+ contact_email: "alex@x.io",
331
+ group_by: ["channel"],
332
+ });
333
+ // Aggregate is single-arena by design (cross-tenant aggregation
334
+ // would be a multi-tenancy violation), so no `arenas` list.
335
+ expect(body).not.toHaveProperty("arenas");
336
+ });
337
+
338
+ it("scopes to user-arena (clientId:userId) when userId is set", async () => {
339
+ mockOk({ arena: "acme:user-42", total: 0, last_seen: null, buckets: [] });
340
+ await engineAggregate("https://e", {
341
+ clientId: "acme",
342
+ userId: "user-42",
343
+ contactEmail: "alex@x.io",
344
+ });
345
+ const body = JSON.parse(calls[0].init.body);
346
+ expect(body.arena).toBe("acme:user-42");
347
+ });
348
+
349
+ it("forwards contact_name when supplied (resolves Person by name alias)", async () => {
350
+ mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
351
+ await engineAggregate("https://e", {
352
+ clientId: "acme",
353
+ contactName: "Alex Tong",
354
+ });
355
+ const body = JSON.parse(calls[0].init.body);
356
+ expect(body.contact_name).toBe("Alex Tong");
357
+ expect(body).not.toHaveProperty("contact_email");
358
+ });
359
+
360
+ it("forwards both contact_email and contact_name when both supplied", async () => {
361
+ mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
362
+ await engineAggregate("https://e", {
363
+ clientId: "acme",
364
+ contactEmail: "alex@x.io",
365
+ contactName: "Alex Tong",
366
+ });
367
+ const body = JSON.parse(calls[0].init.body);
368
+ expect(body.contact_email).toBe("alex@x.io");
369
+ expect(body.contact_name).toBe("Alex Tong");
370
+ });
371
+
372
+ it("forwards custom group_by when non-empty", async () => {
373
+ mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
374
+ await engineAggregate("https://e", {
375
+ clientId: "acme",
376
+ contactEmail: "alex@x.io",
377
+ groupBy: ["channel", "direction"],
378
+ });
379
+ const body = JSON.parse(calls[0].init.body);
380
+ expect(body.group_by).toEqual(["channel", "direction"]);
381
+ });
382
+
383
+ it("defaults group_by to ['channel'] when caller omits it", async () => {
384
+ mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
385
+ await engineAggregate("https://e", {
386
+ clientId: "acme",
387
+ contactEmail: "alex@x.io",
388
+ });
389
+ const body = JSON.parse(calls[0].init.body);
390
+ expect(body.group_by).toEqual(["channel"]);
391
+ });
392
+
393
+ it("defaults group_by to ['channel'] when caller passes empty array", async () => {
394
+ // Defensive: TS callers can pass `groupBy: []` to mean "no
395
+ // grouping" but we treat that as "use the sensible default" so
396
+ // the default behaviour matches a missing field. The engine
397
+ // itself is the place that supports empty group_by → single
398
+ // bucket; expose that explicitly only when the caller asks.
399
+ mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
400
+ await engineAggregate("https://e", {
401
+ clientId: "acme",
402
+ contactEmail: "alex@x.io",
403
+ groupBy: [],
404
+ });
405
+ const body = JSON.parse(calls[0].init.body);
406
+ expect(body.group_by).toEqual(["channel"]);
407
+ });
408
+
409
+ it("requires clientId", async () => {
410
+ await expect(
411
+ engineAggregate("https://e", { contactEmail: "alex@x.io" })
412
+ ).rejects.toThrow(/clientId required/);
413
+ });
414
+
415
+ it("requires contactEmail or contactName", async () => {
416
+ await expect(
417
+ engineAggregate("https://e", { clientId: "acme" })
418
+ ).rejects.toThrow(/contactEmail.*contactName/i);
419
+ });
420
+
421
+ it("forwards CF Access headers when supplied", async () => {
422
+ mockOk({ arena: "acme", total: 0, last_seen: null, buckets: [] });
423
+ await engineAggregate("https://e", {
424
+ clientId: "acme",
425
+ contactEmail: "alex@x.io",
426
+ headers: {
427
+ "CF-Access-Client-Id": "id123",
428
+ "CF-Access-Client-Secret": "sec456",
429
+ },
430
+ });
431
+ expect(calls[0].init.headers["CF-Access-Client-Id"]).toBe("id123");
432
+ expect(calls[0].init.headers["CF-Access-Client-Secret"]).toBe("sec456");
433
+ });
434
+
435
+ it("returns the engine's response shape unchanged", async () => {
436
+ const expected = {
437
+ arena: "acme",
438
+ total: 47,
439
+ last_seen: "2026-05-09T09:00:00Z",
440
+ buckets: [
441
+ {
442
+ keys: { channel: "email" },
443
+ count: 35,
444
+ inbound: 14,
445
+ outbound: 21,
446
+ last_seen: "2026-05-09T09:00:00Z",
447
+ first_seen: "2025-09-01T08:00:00Z",
448
+ },
449
+ ],
450
+ };
451
+ mockOk(expected);
452
+ const out = await engineAggregate("https://e", {
453
+ clientId: "acme",
454
+ contactEmail: "alex@x.io",
455
+ });
456
+ expect(out).toEqual(expected);
457
+ });
458
+ });
459
+
318
460
  describe("engineForget", () => {
319
461
  it("forwards id when provided", async () => {
320
462
  mockOk({ deleted: 1 });
@@ -281,3 +281,68 @@ export async function engineForget(engineUrl, opts) {
281
281
  };
282
282
  return fetchEngine(engineUrl, "/forget", body, { headers });
283
283
  }
284
+
285
+ /**
286
+ * Aggregate communications history about one person from the typed-
287
+ * Person graph (memory-engine #30 / 0.8.x). Backs ``personFacets``-
288
+ * style queries: total + per-channel breakdown + last_seen, all
289
+ * computed by a single Cypher aggregate over
290
+ * ``(:Person)-[:COMMUNICATED]->(:Chunk)`` edges.
291
+ *
292
+ * Caller must supply at least one of ``contactEmail`` or
293
+ * ``contactName``. ``arena`` is single-valued here (not an arenas
294
+ * list) — aggregating across arenas isn't a valid operation in a
295
+ * multi-tenant product, so we don't expose it.
296
+ *
297
+ * Returns ``null`` shape: ``{arena, total, last_seen, buckets: [{keys,
298
+ * count, inbound, outbound, last_seen, first_seen}]}``. ``total: 0``
299
+ * with empty ``buckets`` when no Person node exists for this contact
300
+ * in the requested arena (older memories pre-#28, tenants whose data
301
+ * isn't re-ingested under the new writer yet). The fallback to
302
+ * over-fetch search lives in TES — this helper is a thin wire bridge.
303
+ *
304
+ * @param {string} engineUrl
305
+ * @param {object} opts
306
+ * @param {string} opts.clientId
307
+ * @param {string} [opts.userId] defaults to tenant-wide arena
308
+ * @param {string} [opts.contactEmail]
309
+ * @param {string} [opts.contactName]
310
+ * @param {string[]} [opts.groupBy=["channel"]] bucket keys; engine
311
+ * whitelists "channel"+"direction" today, unknown keys silently
312
+ * dropped.
313
+ * @param {Record<string,string>} [opts.headers] forwarded HTTP headers
314
+ * (e.g. CF Access service token pair).
315
+ * @returns {Promise<{
316
+ * arena: string,
317
+ * total: number,
318
+ * last_seen: string|null,
319
+ * buckets: Array<{
320
+ * keys: Record<string, string|null>,
321
+ * count: number,
322
+ * inbound: number,
323
+ * outbound: number,
324
+ * last_seen: string|null,
325
+ * first_seen: string|null,
326
+ * }>,
327
+ * }>}
328
+ */
329
+ export async function engineAggregate(engineUrl, opts) {
330
+ const { clientId, userId, contactEmail, contactName, groupBy, headers } =
331
+ opts || {};
332
+ if (!clientId) throw new Error("engineAggregate: clientId required");
333
+ if (!contactEmail && !contactName) {
334
+ throw new Error(
335
+ "engineAggregate: provide contactEmail and/or contactName",
336
+ );
337
+ }
338
+ // Single-arena: clientId:userId when userId is set, clientId
339
+ // tenant-wide otherwise. Mirrors composeArenas's first element.
340
+ const arenas = composeArenas(clientId, userId);
341
+ const body = {
342
+ arena: arenas[arenas.length - 1],
343
+ group_by: groupBy && groupBy.length > 0 ? groupBy : ["channel"],
344
+ ...(contactEmail ? { contact_email: contactEmail } : {}),
345
+ ...(contactName ? { contact_name: contactName } : {}),
346
+ };
347
+ return fetchEngine(engineUrl, "/aggregate", body, { headers });
348
+ }
@@ -125,6 +125,21 @@ class ForgetRequest(BaseModel):
125
125
  id: Optional[str] = None
126
126
 
127
127
 
128
+ class AggregateRequest(BaseModel):
129
+ """Public-facing /aggregate request.
130
+
131
+ Mirrors /aggregate-internal on the L2 proxy with arena scoping
132
+ enforced at the shim layer (multi-arena spans aren't allowed
133
+ here — pick a single arena per call). The relationships UI
134
+ queries this directly via TES; future callers will too.
135
+ """
136
+
137
+ arena: str
138
+ contact_email: Optional[str] = None
139
+ contact_name: Optional[str] = None
140
+ group_by: Optional[list[str]] = None
141
+
142
+
128
143
  # ----------------------------------------------------------------------
129
144
  # Engine clients (one per layer)
130
145
  # ----------------------------------------------------------------------
@@ -912,18 +927,88 @@ async def forget(req: ForgetRequest):
912
927
  # Also wipe L0 BM25 + L4 QMD + L3 KG so bench resets fully.
913
928
  # No per-id forget for these — bench harness uses /forget once at
914
929
  # start of each run with empty filters to reset state.
930
+ #
931
+ # Three forwarding rules:
932
+ # - metadata_contains has `arena` → tenant-scoped delete (forwards
933
+ # the arena to the internal endpoint, which only wipes that
934
+ # tenant's L3 data).
935
+ # - metadata_contains is set, no arena → targeted L6-only delete;
936
+ # do NOT call the internal wipe (we don't have arena scope and
937
+ # a global wipe would be wildly wrong here).
938
+ # - empty filters → bench-reset semantics; explicitly request the
939
+ # unsafe global wipe via the new `confirm: GLOBAL_WIPE` gate.
940
+ arena_for_internal = None
941
+ if isinstance(req.metadata_contains, dict):
942
+ meta_arena = req.metadata_contains.get("arena")
943
+ if isinstance(meta_arena, str) and meta_arena:
944
+ arena_for_internal = meta_arena
915
945
  try:
916
- r = await _client().post(f"{L2_PROXY_URL}/forget-internal",
917
- json={}, timeout=15.0)
918
- if r.status_code == 200:
919
- d = r.json().get("deleted", {})
920
- deleted_total += sum(int(v or 0) for v in d.values())
946
+ if arena_for_internal:
947
+ payload = {"arena": arena_for_internal}
948
+ elif req.metadata_contains:
949
+ payload = None # skip — targeted delete shouldn't wipe shared layers
950
+ else:
951
+ payload = {"confirm": "GLOBAL_WIPE"}
952
+ if payload is not None:
953
+ r = await _client().post(
954
+ f"{L2_PROXY_URL}/forget-internal", json=payload, timeout=15.0,
955
+ )
956
+ if r.status_code == 200:
957
+ d = r.json().get("deleted", {})
958
+ deleted_total += sum(int(v or 0) for v in d.values())
921
959
  except Exception as exc:
922
960
  print(f"[shim] L2 /forget-internal failed: {exc}")
923
961
 
924
962
  return {"deleted": deleted_total, "engine": "pentatonic-memory-engine"}
925
963
 
926
964
 
965
+ @app.post("/aggregate")
966
+ async def aggregate(req: AggregateRequest) -> dict[str, Any]:
967
+ """Aggregate communications history with one person.
968
+
969
+ Pass-through to the L2 proxy's /aggregate-internal which runs a
970
+ single Cypher query against L3's typed-Person graph
971
+ ((:Person)-[:COMMUNICATED]->(:Chunk)). Used by TES personFacets
972
+ once we cut over from the over-fetch v1.
973
+
974
+ The shim's job here is shape validation + arena enforcement; the
975
+ real aggregation lives in L3. No layer fan-out, no metadata
976
+ filter scanning — when the typed-Person nodes don't exist for
977
+ this contact (older memories, tenants pre-#28), the response is
978
+ `total: 0` and the caller falls back to whatever it had before.
979
+ """
980
+ arena = (req.arena or "").strip()
981
+ if not arena:
982
+ raise HTTPException(status_code=400, detail="arena is required")
983
+ if not (req.contact_email or req.contact_name):
984
+ raise HTTPException(
985
+ status_code=400,
986
+ detail="provide contact_email and/or contact_name to identify the Person",
987
+ )
988
+ payload: dict[str, Any] = {
989
+ "arena": arena,
990
+ "group_by": req.group_by or ["channel"],
991
+ }
992
+ if req.contact_email:
993
+ payload["contact_email"] = req.contact_email
994
+ if req.contact_name:
995
+ payload["contact_name"] = req.contact_name
996
+ try:
997
+ r = await _client().post(
998
+ f"{L2_PROXY_URL}/aggregate-internal", json=payload, timeout=15.0,
999
+ )
1000
+ if r.status_code != 200:
1001
+ raise HTTPException(
1002
+ status_code=r.status_code,
1003
+ detail=f"aggregate failed: {r.text[:200]}",
1004
+ )
1005
+ return r.json()
1006
+ except HTTPException:
1007
+ raise
1008
+ except Exception as exc:
1009
+ raise HTTPException(status_code=502, detail=f"aggregate upstream: {exc}")
1010
+
1011
+
927
1012
  # ----------------------------------------------------------------------
928
1013
  # Entrypoint
929
1014
  # ----------------------------------------------------------------------
@@ -88,8 +88,8 @@ services:
88
88
  l4:
89
89
  <<: *engine-base
90
90
  build:
91
- context: ./engine/services/l4
92
- dockerfile: Dockerfile
91
+ context: ./engine/services
92
+ dockerfile: l4/Dockerfile
93
93
  container_name: pme-l4
94
94
  # Default 18042 to avoid port collisions on 8042.
95
95
  # Override via PME_L4_PORT for bench setups that intentionally replace it.
@@ -98,6 +98,8 @@ services:
98
98
  L4_NV_EMBED_URL: ${NV_EMBED_URL:-http://host.docker.internal:8041/v1/embeddings}
99
99
  L4_EMBED_MODEL: ${EMBED_MODEL_NAME:-nv-embed-v2}
100
100
  L4_EMBED_API_KEY: ${EMBED_API_KEY:-}
101
+ L4_EMBED_PROVIDER: ${EMBED_PROVIDER:-openai}
102
+ L4_EMBED_AUTODETECT: ${EMBED_AUTODETECT:-true}
101
103
  L4_EMBED_DIM: ${EMBED_DIM:-4096}
102
104
  L4_DB_PATH: /data/vec.db
103
105
  extra_hosts:
@@ -116,8 +118,8 @@ services:
116
118
  l5:
117
119
  <<: *engine-base
118
120
  build:
119
- context: ./engine/services/l5
120
- dockerfile: Dockerfile
121
+ context: ./engine/services
122
+ dockerfile: l5/Dockerfile
121
123
  container_name: pme-l5
122
124
  # Default 18034 to avoid port collisions on 8034.
123
125
  # Override via PME_L5_PORT for bench setups that intentionally replace it.
@@ -126,6 +128,8 @@ services:
126
128
  L5_NV_EMBED_URL: ${NV_EMBED_URL:-http://host.docker.internal:8041/v1/embeddings}
127
129
  L5_EMBED_MODEL: ${EMBED_MODEL_NAME:-nv-embed-v2}
128
130
  L5_EMBED_API_KEY: ${EMBED_API_KEY:-}
131
+ L5_EMBED_PROVIDER: ${EMBED_PROVIDER:-openai}
132
+ L5_EMBED_AUTODETECT: ${EMBED_AUTODETECT:-true}
129
133
  L5_EMBED_DIM: ${EMBED_DIM:-4096}
130
134
  L5_OLLAMA_DIM: ${OLLAMA_DIM:-768}
131
135
  L5_OLLAMA_EMBED_URL: ${L5_OLLAMA_EMBED_URL:-http://host.docker.internal:11434/api/embed}
@@ -143,8 +147,8 @@ services:
143
147
  l6:
144
148
  <<: *engine-base
145
149
  build:
146
- context: ./engine/services/l6
147
- dockerfile: Dockerfile
150
+ context: ./engine/services
151
+ dockerfile: l6/Dockerfile
148
152
  container_name: pme-l6
149
153
  # Default 18037 to avoid colliding with Spark Core L6 doc-store on 8037.
150
154
  # Override via PME_L6_PORT for bench setups that intentionally replace it.
@@ -153,6 +157,8 @@ services:
153
157
  L6_NV_EMBED_URL: ${NV_EMBED_URL:-http://host.docker.internal:8041/v1/embeddings}
154
158
  L6_EMBED_MODEL: ${EMBED_MODEL_NAME:-nv-embed-v2}
155
159
  L6_EMBED_API_KEY: ${EMBED_API_KEY:-}
160
+ L6_EMBED_PROVIDER: ${EMBED_PROVIDER:-openai}
161
+ L6_EMBED_AUTODETECT: ${EMBED_AUTODETECT:-true}
156
162
  L6_EMBED_DIM: ${EMBED_DIM:-4096}
157
163
  L6_DATA_DIR: /data
158
164
  extra_hosts:
@@ -166,12 +172,16 @@ services:
166
172
  l2:
167
173
  <<: *engine-base
168
174
  build:
169
- context: ./engine/services/l2
170
- dockerfile: Dockerfile
175
+ context: ./engine/services
176
+ dockerfile: l2/Dockerfile
171
177
  container_name: pme-l2
172
178
  ports: ["127.0.0.1:${PME_L2_PORT:-8131}:8031"]
173
179
  environment:
174
180
  PME_NV_EMBED_URL: ${NV_EMBED_URL:-http://host.docker.internal:8041/v1/embeddings}
181
+ PME_EMBED_API_KEY: ${EMBED_API_KEY:-}
182
+ PME_EMBED_PROVIDER: ${EMBED_PROVIDER:-openai}
183
+ PME_EMBED_AUTODETECT: ${EMBED_AUTODETECT:-true}
184
+ PME_NV_EMBED_MODEL: ${EMBED_MODEL_NAME:-nv-embed-v2}
175
185
  PME_NEO4J_URI: bolt://l3:7687
176
186
  PME_NEO4J_PASSWORD: ${NEO4J_PASSWORD:-local-dev-pw}
177
187
  NEO4J_PASSWORD: ${NEO4J_PASSWORD:-local-dev-pw}
@@ -0,0 +1 @@
1
+ """Shared utilities used across the memory-engine layer services."""