@susu-eng/gralkor 27.2.14 → 27.3.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.
package/README.md CHANGED
@@ -65,6 +65,23 @@ Gralkor is _good_ memory, not cheap memory. You can push the llm choice and perh
65
65
  - Hooks: auto-capture (stores full multi-turn conversations after each agent run), auto-recall (injects relevant facts before the agent responds)
66
66
  - Set up: `plugins.slots.memory = "gralkor"` in `openclaw.json`
67
67
 
68
+ ## Using Gralkor from Elixir / Jido
69
+
70
+ Gralkor is primarily an OpenClaw plugin, but the Python server exposes a harness-agnostic HTTP API. The Elixir package in `ex/` — published as [`:gralkor` on Hex](https://hex.pm/packages/gralkor) — wraps it: supervises the Python server, exposes a `Gralkor.Client` port with HTTP and in-memory adapters, ships a boot-readiness gate, and auto-recovers from orphaned uvicorn processes on dev reboot.
71
+
72
+ **For Jido agents**, use [`:jido_gralkor`](https://hex.pm/packages/jido_gralkor) — it pulls `:gralkor` transitively and adds three modules (a plugin + two ReAct tools) that turn the Client port into transparent long-term memory on your agent. That package's README is the Jido-dev entry point; it covers the full wiring recipe.
73
+
74
+ **For any Elixir app** (non-Jido), see [`ex/README.md`](./ex/README.md) — how to supervise the server, gate your boot on readiness, and call `Gralkor.Client.impl/0` from your own code.
75
+
76
+ **HTTP endpoints** (unauthenticated — loopback-only; consumer supervises the server):
77
+
78
+ - `POST /recall` — before-prompt auto-recall
79
+ - `POST /capture` — fire-and-forget turn capture (server buffers, distils, ingests on idle)
80
+ - `POST /session_end` — flush the session's buffer now (fire-and-forget; 204 before the graph write); for consumers that know when a session is over
81
+ - `POST /tools/memory_search`, `POST /tools/memory_add` — consumer-facing tools
82
+ - `POST /distill` — standalone distillation (for clients that want raw distill access)
83
+ - Existing: `POST /episodes`, `POST /search`, `GET /health`
84
+
68
85
  ## Quick Start
69
86
 
70
87
  ### 1. Prerequisites
@@ -153,5 +153,5 @@
153
153
  "label": "Groq API key"
154
154
  }
155
155
  },
156
- "version": "27.2.14"
156
+ "version": "27.3.0"
157
157
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@susu-eng/gralkor",
3
3
  "displayName": "Gralkor",
4
- "version": "27.2.14",
4
+ "version": "27.3.0",
5
5
  "description": "OpenClaw memory plugin powered by Graphiti knowledge graphs and FalkorDB",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -85,6 +85,7 @@
85
85
  "pack": "bash scripts/pack.sh",
86
86
  "publish:npm": "bash scripts/publish-npm.sh",
87
87
  "publish:clawhub": "bash scripts/publish-clawhub.sh",
88
+ "publish:hex": "bash scripts/publish-hex.sh",
88
89
  "publish:all": "bash scripts/publish-all.sh"
89
90
  }
90
91
  }
package/server/main.py CHANGED
@@ -11,11 +11,19 @@ from copy import deepcopy
11
11
  from datetime import datetime, timezone
12
12
  from typing import Any, Literal
13
13
 
14
+ import uuid
15
+
14
16
  import yaml
15
- from fastapi import FastAPI
17
+ from fastapi import APIRouter, FastAPI, HTTPException, Response, status
16
18
  from fastapi.responses import JSONResponse
17
19
  from pydantic import BaseModel, Field, create_model
18
20
 
21
+ from pipelines.capture_buffer import CaptureBuffer, CaptureClientError, turns_to_conversation
22
+ from pipelines.distill import Turn, format_transcript
23
+ from pipelines.formatting import format_fact, format_node
24
+ from pipelines.interpret import interpret_facts
25
+ from pipelines.message_clean import ConversationMessage
26
+
19
27
 
