@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
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@susu-eng/gralkor",
|
|
3
3
|
"displayName": "Gralkor",
|
|
4
|
-
"version": "27.
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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)
|
|
Binary file
|