@gralkor/openclaw 4.0.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.
Files changed (115) hide show
  1. package/.env.example +32 -0
  2. package/README.md +77 -0
  3. package/config.yaml +16 -0
  4. package/dist/config.d.ts +33 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +49 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/ctx-to-messages.d.ts +36 -0
  9. package/dist/ctx-to-messages.d.ts.map +1 -0
  10. package/dist/ctx-to-messages.js +120 -0
  11. package/dist/ctx-to-messages.js.map +1 -0
  12. package/dist/ctx-to-turn.d.ts +32 -0
  13. package/dist/ctx-to-turn.d.ts.map +1 -0
  14. package/dist/ctx-to-turn.js +55 -0
  15. package/dist/ctx-to-turn.js.map +1 -0
  16. package/dist/gralkor/client/http.d.ts +55 -0
  17. package/dist/gralkor/client/http.d.ts.map +1 -0
  18. package/dist/gralkor/client/http.js +150 -0
  19. package/dist/gralkor/client/http.js.map +1 -0
  20. package/dist/gralkor/client/in-memory.d.ts +38 -0
  21. package/dist/gralkor/client/in-memory.d.ts.map +1 -0
  22. package/dist/gralkor/client/in-memory.js +72 -0
  23. package/dist/gralkor/client/in-memory.js.map +1 -0
  24. package/dist/gralkor/client.d.ts +64 -0
  25. package/dist/gralkor/client.d.ts.map +1 -0
  26. package/dist/gralkor/client.js +32 -0
  27. package/dist/gralkor/client.js.map +1 -0
  28. package/dist/gralkor/config.d.ts +33 -0
  29. package/dist/gralkor/config.d.ts.map +1 -0
  30. package/dist/gralkor/config.js +58 -0
  31. package/dist/gralkor/config.js.map +1 -0
  32. package/dist/gralkor/connection.d.ts +20 -0
  33. package/dist/gralkor/connection.d.ts.map +1 -0
  34. package/dist/gralkor/connection.js +31 -0
  35. package/dist/gralkor/connection.js.map +1 -0
  36. package/dist/gralkor/index.d.ts +11 -0
  37. package/dist/gralkor/index.d.ts.map +1 -0
  38. package/dist/gralkor/index.js +6 -0
  39. package/dist/gralkor/index.js.map +1 -0
  40. package/dist/gralkor/server-env.d.ts +11 -0
  41. package/dist/gralkor/server-env.d.ts.map +1 -0
  42. package/dist/gralkor/server-env.js +26 -0
  43. package/dist/gralkor/server-env.js.map +1 -0
  44. package/dist/gralkor/server-manager.d.ts +58 -0
  45. package/dist/gralkor/server-manager.d.ts.map +1 -0
  46. package/dist/gralkor/server-manager.js +390 -0
  47. package/dist/gralkor/server-manager.js.map +1 -0
  48. package/dist/gralkor/testing.d.ts +10 -0
  49. package/dist/gralkor/testing.d.ts.map +1 -0
  50. package/dist/gralkor/testing.js +10 -0
  51. package/dist/gralkor/testing.js.map +1 -0
  52. package/dist/hooks/agent-end.d.ts +25 -0
  53. package/dist/hooks/agent-end.d.ts.map +1 -0
  54. package/dist/hooks/agent-end.js +51 -0
  55. package/dist/hooks/agent-end.js.map +1 -0
  56. package/dist/hooks/before-prompt-build.d.ts +12 -0
  57. package/dist/hooks/before-prompt-build.d.ts.map +1 -0
  58. package/dist/hooks/before-prompt-build.js +15 -0
  59. package/dist/hooks/before-prompt-build.js.map +1 -0
  60. package/dist/hooks/session-end.d.ts +18 -0
  61. package/dist/hooks/session-end.d.ts.map +1 -0
  62. package/dist/hooks/session-end.js +19 -0
  63. package/dist/hooks/session-end.js.map +1 -0
  64. package/dist/index.d.ts +130 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +133 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/native-indexer.d.ts +43 -0
  69. package/dist/native-indexer.d.ts.map +1 -0
  70. package/dist/native-indexer.js +107 -0
  71. package/dist/native-indexer.js.map +1 -0
  72. package/dist/register.d.ts +25 -0
  73. package/dist/register.d.ts.map +1 -0
  74. package/dist/register.js +184 -0
  75. package/dist/register.js.map +1 -0
  76. package/dist/session-map.d.ts +13 -0
  77. package/dist/session-map.d.ts.map +1 -0
  78. package/dist/session-map.js +32 -0
  79. package/dist/session-map.js.map +1 -0
  80. package/dist/tools/memory-add.d.ts +15 -0
  81. package/dist/tools/memory-add.d.ts.map +1 -0
  82. package/dist/tools/memory-add.js +15 -0
  83. package/dist/tools/memory-add.js.map +1 -0
  84. package/dist/tools/memory-build-communities.d.ts +19 -0
  85. package/dist/tools/memory-build-communities.d.ts.map +1 -0
  86. package/dist/tools/memory-build-communities.js +18 -0
  87. package/dist/tools/memory-build-communities.js.map +1 -0
  88. package/dist/tools/memory-build-indices.d.ts +12 -0
  89. package/dist/tools/memory-build-indices.d.ts.map +1 -0
  90. package/dist/tools/memory-build-indices.js +11 -0
  91. package/dist/tools/memory-build-indices.js.map +1 -0
  92. package/dist/tools/memory-search.d.ts +20 -0
  93. package/dist/tools/memory-search.d.ts.map +1 -0
  94. package/dist/tools/memory-search.js +18 -0
  95. package/dist/tools/memory-search.js.map +1 -0
  96. package/dist/types.d.ts +62 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +8 -0
  99. package/dist/types.js.map +1 -0
  100. package/openclaw.plugin.json +130 -0
  101. package/package.json +75 -0
  102. package/server/server/.python-version +1 -0
  103. package/server/server/main.py +902 -0
  104. package/server/server/pipelines/__init__.py +0 -0
  105. package/server/server/pipelines/capture_buffer.py +170 -0
  106. package/server/server/pipelines/distill.py +122 -0
  107. package/server/server/pipelines/formatting.py +48 -0
  108. package/server/server/pipelines/interpret.py +165 -0
  109. package/server/server/pipelines/messages.py +13 -0
  110. package/server/server/pyproject.toml +19 -0
  111. package/server/server/pytest.ini +4 -0
  112. package/server/server/requirements-dev.txt +3 -0
  113. package/server/server/requirements.txt +5 -0
  114. package/server/server/uv.lock +1162 -0
  115. package/server/wheels/falkordblite-0.9.0-py3-none-manylinux_2_36_aarch64.whl +0 -0