20
28
 
21
29
  from graphiti_core import Graphiti
@@ -37,9 +45,17 @@ def _load_config() -> dict:
37
45
  return {}
38
46
 
39
47
 
48
+ DEFAULT_LLM_PROVIDER = "gemini"
49
+ DEFAULT_LLM_MODEL = "gemini-3.1-flash-lite-preview"
50
+ DEFAULT_EMBEDDER_PROVIDER = "gemini"
51
+ DEFAULT_EMBEDDER_MODEL = "gemini-embedding-2-preview"
52
+
53
+
40
54
  def _build_llm_client(cfg: dict):
41
- provider = cfg.get("llm", {}).get("provider", "gemini")
42
- model = cfg.get("llm", {}).get("model")
55
+ provider = cfg.get("llm", {}).get("provider") or DEFAULT_LLM_PROVIDER
56
+ model = cfg.get("llm", {}).get("model") or (
57
+ DEFAULT_LLM_MODEL if provider == DEFAULT_LLM_PROVIDER else None
58
+ )
43
59
  llm_cfg = LLMConfig(model=model) if model else None
44
60
 
45
61
  if provider == "anthropic":
@@ -62,8 +78,10 @@ def _build_llm_client(cfg: dict):
62
78
 
63
79
 
64
80
  def _build_embedder(cfg: dict):
65
- provider = cfg.get("embedder", {}).get("provider", "gemini")
66
- model = cfg.get("embedder", {}).get("model")
81
+ provider = cfg.get("embedder", {}).get("provider") or DEFAULT_EMBEDDER_PROVIDER
82
+ model = cfg.get("embedder", {}).get("model") or (
83
+ DEFAULT_EMBEDDER_MODEL if provider == DEFAULT_EMBEDDER_PROVIDER else None
84
+ )
67
85
 
68
86
  if provider == "gemini":
69
87
  from graphiti_core.embedder.gemini import GeminiEmbedder, GeminiEmbedderConfig
@@ -259,7 +277,13 @@ async def lifespan(_app: FastAPI):
259
277
  edge_names = list(ontology_edge_types or {})
260
278
  print(f"[gralkor] ontology: entities={entity_names} edges={edge_names}", flush=True)
261
279
 
280
+ global capture_buffer
281
+ idle_seconds = float(cfg.get("capture", {}).get("idle_seconds", CAPTURE_IDLE_SECONDS_DEFAULT))
282
+ capture_buffer = CaptureBuffer(idle_seconds=idle_seconds, flush_callback=_capture_flush)
283
+
262
284
  yield
285
+
286
+ await capture_buffer.flush_all()
263
287
  await graphiti.close()
264
288
 
265
289
 
@@ -345,6 +369,40 @@ async def rate_limit_middleware(request, call_next):
345
369
  raise
346
370
 
347
371
 
372
+ # ── Auth ─────────────────────────────────────────────────────
373
+
374
+
375
+ # ── Capture buffer ───────────────────────────────────────────
376
+
377
+ CAPTURE_IDLE_SECONDS_DEFAULT = 300.0
378
+ capture_buffer: CaptureBuffer | None = None
379
+
380
+
381
+ async def _capture_flush(group_id: str, turns: list[Turn]) -> None:
382
+ if graphiti is None:
383
+ return
384
+ t0 = time.monotonic()
385
+ episode_body = await format_transcript(turns, graphiti.llm_client)
386
+ if not episode_body.strip():
387
+ return
388
+ logger.debug("[gralkor] [test] capture flush body: %s", episode_body)
389
+ async with _driver_lock:
390
+ result = await graphiti.add_episode(
391
+ name=f"conversation-{int(time.time() * 1000)}",
392
+ episode_body=episode_body,
393
+ source_description="auto-capture",
394
+ group_id=_sanitize_group_id(group_id),
395
+ reference_time=datetime.now(timezone.utc),
396
+ source=EpisodeType.message,
397
+ entity_types=ontology_entity_types,
398
+ edge_types=ontology_edge_types,
399
+ edge_type_map=ontology_edge_type_map,
400
+ )
401
+ duration_ms = (time.monotonic() - t0) * 1000
402
+ logger.info("[gralkor] capture flushed — group:%s uuid:%s bodyChars:%d %.0fms",
403
+ group_id, result.episode.uuid, len(episode_body), duration_ms)
404
+
405
+
348
406
  # ── Idempotency store ────────────────────────────────────────
