@susu-eng/gralkor 27.2.15 → 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,23 +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
614
|
ref_time = (
|
|
474
615
|
datetime.fromisoformat(req.reference_time)
|
|
475
616
|
if req.reference_time
|
|
476
617
|
else datetime.now(timezone.utc)
|
|
477
618
|
)
|
|
478
619
|
episode_type = EpisodeType(req.source) if req.source else EpisodeType.message
|
|
479
|
-
t0 = time.monotonic()
|
|
480
620
|
async with _driver_lock:
|
|
481
621
|
result = await graphiti.add_episode(
|
|
482
622
|
name=req.name,
|
|
@@ -490,10 +630,7 @@ async def add_episode(req: AddEpisodeRequest):
|
|
|
490
630
|
edge_type_map=ontology_edge_type_map,
|
|
491
631
|
excluded_entity_types=None,
|
|
492
632
|
)
|
|
493
|
-
duration_ms = (time.monotonic() - t0) * 1000
|
|
494
633
|
episode = result.episode
|
|
495
|
-
logger.info("[gralkor] episode added — uuid:%s duration:%.0fms", episode.uuid, duration_ms)
|
|
496
|
-
logger.debug("[gralkor] episode result: %s", _serialize_episode(episode))
|
|
497
634
|
serialized = _serialize_episode(episode)
|
|
498
635
|
_idempotency_store_result(req.idempotency_key, serialized)
|
|
499
636
|
return serialized
|
|
@@ -535,7 +672,6 @@ def _ensure_driver_graph(group_ids: list[str] | None) -> None:
|
|
|
535
672
|
try:
|
|
536
673
|
graphiti.driver = graphiti.driver.clone(database=target)
|
|
537
674
|
graphiti.clients.driver = graphiti.driver
|
|
538
|
-
print(f"[gralkor] driver graph routed: {target}", flush=True)
|
|
539
675
|
except Exception as e:
|
|
540
676
|
# Invalid group_id (e.g. hyphens rejected by FalkorDB). Skip routing
|
|
541
677
|
# so the search runs against the current graph and returns empty results
|
|
@@ -543,12 +679,10 @@ def _ensure_driver_graph(group_ids: list[str] | None) -> None:
|
|
|
543
679
|
logger.warning("[gralkor] driver graph routing failed for %s: %s", target, e)
|
|
544
680
|
|
|
545
681
|
|
|
546
|
-
@
|
|
682
|
+
@router.post("/search")
|
|
547
683
|
async def search(req: SearchRequest):
|
|
548
684
|
# Sanitize group IDs: hyphens cause RediSearch syntax errors in graphiti-core.
|
|
549
685
|
sanitized = [_sanitize_group_id(g) for g in req.group_ids]
|
|
550
|
-
logger.info("[gralkor] search — mode:%s query:%d chars group_ids:%s num_results:%d",
|
|
551
|
-
req.mode, len(req.query), sanitized, req.num_results)
|
|
552
686
|
# graphiti.add_episode() clones the driver to target the correct FalkorDB
|
|
553
687
|
# named graph (database=group_id), but graphiti.search() does not — it just
|
|
554
688
|
# uses whatever graph the driver currently points at. Before the first
|
|
@@ -582,23 +716,19 @@ async def search(req: SearchRequest):
|
|
|
582
716
|
duration_ms = (time.monotonic() - t0) * 1000
|
|
583
717
|
logger.error("[gralkor] search failed — mode:%s %.0fms: %s", req.mode, duration_ms, e)
|
|
584
718
|
raise
|
|
585
|
-
duration_ms = (time.monotonic() - t0) * 1000
|
|
586
719
|
result = [_serialize_fact(e) for e in edges]
|
|
587
720
|
serialized_nodes = [_serialize_node(n) for n in nodes]
|
|
588
|
-
logger.info("[gralkor] search result — mode:%s %d facts %d nodes %.0fms",
|
|
589
|
-
req.mode, len(result), len(serialized_nodes), duration_ms)
|
|
590
|
-
logger.debug("[gralkor] search facts: %s", result)
|
|
591
721
|
return {"facts": result, "nodes": serialized_nodes}
|
|
592
722
|
|
|
593
723
|
|
|
594
724
|
|
|
595
|
-
@
|
|
725
|
+
@router.post("/build-indices")
|
|
596
726
|
async def build_indices():
|
|
597
727
|
await graphiti.build_indices_and_constraints()
|
|
598
728
|
return {"status": "ok"}
|
|
599
729
|
|
|
600
730
|
|
|
601
|
-
@
|
|
731
|
+
@router.post("/build-communities")
|
|
602
732
|
async def build_communities(req: GroupIdRequest):
|
|
603
733
|
gid = _sanitize_group_id(req.group_id)
|
|
604
734
|
async with _driver_lock:
|
|
@@ -607,3 +737,137 @@ async def build_communities(req: GroupIdRequest):
|
|
|
607
737
|
group_ids=[gid],
|
|
608
738
|
)
|
|
609
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
|