File without changes
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from typing import Awaitable, Callable
7
+
8
+ from google.genai.errors import APIError as _GenaiAPIError
9
+ from graphiti_core.llm_client.errors import RateLimitError as _GraphitiRateLimitError
10
+
11
+ from .messages import Message
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ DEFAULT_RETRY_DELAYS: tuple[float, ...] = (1.0, 2.0, 4.0)
18
+
19
+
20
+ # Exceptions raised by the Vertex-upstream path. These are NOT retried
21
+ # at the buffer layer — see gralkor/TEST_TREES.md > Retry ownership:
22
+ # 429 retry is owned by /recall only, and retrying upstream failures
23
+ # here would amplify load on an already-struggling upstream without
24
+ # a meaningful chance of success. `APIError` covers the SDK's
25
+ # 408/429/5xx/4xx path; graphiti's `RateLimitError` is what its
26
+ # GeminiClient raises on rate-limit classification.
27
+ _UPSTREAM_ERROR: tuple[type[BaseException], ...] = (
28
+ _GenaiAPIError,
29
+ _GraphitiRateLimitError,
30
+ )
31
+
32
+
33
+ class CaptureClientError(Exception):
34
+ """Raised when a downstream client returned a non-retryable 4xx response."""
35
+
36
+
37
+ FlushCallback = Callable[[str, str, list[list[Message]]], Awaitable[None]]
38
+
39
+
40
+ @dataclass
41
+ class _Entry:
42
+ group_id: str
43
+ agent_name: str
44
+ turns: list[list[Message]] = field(default_factory=list)
45
+
46
+
47
+ class CaptureBuffer:
48
+ def __init__(
49
+ self,
50
+ flush_callback: FlushCallback,
51
+ retry_delays: tuple[float, ...] = DEFAULT_RETRY_DELAYS,
52
+ ) -> None:
53
+ self._entries: dict[str, _Entry] = {}
54
+ self._flush_callback = flush_callback
55
+ self._retry_delays = retry_delays
56
+ self._pending_flushes: set[asyncio.Task[None]] = set()
57
+
58
+ def append(
59
+ self,
60
+ session_id: str,
61
+ group_id: str,
62
+ agent_name: str,
63
+ messages: list[Message],
64
+ ) -> None:
65
+ if agent_name is None or not str(agent_name).strip():
66
+ raise ValueError("agent_name is required and must be non-blank")
67
+ entry = self._entries.get(session_id)
68
+ if entry is None:
69
+ entry = _Entry(group_id=group_id, agent_name=agent_name)
70
+ self._entries[session_id] = entry
71
+ else:
72
+ if entry.group_id != group_id:
73
+ raise ValueError(
74
+ f"session_id {session_id!r} already bound to group_id "
75
+ f"{entry.group_id!r}; refusing to rebind to {group_id!r}"
76
+ )
77
+ if entry.agent_name != agent_name:
78
+ raise ValueError(
79
+ f"session_id {session_id!r} already bound to agent_name "
80
+ f"{entry.agent_name!r}; refusing to rebind to {agent_name!r}"
81
+ )
82
+ entry.turns.append(list(messages))
83
+
84
+ def turns_for(self, session_id: str) -> list[list[Message]]:
85
+ entry = self._entries.get(session_id)
86
+ return [list(turn) for turn in entry.turns] if entry is not None else []
87
+
88
+ async def _flush_with_retry(
89
+ self,
90
+ session_id: str,
91
+ group_id: str,
92
+ agent_name: str,
93
+ turns: list[list[Message]],
94
+ ) -> None:
95
+ attempt = 0
96
+ while True:
97
+ try:
98
+ await self._flush_callback(group_id, agent_name, turns)
99
+ return
100
+ except CaptureClientError as err:
101
+ logger.error(
102
+ "capture dropped (4xx) session=%s group=%s turns=%d err=%s",
103
+ session_id,
104
+ group_id,
105
+ len(turns),
106
+ err,
107
+ )
108
+ return
109
+ except _UPSTREAM_ERROR as err:
110
+ logger.error(
111
+ "capture dropped (upstream error — see Retry ownership) "
112
+ "session=%s group=%s turns=%d err=%s",
113
+ session_id,
114
+ group_id,
115
+ len(turns),
116
+ err,
117
+ )
118
+ return
119
+ except Exception as err:
120
+ if attempt >= len(self._retry_delays):
121
+ logger.error(
122
+ "capture exhausted session=%s group=%s turns=%d err=%s",
123
+ session_id,
124
+ group_id,
125
+ len(turns),
126
+ err,
127
+ )
128
+ return
129
+ logger.warning(
130
+ "capture retry session=%s group=%s attempt=%d err=%s",
131
+ session_id,
132
+ group_id,
133
+ attempt + 1,
134
+ err,
135
+ )
136
+ await asyncio.sleep(self._retry_delays[attempt])
137
+ attempt += 1
138
+
139
+ def flush(self, session_id: str) -> None:
140
+ entry = self._entries.pop(session_id, None)
141
+ if entry is None:
142
+ return
143
+ task = asyncio.create_task(
144
+ self._flush_with_retry(session_id, entry.group_id, entry.agent_name, entry.turns)
145
+ )
146
+ self._pending_flushes.add(task)
147
+ task.add_done_callback(self._pending_flushes.discard)
148
+
149
+ async def flush_all(self) -> None:
150
+ for session_id in list(self._entries.keys()):
151
+ entry = self._entries.pop(session_id, None)
152
+ if entry is None:
153
+ continue
154
+ if entry.turns:
155
+ task = asyncio.create_task(
156
+ self._flush_with_retry(
157
+ session_id, entry.group_id, entry.agent_name, entry.turns
158
+ )
159
+ )
160
+ self._pending_flushes.add(task)
161
+ task.add_done_callback(self._pending_flushes.discard)
162
+ if self._pending_flushes:
163
+ await asyncio.gather(*self._pending_flushes, return_exceptions=True)
164
+
165
+ @property
166
+ def pending_count(self) -> int:
167
+ return sum(len(entry.turns) for entry in self._entries.values())
168
+
169
+ def has(self, session_id: str) -> bool:
170
+ return session_id in self._entries
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from .messages import Message
10
+
11
+ if TYPE_CHECKING:
12
+ from graphiti_core.llm_client import LLMClient
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ DISTILL_SYSTEM_PROMPT = (
19
+ "You are a distillery for agentic thought and action. You will be given an agent's actions "
20
+ "during a turn, alongside the user's request and the agent's response for context. Write one "
21
+ "to two sentences in first person past tense capturing the reasoning, decisions, and actions "
22
+ "that drove the outcome — including dead ends and intermediary steps, not just the final "
23
+ "response. When the agent searched memory, do not restate the recalled facts — note only "
24
+ "that memory was consulted and what the agent concluded. Output only the distilled text."
25
+ )
26
+
27
+
28
+ class DistillResult(BaseModel):
29
+ behaviour: str
30
+
31
+
32
+ def _require_agent_name(agent_name: str) -> None:
33
+ if agent_name is None or not str(agent_name).strip():
34
+ raise ValueError("agent_name is required and must be non-blank")
35
+
36
+
37
+ def _render_label(role: str, agent_name: str) -> str:
38
+ if role == "user":
39
+ return "User"
40
+ if role == "assistant":
41
+ return agent_name
42
+ if role == "behaviour":
43
+ return agent_name
44
+ return role.capitalize()
45
+
46
+
47
+ def _build_distill_input(messages: list[Message], agent_name: str) -> str:
48
+ has_behaviour = any(m.role == "behaviour" and m.content.strip() for m in messages)
49
+ if not has_behaviour:
50
+ return ""
51
+
52
+ lines: list[str] = []
53
+ for msg in messages:
54
+ text = msg.content.strip()
55
+ if not text:
56
+ continue
57
+ lines.append(f"{_render_label(msg.role, agent_name)}: {text}")
58
+ return "\n".join(lines)
59
+
60
+
61
+ async def _distill_one(llm_client: "LLMClient", thinking: str) -> str:
62
+ from graphiti_core.prompts.models import Message as LLMMessage
63
+
64
+ prompt = [
65
+ LLMMessage(role="system", content=DISTILL_SYSTEM_PROMPT),
66
+ LLMMessage(role="user", content=thinking),
67
+ ]
68
+ response = await llm_client.generate_response(
69
+ prompt,
70
+ response_model=DistillResult,
71
+ max_tokens=150,
72
+ )
73
+ if isinstance(response, dict):
74
+ return (response.get("behaviour") or "").strip()
75
+ return ""
76
+
77
+
78
+ async def safe_distill(llm_client: "LLMClient", thinking: str) -> str:
79
+ if not thinking.strip():
80
+ return ""
81
+ try:
82
+ return await _distill_one(llm_client, thinking)
83
+ except Exception as err:
84
+ logger.warning("behaviour distillation failed: %s", err)
85
+ return ""
86
+
87
+
88
+ async def format_transcript(
89
+ turns: list[list[Message]],
90
+ llm_client: "LLMClient | None",
91
+ agent_name: str,
92
+ ) -> str:
93
+ _require_agent_name(agent_name)
94
+
95
+ distill_inputs = [(i, _build_distill_input(turn, agent_name)) for i, turn in enumerate(turns)]
96
+ distill_inputs = [(i, text) for i, text in distill_inputs if text]
97
+
98
+ summaries: dict[int, str] = {}
99
+ if distill_inputs and llm_client is not None:
100
+ results = await asyncio.gather(
101
+ *(safe_distill(llm_client, text) for _, text in distill_inputs)
102
+ )
103
+ for (i, _), summary in zip(distill_inputs, results):
104
+ if summary:
105
+ summaries[i] = summary
106
+
107
+ lines: list[str] = []
108
+ for i, turn in enumerate(turns):
109
+ user_texts = [m.content.strip() for m in turn if m.role == "user" and m.content.strip()]
110
+ for text in user_texts:
111
+ lines.append(f"User: {text}")
112
+
113
+ summary = summaries.get(i)
114
+ if summary:
115
+ lines.append(f"{agent_name}: (behaviour: {summary})")
116
+
117
+ answer_texts = [
118
+ m.content.strip() for m in turn if m.role == "assistant" and m.content.strip()
119
+ ]
120
+ for text in answer_texts:
121
+ lines.append(f"{agent_name}: {text}")
122
+ return "\n".join(lines)
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+
7
+ _FRACTIONAL_SECONDS = re.compile(r"\.\d+")
8
+ _TRAILING_Z = re.compile(r"Z$")
9
+ _TZ_OFFSET = re.compile(r"([+-])(\d{2}):(\d{2})$")
10
+
11
+
12
+ def format_timestamp(ts: str) -> str:
13
+ s = _FRACTIONAL_SECONDS.sub("", ts)
14
+ s = _TRAILING_Z.sub("+0", s)
15
+
16
+ def _compact(match: re.Match[str]) -> str:
17
+ sign, hours_str, minutes_str = match.group(1), match.group(2), match.group(3)
18
+ hours = str(int(hours_str))
19
+ if minutes_str == "00":
20
+ return f"{sign}{hours}"
21
+ return f"{sign}{hours}:{minutes_str}"
22
+
23
+ return _TZ_OFFSET.sub(_compact, s)
24
+
25
+
26
+ def format_fact(fact: dict[str, Any]) -> str:
27
+ parts = [f"- {fact['fact']}"]
28
+ if fact.get("created_at"):
29
+ parts.append(f" (created {format_timestamp(fact['created_at'])})")
30
+ if fact.get("valid_at"):
31
+ parts.append(f" (valid from {format_timestamp(fact['valid_at'])})")
32
+ if fact.get("invalid_at"):
33
+ parts.append(f" (invalid since {format_timestamp(fact['invalid_at'])})")
34
+ if fact.get("expired_at"):
35
+ parts.append(f" (expired {format_timestamp(fact['expired_at'])})")
36
+ return "".join(parts)
37
+
38
+
39
+ def format_facts(facts: list[dict[str, Any]]) -> str:
40
+ if not facts:
41
+ return "No graph facts found."
42
+ lines = "\n".join(format_fact(f) for f in facts)
43
+ return f"Facts:\n{lines}"
44
+
45
+
46
+ def format_node(node: dict[str, Any]) -> str:
47
+ summary = node.get("summary") or "(no summary)"
48
+ return f"- {node['name']}: {summary}"
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from .messages import Message
8
+
9
+ if TYPE_CHECKING:
10
+ from graphiti_core.llm_client import LLMClient
11
+
12
+
13
+ INTERPRET_SYSTEM_PROMPT = (
14
+ "You are reviewing recalled memory facts for an agent mid-conversation. "
15
+ "Each input fact is one line beginning with '- ' and may carry one or more "
16
+ "timestamp parentheticals such as '(created …)', '(valid from …)', "
17
+ "'(invalid since …)', '(expired …)'.\n\n"
18
+ "Return only the facts that bear on the current task. For each one, produce "
19
+ "a single string built from two parts joined by ' — ':\n"
20
+ " 1. The original fact copied verbatim WITHOUT the leading '- '. Preserve "
21
+ "every timestamp parenthetical exactly as given. Do not paraphrase, "
22
+ "summarise, merge facts, drop timestamps, or reformat them.\n"
23
+ " 2. One short sentence explaining why this fact is relevant to the "
24
+ "current task.\n\n"
25
+ "Example output entry: "
26
+ "'Alice works at Acme (valid from 2024-01-01) (expired 2025-06-01) — "
27
+ "confirms her former employer, which the user just asked about.'\n\n"
28
+ "Skip facts with no bearing on the current task. If no facts are relevant, "
29
+ "return an empty list. Do not return prose, prefixes, bullets, numbering, "
30
+ "or any wrapping object — only the list of strings in the schema."
31
+ )
32
+
33
+ INTERPRET_TOKEN_BUDGET = 250_000
34
+ _CHARS_PER_TOKEN = 4
35
+ INTERPRET_CHAR_BUDGET = INTERPRET_TOKEN_BUDGET * _CHARS_PER_TOKEN
36
+
37
+ DEFAULT_OUTPUT_TOKEN_BUDGET = 2000
38
+
39
+
40
+ class InterpretParseFailed(Exception):
41
+ """Raised when the LLM's interpret response cannot be parsed against the
42
+ InterpretResult schema — typically because output_token_budget was too
43
+ small and the response truncated mid-list. Distinct from generic
44
+ RuntimeError so callers can catch parse failure specifically."""
45
+
46
+ def __init__(self, message: str, raw_response: object = None) -> None:
47
+ super().__init__(message)
48
+ self.raw_response = raw_response
49
+
50
+
51
+ class InterpretResult(BaseModel):
52
+ relevantFacts: list[str] = Field(
53
+ description=(
54
+ "List of relevant facts. Each entry is the original fact line "
55
+ "copied verbatim (without the leading '- ', preserving every "
56
+ "timestamp parenthetical such as '(valid from …)', '(invalid "
57
+ "since …)', '(expired …)', '(created …)'), followed by ' — ' "
58
+ "and one short sentence explaining why this fact is relevant. "
59
+ "Empty list if nothing is relevant. No prose, no bullets, no "
60
+ "numbering."
61
+ )
62
+ )
63
+
64
+
65
+ def _require_agent_name(agent_name: str) -> None:
66
+ if agent_name is None or not str(agent_name).strip():
67
+ raise ValueError("agent_name is required and must be non-blank")
68
+
69
+
70
+ def _require_positive_int(name: str, value: object) -> int:
71
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
72
+ raise ValueError(f"{name} must be a positive integer, got {value!r}")
73
+ return value
74
+
75
+
76
+ def _render_label(role: str, agent_name: str) -> str:
77
+ if role == "user":
78
+ return "User"
79
+ if role == "assistant":
80
+ return agent_name
81
+ if role == "behaviour":
82
+ return agent_name
83
+ return role.capitalize()
84
+
85
+
86
+ def _budget_instruction(output_token_budget: int) -> str:
87
+ return (
88
+ f"Respond within {output_token_budget} tokens. Keep each relevance "
89
+ f"reason short so the full list fits."
90
+ )
91
+
92
+
93
+ def build_interpretation_context(
94
+ messages: list[Message],
95
+ facts_text: str,
96
+ agent_name: str,
97
+ char_budget: int = INTERPRET_CHAR_BUDGET,
98
+ ) -> str:
99
+ _require_agent_name(agent_name)
100
+ lines: list[str] = []
101
+ for msg in messages:
102
+ text = msg.content.strip()
103
+ if not text:
104
+ continue
105
+ if msg.role == "behaviour":
106
+ lines.append(f"{agent_name}: (behaviour: {text})")
107
+ else:
108
+ lines.append(f"{_render_label(msg.role, agent_name)}: {text}")
109
+
110
+ budget = char_budget
111
+ trimmed: list[str] = []
112
+ for line in reversed(lines):
113
+ if budget <= 0:
114
+ break
115
+ trimmed.insert(0, line)
116
+ budget -= len(line)
117
+
118
+ return (
119
+ "Conversation context:\n"
120
+ + "\n".join(trimmed)
121
+ + "\n\nMemory facts to interpret:\n"
122
+ + facts_text
123
+ )
124
+
125
+
126
+ async def interpret_facts(
127
+ messages: list[Message],
128
+ facts_text: str,
129
+ llm_client: "LLMClient",
130
+ agent_name: str,
131
+ output_token_budget: int = DEFAULT_OUTPUT_TOKEN_BUDGET,
132
+ ) -> list[str]:
133
+ _require_agent_name(agent_name)
134
+ _require_positive_int("output_token_budget", output_token_budget)
135
+ if llm_client is None:
136
+ raise RuntimeError(
137
+ "interpret_facts: llm_client is required (configure an LLM provider API key)"
138
+ )
139
+
140
+ from graphiti_core.prompts.models import Message as LLMMessage
141
+
142
+ context = build_interpretation_context(messages, facts_text, agent_name)
143
+ context_with_budget = context + "\n\n" + _budget_instruction(output_token_budget)
144
+ prompt = [
145
+ LLMMessage(role="system", content=INTERPRET_SYSTEM_PROMPT),
146
+ LLMMessage(role="user", content=context_with_budget),
147
+ ]
148
+
149
+ response = await llm_client.generate_response(
150
+ prompt,
151
+ response_model=InterpretResult,
152
+ max_tokens=output_token_budget,
153
+ )
154
+ if not isinstance(response, dict):
155
+ raise InterpretParseFailed(
156
+ "interpret_facts: response was not a dict (schema mismatch or truncation)",
157
+ raw_response=response,
158
+ )
159
+ raw = response.get("relevantFacts")
160
+ if not isinstance(raw, list):
161
+ raise InterpretParseFailed(
162
+ "interpret_facts: relevantFacts missing or not a list (schema mismatch or truncation)",
163
+ raw_response=response,
164
+ )
165
+ return [str(item).strip() for item in raw if str(item).strip()]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ Role = Literal["user", "assistant", "behaviour"]
9
+
10
+
11
+ class Message(BaseModel):
12
+ role: Role
13
+ content: str
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "gralkor-server"
3
+ version = "0.0.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "graphiti-core[falkordb,anthropic,google-genai,groq]>=0.28.2",
7
+ "falkordblite",
8
+ "fastapi>=0.115",
9
+ "uvicorn>=0.34",
10
+ "pyyaml>=6.0",
11
+ ]
12
+
13
+ [dependency-groups]
14
+ dev = [
15
+ "pytest>=8.0",
16
+ "pytest-asyncio>=0.24",
17
+ "pytest-spec>=6.0",
18
+ "httpx>=0.27",
19
+ ]
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ addopts = --spec
4
+ norecursedirs = mutants
@@ -0,0 +1,3 @@
1
+ pytest>=8.0
2
+ pytest-asyncio>=0.24
3
+ httpx>=0.27
@@ -0,0 +1,5 @@
1
+ graphiti-core[falkordb]>=0.28
2
+ falkordblite
3
+ fastapi>=0.115
4
+ uvicorn>=0.34
5
+ pyyaml>=6.0