349
407
 
350
408
  # In-memory store: idempotency_key -> serialized_episode
@@ -385,6 +443,63 @@ class GroupIdRequest(BaseModel):
385
443
  group_id: str
386
444
 
387
445
 
446
+ class RecallRequest(BaseModel):
447
+ session_id: str
448
+ group_id: str
449
+ query: str
450
+ max_results: int = 10
451
+
452
+
453
+ class RecallResponse(BaseModel):
454
+ memory_block: str
455
+
456
+
457
+ class TurnBody(BaseModel):
458
+ user_query: str
459
+ events: list[Any] = Field(default_factory=list)
460
+ assistant_answer: str
461
+
462
+
463
+ class DistillRequest(BaseModel):
464
+ turns: list[TurnBody]
465
+
466
+
467
+ class DistillResponse(BaseModel):
468
+ episode_body: str
469
+
470
+
471
+ class CaptureRequest(BaseModel):
472
+ session_id: str
473
+ group_id: str
474
+ turn: TurnBody
475
+
476
+
477
+ class SessionEndRequest(BaseModel):
478
+ session_id: str = Field(min_length=1)
479
+
480
+
481
+ class MemorySearchRequest(BaseModel):
482
+ session_id: str
483
+ group_id: str
484
+ query: str
485
+ max_results: int = 20
486
+ max_entity_results: int = 10
487
+
488
+
489
+ class MemorySearchResponse(BaseModel):
490
+ text: str
491
+
492
+
493
+ class MemoryAddRequest(BaseModel):
494
+ group_id: str
495
+ content: str
496
+ source_description: str = "manual"
497
+
498
+
499
+ class MemoryAddResponse(BaseModel):
500
+ status: Literal["stored"]
501
+
502
+
388
503
  # ── Serializers ───────────────────────────────────────────────
389
504
 
390
505
 
@@ -431,7 +546,37 @@ def _serialize_episode(ep: EpisodicNode) -> dict[str, Any]:
431
546
  logger = logging.getLogger(__name__)
432
547
 
433
548
 
434
- @app.get("/health")
549
+ router = APIRouter()
550
+
551
+
552
+ def _turn_body_to_turn(body: TurnBody) -> Turn:
553
+ return Turn(
554
+ user_query=body.user_query,
555
+ events=list(body.events),
556
+ assistant_answer=body.assistant_answer,
557
+ )
558
+
559
+
560
+ def _elide_tokens(value: Any) -> Any:
561
+ if isinstance(value, dict):
562
+ return {k: "[...]" if k == "token" else _elide_tokens(v) for k, v in value.items()}
563
+ if isinstance(value, list):
564
+ return [_elide_tokens(v) for v in value]
565
+ return value
566
+
567
+
568
+ def _conversation_for_session(session_id: str) -> list[ConversationMessage]:
569
+ if capture_buffer is None:
570
+ return []
571
+ return turns_to_conversation(capture_buffer.turns_for(session_id))
572
+
573
+
574
+ FURTHER_QUERYING_INSTRUCTION = (
575
+ "Search memory (up to 3 times, diverse queries) if you need more detail."
576
+ )
577
+
578
+
579
+ @router.get("/health")
435
580
  async def health():
436
581
  result: dict = {"status": "ok"}
437
582
 
@@ -460,24 +605,18 @@ async def health():
460
605
  return result
461
606
 
462
607
 
463
- @app.post("/episodes")
608
+ @router.post("/episodes")
464
609
  async def add_episode(req: AddEpisodeRequest):
465
610
  cached = _idempotency_check(req.idempotency_key)
466
611
  if cached is not None:
467
- logger.info("[gralkor] add-episode idempotent hit — key:%s uuid:%s",
468
- req.idempotency_key, cached.get("uuid"))
469
612
  return cached
470
613
 
471
- logger.info("[gralkor] add-episode — group:%s name:%s bodyChars:%d source:%s",
472
- req.group_id, req.name, len(req.episode_body), req.source or "message")
473
- logger.debug("[gralkor] add-episode body:\n%s", req.episode_body)
474
614
  ref_time = (
475
615
  datetime.fromisoformat(req.reference_time)
476
616
  if req.reference_time
477
617
  else datetime.now(timezone.utc)
478
618
  )
479
619
  episode_type = EpisodeType(req.source) if req.source else EpisodeType.message
480
- t0 = time.monotonic()
481
620
  async with _driver_lock:
482
621
  result = await graphiti.add_episode(
483
622
  name=req.name,
@@ -491,10 +630,7 @@ async def add_episode(req: AddEpisodeRequest):
491
630
  edge_type_map=ontology_edge_type_map,
492
631
  excluded_entity_types=None,
493
632
  )
494
- duration_ms = (time.monotonic() - t0) * 1000
495
633
  episode = result.episode
496
- logger.info("[gralkor] episode added — uuid:%s duration:%.0fms", episode.uuid, duration_ms)
497
- logger.debug("[gralkor] episode result: %s", _serialize_episode(episode))
498
634
  serialized = _serialize_episode(episode)
499
635
  _idempotency_store_result(req.idempotency_key, serialized)
500
636
  return serialized
@@ -536,7 +672,6 @@ def _ensure_driver_graph(group_ids: list[str] | None) -> None:
536
672
  try:
537
673
  graphiti.driver = graphiti.driver.clone(database=target)
538
674
  graphiti.clients.driver = graphiti.driver
539
- print(f"[gralkor] driver graph routed: {target}", flush=True)
540
675
  except Exception as e:
541
676
  # Invalid group_id (e.g. hyphens rejected by FalkorDB). Skip routing
542
677
  # so the search runs against the current graph and returns empty results
@@ -544,12 +679,10 @@ def _ensure_driver_graph(group_ids: list[str] | None) -> None:
544
679
  logger.warning("[gralkor] driver graph routing failed for %s: %s", target, e)
545
680
 
546
681
 
547
- @app.post("/search")
682
+ @router.post("/search")
548
683
  async def search(req: SearchRequest):
549
684
  # Sanitize group IDs: hyphens cause RediSearch syntax errors in graphiti-core.
550
685
  sanitized = [_sanitize_group_id(g) for g in req.group_ids]
551
- logger.info("[gralkor] search — mode:%s query:%d chars group_ids:%s num_results:%d",
552
- req.mode, len(req.query), sanitized, req.num_results)
553
686
  # graphiti.add_episode() clones the driver to target the correct FalkorDB
554
687
  # named graph (database=group_id), but graphiti.search() does not — it just
555
688
  # uses whatever graph the driver currently points at. Before the first
@@ -583,23 +716,19 @@ async def search(req: SearchRequest):
583
716
  duration_ms = (time.monotonic() - t0) * 1000
584
717
  logger.error("[gralkor] search failed — mode:%s %.0fms: %s", req.mode, duration_ms, e)
585
718
  raise
586
- duration_ms = (time.monotonic() - t0) * 1000
587
719
  result = [_serialize_fact(e) for e in edges]
588
720
  serialized_nodes = [_serialize_node(n) for n in nodes]
589
- logger.info("[gralkor] search result — mode:%s %d facts %d nodes %.0fms",
590
- req.mode, len(result), len(serialized_nodes), duration_ms)
591
- logger.debug("[gralkor] search facts: %s", result)
592
721
  return {"facts": result, "nodes": serialized_nodes}
593
722
 
594
723
 
595
724
 
596
- @app.post("/build-indices")
725
+ @router.post("/build-indices")
597
726
  async def build_indices():
598
727
  await graphiti.build_indices_and_constraints()
599
728
  return {"status": "ok"}
600
729
 
601
730
 
602
- @app.post("/build-communities")
731
+ @router.post("/build-communities")
603
732
  async def build_communities(req: GroupIdRequest):
604
733
  gid = _sanitize_group_id(req.group_id)
605
734
  async with _driver_lock:
@@ -608,3 +737,137 @@ async def build_communities(req: GroupIdRequest):
608
737
  group_ids=[gid],
609
738
  )
610
739
  return {"communities": len(communities), "edges": len(edges)}
740
+
741
+
742
+ # ── New endpoints ────────────────────────────────────────────
743
+
744
+
745
+ @router.post("/recall", response_model=RecallResponse)
746
+ async def recall(req: RecallRequest) -> RecallResponse:
747
+ sanitized = _sanitize_group_id(req.group_id)
748
+ conversation = _conversation_for_session(req.session_id)
749
+ logger.info("[gralkor] recall — session:%s group:%s queryChars:%d max:%d",
750
+ req.session_id, sanitized, len(req.query), req.max_results)
751
+ logger.debug("[gralkor] [test] recall query: %s", req.query)
752
+ t0 = time.monotonic()
753
+
754
+ async with _driver_lock:
755
+ _ensure_driver_graph([sanitized])
756
+ edges = await graphiti.search(
757
+ query=_sanitize_query(req.query),
758
+ group_ids=[sanitized],
759
+ num_results=req.max_results,
760
+ )
761
+
762
+ facts = [_serialize_fact(e) for e in edges]
763
+ if not facts:
764
+ logger.info("[gralkor] recall result — 0 facts %.0fms",
765
+ (time.monotonic() - t0) * 1000)
766
+ return RecallResponse(memory_block="")
767
+
768
+ facts_text = "\n".join(format_fact(f) for f in facts)
769
+ interpretation = await interpret_facts(conversation, facts_text, graphiti.llm_client)
770
+
771
+ block = (
772
+ '<gralkor-memory trust="untrusted">\n'
773
+ f"Facts:\n{facts_text}\n\n"
774
+ f"Interpretation:\n{interpretation}\n\n"
775
+ f"{FURTHER_QUERYING_INSTRUCTION}\n"
776
+ "</gralkor-memory>"
777
+ )
778
+ duration_ms = (time.monotonic() - t0) * 1000
779
+ logger.info("[gralkor] recall result — %d facts blockChars:%d %.0fms",
780
+ len(facts), len(block), duration_ms)
781
+ logger.debug("[gralkor] [test] recall block: %s", block)
782
+ return RecallResponse(memory_block=block)
783
+
784
+
785
+ @router.post("/distill", response_model=DistillResponse)
786
+ async def distill(req: DistillRequest) -> DistillResponse:
787
+ turns = [_turn_body_to_turn(t) for t in req.turns]
788
+ episode_body = await format_transcript(turns, graphiti.llm_client if graphiti else None)
789
+ return DistillResponse(episode_body=episode_body)
790
+
791
+
792
+ @router.post("/capture", status_code=status.HTTP_204_NO_CONTENT)
793
+ async def capture(req: CaptureRequest) -> Response:
794
+ if capture_buffer is None:
795
+ raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "capture buffer not initialized")
796
+ sanitized = _sanitize_group_id(req.group_id)
797
+ turn = _turn_body_to_turn(req.turn)
798
+ capture_buffer.append(req.session_id, sanitized, turn)
799
+ logger.debug("[gralkor] [test] capture turn: user_query=%s events=%s assistant_answer=%s",
800
+ turn.user_query, _elide_tokens(turn.events), turn.assistant_answer)
801
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
802
+
803
+
804
+ @router.post("/session_end", status_code=status.HTTP_204_NO_CONTENT)
805
+ async def session_end(req: SessionEndRequest) -> Response:
806
+ if capture_buffer is None:
807
+ raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "capture buffer not initialized")
808
+ turns = len(capture_buffer.turns_for(req.session_id))
809
+ capture_buffer.flush(req.session_id)
810
+ logger.info("[gralkor] session_end session:%s turns:%d", req.session_id, turns)
811
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
812
+
813
+
814
+ @router.post("/tools/memory_search", response_model=MemorySearchResponse)
815
+ async def tools_memory_search(req: MemorySearchRequest) -> MemorySearchResponse:
816
+ sanitized = _sanitize_group_id(req.group_id)
817
+ conversation = _conversation_for_session(req.session_id)
818
+ logger.info("[gralkor] tools.memory_search — session:%s group:%s queryChars:%d max:%d/%d",
819
+ req.session_id, sanitized, len(req.query), req.max_results, req.max_entity_results)
820
+ logger.debug("[gralkor] [test] tools.memory_search query: %s", req.query)
821
+ t0 = time.monotonic()
822
+
823
+ async with _driver_lock:
824
+ _ensure_driver_graph([sanitized])
825
+ config = deepcopy(COMBINED_HYBRID_SEARCH_CROSS_ENCODER)
826
+ config.limit = req.max_results
827
+ search_result = await graphiti.search_(
828
+ query=_sanitize_query(req.query),
829
+ group_ids=[sanitized],
830
+ config=config,
831
+ )
832
+
833
+ facts = [_serialize_fact(e) for e in search_result.edges]
834
+ nodes = [_serialize_node(n) for n in search_result.nodes[: req.max_entity_results]]
835
+
836
+ if not facts and not nodes:
837
+ logger.info("[gralkor] tools.memory_search result — 0 facts 0 entities %.0fms",
838
+ (time.monotonic() - t0) * 1000)
839
+ return MemorySearchResponse(text="Facts: (none)\nEntities: (none)")
840
+
841
+ facts_section = "Facts:\n" + ("\n".join(format_fact(f) for f in facts) if facts else "(none)")
842
+ entities_section = "Entities:\n" + (
843
+ "\n".join(format_node(n) for n in nodes) if nodes else "(none)"
844
+ )
845
+ facts_text = facts_section + "\n\n" + entities_section
846
+ interpretation = await interpret_facts(conversation, facts_text, graphiti.llm_client)
847
+ text = f"{facts_text}\n\nInterpretation:\n{interpretation}"
848
+ duration_ms = (time.monotonic() - t0) * 1000
849
+ logger.info("[gralkor] tools.memory_search result — %d facts %d entities textChars:%d %.0fms",
850
+ len(facts), len(nodes), len(text), duration_ms)
851
+ logger.debug("[gralkor] [test] tools.memory_search text: %s", text)
852
+ return MemorySearchResponse(text=text)
853
+
854
+
855
+ @router.post("/tools/memory_add", response_model=MemoryAddResponse)
856
+ async def tools_memory_add(req: MemoryAddRequest) -> MemoryAddResponse:
857
+ sanitized = _sanitize_group_id(req.group_id)
858
+ async with _driver_lock:
859
+ await graphiti.add_episode(
860
+ name=f"manual-add-{int(time.time() * 1000)}",
861
+ episode_body=req.content,
862
+ source_description=req.source_description,
863
+ group_id=sanitized,
864
+ reference_time=datetime.now(timezone.utc),
865
+ source=EpisodeType.text,
866
+ entity_types=ontology_entity_types,
867
+ edge_types=ontology_edge_types,
868
+ edge_type_map=ontology_edge_type_map,
869
+ )
870
+ return MemoryAddResponse(status="stored")
871
+
872
+
873
+ app.include_router(router)