hipcortex 0.2.0__py3-none-any.whl
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.
- hipcortex/__init__.py +15 -0
- hipcortex/adapters/__init__.py +1 -0
- hipcortex/adapters/autogen.py +156 -0
- hipcortex/adapters/crewai.py +153 -0
- hipcortex/async_client.py +255 -0
- hipcortex/cli.py +480 -0
- hipcortex/client.py +223 -0
- hipcortex/install/SKILL.md +114 -0
- hipcortex/install/__init__.py +1 -0
- hipcortex/langchain_memory.py +238 -0
- hipcortex/llamaindex_storage.py +107 -0
- hipcortex-0.2.0.dist-info/METADATA +108 -0
- hipcortex-0.2.0.dist-info/RECORD +16 -0
- hipcortex-0.2.0.dist-info/WHEEL +5 -0
- hipcortex-0.2.0.dist-info/entry_points.txt +2 -0
- hipcortex-0.2.0.dist-info/top_level.txt +1 -0
hipcortex/client.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""HipCortex HTTP client — thin wrapper around the REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HipCortexClient:
|
|
12
|
+
"""Synchronous HTTP client for the HipCortex memory server.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
base_url: Root URL of the running hipcortex web-server binary.
|
|
16
|
+
timeout: Per-request timeout in seconds.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, base_url: str = "http://localhost:3000", timeout: float = 10.0) -> None:
|
|
20
|
+
self.base_url = base_url.rstrip("/")
|
|
21
|
+
self.timeout = timeout
|
|
22
|
+
self._session = requests.Session()
|
|
23
|
+
|
|
24
|
+
# ------------------------------------------------------------------
|
|
25
|
+
# Core memory operations
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def add_memory(
|
|
29
|
+
self,
|
|
30
|
+
actor: str,
|
|
31
|
+
action: str,
|
|
32
|
+
target: str,
|
|
33
|
+
record_type: str = "Temporal",
|
|
34
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
35
|
+
tags: Optional[List[str]] = None,
|
|
36
|
+
priority: str = "normal", # "pinned"|"high"|"normal"|"low"
|
|
37
|
+
) -> Dict[str, Any]:
|
|
38
|
+
"""Store a memory record.
|
|
39
|
+
|
|
40
|
+
Returns the server response dict with ``success`` and ``record_id``.
|
|
41
|
+
"""
|
|
42
|
+
payload = {
|
|
43
|
+
"actor": actor,
|
|
44
|
+
"action": action,
|
|
45
|
+
"target": target,
|
|
46
|
+
"record_type": record_type,
|
|
47
|
+
"metadata": metadata or {},
|
|
48
|
+
}
|
|
49
|
+
if tags:
|
|
50
|
+
payload["tags"] = tags
|
|
51
|
+
if priority != "normal":
|
|
52
|
+
payload["priority"] = priority
|
|
53
|
+
resp = self._session.post(
|
|
54
|
+
f"{self.base_url}/memory/add", json=payload, timeout=self.timeout
|
|
55
|
+
)
|
|
56
|
+
resp.raise_for_status()
|
|
57
|
+
return resp.json()
|
|
58
|
+
|
|
59
|
+
def query_memory(
|
|
60
|
+
self,
|
|
61
|
+
actor: Optional[str] = None,
|
|
62
|
+
action: Optional[str] = None,
|
|
63
|
+
record_type: Optional[str] = None,
|
|
64
|
+
limit: int = 100,
|
|
65
|
+
tags: Optional[List[str]] = None, # filter by tags (any match)
|
|
66
|
+
as_of: Optional[str] = None, # ISO 8601 timestamp for time-travel query
|
|
67
|
+
) -> List[Dict[str, Any]]:
|
|
68
|
+
"""Query memory records. Returns a list of record dicts."""
|
|
69
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
70
|
+
if actor is not None:
|
|
71
|
+
params["actor"] = actor
|
|
72
|
+
if action is not None:
|
|
73
|
+
params["action"] = action
|
|
74
|
+
if record_type is not None:
|
|
75
|
+
params["record_type"] = record_type
|
|
76
|
+
if tags is not None:
|
|
77
|
+
params["tags"] = ",".join(tags)
|
|
78
|
+
if as_of is not None:
|
|
79
|
+
params["as_of"] = as_of
|
|
80
|
+
resp = self._session.get(
|
|
81
|
+
f"{self.base_url}/memory/query", params=params, timeout=self.timeout
|
|
82
|
+
)
|
|
83
|
+
resp.raise_for_status()
|
|
84
|
+
return resp.json().get("records", [])
|
|
85
|
+
|
|
86
|
+
def search(
|
|
87
|
+
self,
|
|
88
|
+
query: str,
|
|
89
|
+
embedding: Optional[List[float]] = None,
|
|
90
|
+
limit: int = 10,
|
|
91
|
+
) -> List[Dict[str, Any]]:
|
|
92
|
+
"""Semantic + keyword search over stored memory records.
|
|
93
|
+
|
|
94
|
+
If ``embedding`` is provided, ranks results by cosine similarity
|
|
95
|
+
against records that carry a ``metadata.embedding`` float array.
|
|
96
|
+
Falls back to keyword matching when embeddings are absent.
|
|
97
|
+
|
|
98
|
+
Returns a list of ``{"score": float, "record": {...}}`` dicts,
|
|
99
|
+
sorted by descending score.
|
|
100
|
+
"""
|
|
101
|
+
payload: Dict[str, Any] = {"query": query, "limit": limit}
|
|
102
|
+
if embedding is not None:
|
|
103
|
+
payload["embedding"] = embedding
|
|
104
|
+
resp = self._session.post(
|
|
105
|
+
f"{self.base_url}/memory/search", json=payload, timeout=self.timeout
|
|
106
|
+
)
|
|
107
|
+
resp.raise_for_status()
|
|
108
|
+
return resp.json().get("results", [])
|
|
109
|
+
|
|
110
|
+
def forget(self, actor: str) -> Dict[str, Any]:
|
|
111
|
+
"""GDPR right-to-forget: delete all records for ``actor``.
|
|
112
|
+
|
|
113
|
+
Returns ``{"success": bool, "records_deleted": int, "symbolic_nodes_deleted": int}``.
|
|
114
|
+
"""
|
|
115
|
+
resp = self._session.delete(
|
|
116
|
+
f"{self.base_url}/memory/forget/{actor}", timeout=self.timeout
|
|
117
|
+
)
|
|
118
|
+
resp.raise_for_status()
|
|
119
|
+
return resp.json()
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Symbolic graph
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def graph(self) -> Dict[str, Any]:
|
|
126
|
+
"""Return the full symbolic graph (nodes + edges)."""
|
|
127
|
+
resp = self._session.get(f"{self.base_url}/graph", timeout=self.timeout)
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
return resp.json()
|
|
130
|
+
|
|
131
|
+
def get_node(self, node_id: str) -> Optional[Dict[str, Any]]:
|
|
132
|
+
"""Fetch a single symbolic node by UUID."""
|
|
133
|
+
resp = self._session.get(f"{self.base_url}/node/{node_id}", timeout=self.timeout)
|
|
134
|
+
resp.raise_for_status()
|
|
135
|
+
return resp.json()
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# System
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def health(self) -> bool:
|
|
142
|
+
"""Return True if server is reachable and healthy."""
|
|
143
|
+
try:
|
|
144
|
+
resp = self._session.get(f"{self.base_url}/health", timeout=self.timeout)
|
|
145
|
+
return resp.status_code == 200
|
|
146
|
+
except requests.RequestException:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
def coherence_status(self) -> Dict[str, Any]:
|
|
150
|
+
"""Return the current coherence metrics from the server."""
|
|
151
|
+
resp = self._session.get(f"{self.base_url}/coherence/status", timeout=self.timeout)
|
|
152
|
+
resp.raise_for_status()
|
|
153
|
+
return resp.json()
|
|
154
|
+
|
|
155
|
+
def stats(self) -> Dict[str, Any]:
|
|
156
|
+
"""Return live server statistics: record counts, type breakdown, metering state."""
|
|
157
|
+
resp = self._session.get(f"{self.base_url}/stats", timeout=self.timeout)
|
|
158
|
+
resp.raise_for_status()
|
|
159
|
+
return resp.json()
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Convenience helpers used by framework adapters
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def add_human_message(self, session_id: str, content: str) -> Dict[str, Any]:
|
|
166
|
+
return self.add_memory(
|
|
167
|
+
actor=session_id,
|
|
168
|
+
action="human_message",
|
|
169
|
+
target=content,
|
|
170
|
+
record_type="Temporal",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def add_ai_message(self, session_id: str, content: str) -> Dict[str, Any]:
|
|
174
|
+
return self.add_memory(
|
|
175
|
+
actor=session_id,
|
|
176
|
+
action="ai_message",
|
|
177
|
+
target=content,
|
|
178
|
+
record_type="Reflexion",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def get_conversation_history(
|
|
182
|
+
self, session_id: str, limit: int = 50
|
|
183
|
+
) -> List[Dict[str, Any]]:
|
|
184
|
+
return self.query_memory(actor=session_id, limit=limit)
|
|
185
|
+
|
|
186
|
+
def create_node(self, label: str, properties: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
187
|
+
"""Create a symbolic knowledge graph node."""
|
|
188
|
+
resp = self._session.post(
|
|
189
|
+
f"{self.base_url}/graph/node",
|
|
190
|
+
json={"label": label, "properties": properties or {}},
|
|
191
|
+
timeout=self.timeout,
|
|
192
|
+
)
|
|
193
|
+
resp.raise_for_status()
|
|
194
|
+
return resp.json()
|
|
195
|
+
|
|
196
|
+
def create_edge(self, from_id: str, to_id: str, relation: str) -> Dict[str, Any]:
|
|
197
|
+
"""Create a relationship edge in the knowledge graph."""
|
|
198
|
+
resp = self._session.post(
|
|
199
|
+
f"{self.base_url}/graph/edge",
|
|
200
|
+
json={"from_id": from_id, "to_id": to_id, "relation": relation},
|
|
201
|
+
timeout=self.timeout,
|
|
202
|
+
)
|
|
203
|
+
resp.raise_for_status()
|
|
204
|
+
return resp.json()
|
|
205
|
+
|
|
206
|
+
def consolidate(self, actor: Optional[str] = None, threshold: float = 0.8, dry_run: bool = True) -> Dict[str, Any]:
|
|
207
|
+
"""Find near-duplicate memories and optionally merge them."""
|
|
208
|
+
params: Dict[str, Any] = {"threshold": threshold, "dry_run": dry_run}
|
|
209
|
+
if actor is not None:
|
|
210
|
+
params["actor"] = actor
|
|
211
|
+
resp = self._session.post(
|
|
212
|
+
f"{self.base_url}/memory/consolidate",
|
|
213
|
+
params=params,
|
|
214
|
+
timeout=self.timeout,
|
|
215
|
+
)
|
|
216
|
+
resp.raise_for_status()
|
|
217
|
+
return resp.json()
|
|
218
|
+
|
|
219
|
+
def ping_latency_ms(self) -> float:
|
|
220
|
+
"""Return round-trip latency to /health in milliseconds."""
|
|
221
|
+
t0 = time.perf_counter()
|
|
222
|
+
self.health()
|
|
223
|
+
return (time.perf_counter() - t0) * 1000
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# HipCortex Memory
|
|
2
|
+
|
|
3
|
+
HipCortex gives you persistent causal memory across sessions. Store decisions, recall context, forget on request.
|
|
4
|
+
|
|
5
|
+
## When to use
|
|
6
|
+
|
|
7
|
+
Invoke HipCortex when the user asks you to:
|
|
8
|
+
- Remember something ("remember that we use JWT")
|
|
9
|
+
- Recall past context ("what did we decide about the database?")
|
|
10
|
+
- Forget data ("forget everything about project X")
|
|
11
|
+
- Store a decision, bug fix, or architectural note
|
|
12
|
+
|
|
13
|
+
## How to use
|
|
14
|
+
|
|
15
|
+
**Store a memory:**
|
|
16
|
+
```
|
|
17
|
+
POST http://localhost:3030/memory/add
|
|
18
|
+
{"actor": "<project-or-user>", "action": "decided", "target": "<what to remember>"}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Search memories:**
|
|
22
|
+
```
|
|
23
|
+
GET http://localhost:3030/memory/search-flat?query=<topic>&limit=10
|
|
24
|
+
Returns: {"memories": ["[action] target", ...]}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Forget (GDPR):**
|
|
28
|
+
```
|
|
29
|
+
DELETE http://localhost:3030/memory/forget/<actor>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Stats:**
|
|
33
|
+
```
|
|
34
|
+
GET http://localhost:3030/stats
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Slash commands
|
|
38
|
+
|
|
39
|
+
When the user types `/hipcortex remember <text>` — call POST /memory/add with actor=current-project-name.
|
|
40
|
+
When the user types `/hipcortex recall <query>` — call GET /memory/search-flat?query=<query>.
|
|
41
|
+
When the user types `/hipcortex latest <topic>` — call GET /memory/latest?actor=<project>&action=<topic> to get the most recent fact.
|
|
42
|
+
When the user types `/hipcortex update <id> <corrected text>` — call PATCH /memory/update/<id> with {"target": "<corrected text>"} to fix a wrong memory.
|
|
43
|
+
When the user types `/hipcortex forget <actor>` — call DELETE /memory/forget/<actor>.
|
|
44
|
+
When the user types `/hipcortex stats` — call GET /stats and display the result.
|
|
45
|
+
|
|
46
|
+
## Correction workflow
|
|
47
|
+
|
|
48
|
+
When the user says "that's wrong, it should be X" about a previously stored memory:
|
|
49
|
+
1. Search for the wrong memory: GET /memory/search-flat?query=<topic>
|
|
50
|
+
2. Get the record id from the result
|
|
51
|
+
3. Update it: PATCH /memory/update/<id> {"target": "<correct text>", "confidence": 1.0}
|
|
52
|
+
4. Confirm: "✓ Memory corrected (version N)"
|
|
53
|
+
|
|
54
|
+
## Confidence scoring
|
|
55
|
+
|
|
56
|
+
When storing uncertain or inferred information, include confidence:
|
|
57
|
+
POST /memory/add {"confidence": 0.6, "source": "inferred", ...}
|
|
58
|
+
When storing verified user-provided facts: confidence=1.0 (default)
|
|
59
|
+
When storing LLM-generated inferences: confidence=0.7
|
|
60
|
+
When storing speculation: confidence=0.3
|
|
61
|
+
|
|
62
|
+
## Tags and priority
|
|
63
|
+
|
|
64
|
+
When storing memories, add tags for RAG filtering:
|
|
65
|
+
```
|
|
66
|
+
POST /memory/add {"tags": ["architecture", "decision"], "priority": "normal", ...}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Priority values:
|
|
70
|
+
- "pinned" — always returned in search, bypass decay. Use for safety constraints, hard rules.
|
|
71
|
+
- "high" — weighted higher in results
|
|
72
|
+
- "normal" — default
|
|
73
|
+
- "low" — fades faster
|
|
74
|
+
|
|
75
|
+
Example: store an allergy constraint that must never be forgotten:
|
|
76
|
+
POST /memory/add {"priority": "pinned", "confidence": 1.0, "action": "constraint", "target": "User is allergic to penicillin"}
|
|
77
|
+
|
|
78
|
+
## Knowledge graph
|
|
79
|
+
|
|
80
|
+
Write relationships to the symbolic knowledge graph:
|
|
81
|
+
```
|
|
82
|
+
POST /graph/node {"label": "Alice", "properties": {"role": "CEO"}}
|
|
83
|
+
POST /graph/edge {"from_id": "<alice-uuid>", "to_id": "<project-uuid>", "relation": "MANAGES"}
|
|
84
|
+
GET /graph → view full graph
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Time-travel queries
|
|
88
|
+
|
|
89
|
+
Query memory state at a past timestamp:
|
|
90
|
+
GET /memory/query?as_of=2026-01-15T00:00:00Z&actor=alice
|
|
91
|
+
|
|
92
|
+
Useful for auditing: "what did the agent know about user alice on January 15th?"
|
|
93
|
+
|
|
94
|
+
## Deduplication
|
|
95
|
+
|
|
96
|
+
Find near-duplicate memories:
|
|
97
|
+
POST /memory/consolidate?actor=my-project&threshold=0.8&dry_run=true
|
|
98
|
+
→ Returns pairs of similar records with "keep" and "drop" IDs
|
|
99
|
+
→ Use GDPR forget to remove duplicates: DELETE /memory/forget/<actor> for specific cleanup
|
|
100
|
+
|
|
101
|
+
## Auto-memory mode
|
|
102
|
+
|
|
103
|
+
If the user says "remember this" at the end of any message, automatically store a summary of the conversation turn in HipCortex before responding.
|
|
104
|
+
|
|
105
|
+
## Default actor
|
|
106
|
+
|
|
107
|
+
Use the current git repository name as the actor (run `git rev-parse --show-toplevel | xargs basename` to get it). Fall back to "default" if not in a git repo.
|
|
108
|
+
|
|
109
|
+
## Server
|
|
110
|
+
|
|
111
|
+
Default: http://localhost:3030
|
|
112
|
+
Managed free tier: https://hipcortex.fly.dev (set HIPCORTEX_URL to override)
|
|
113
|
+
|
|
114
|
+
The server must be running. If unreachable, tell the user to run: `hipcortex start`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# HipCortex install package — contains SKILL.md for Claude Code registration
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""LangChain-compatible memory backed by HipCortex.
|
|
2
|
+
|
|
3
|
+
Drop-in replacement for ``ConversationBufferMemory`` in any LangChain chain.
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
from hipcortex import HipCortexClient
|
|
8
|
+
from hipcortex.langchain_memory import HipCortexMemory
|
|
9
|
+
from langchain.chains import ConversationChain
|
|
10
|
+
from langchain.chat_models import ChatOpenAI
|
|
11
|
+
|
|
12
|
+
client = HipCortexClient(base_url="http://localhost:3000")
|
|
13
|
+
memory = HipCortexMemory(client=client, session_id="user-42")
|
|
14
|
+
|
|
15
|
+
chain = ConversationChain(llm=ChatOpenAI(), memory=memory)
|
|
16
|
+
chain.predict(input="Hello!")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Dict, List
|
|
22
|
+
|
|
23
|
+
from .client import HipCortexClient
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from langchain.schema import BaseMemory
|
|
27
|
+
from langchain.schema.messages import AIMessage, HumanMessage, BaseMessage
|
|
28
|
+
_LANGCHAIN_AVAILABLE = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
_LANGCHAIN_AVAILABLE = False
|
|
31
|
+
# Provide a no-op base so the class can be imported without LangChain installed
|
|
32
|
+
class BaseMemory: # type: ignore[no-redef]
|
|
33
|
+
def save_context(self, inputs: Dict, outputs: Dict) -> None: ...
|
|
34
|
+
def load_memory_variables(self, inputs: Dict) -> Dict: ...
|
|
35
|
+
def clear(self) -> None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HipCortexMemory(BaseMemory):
|
|
39
|
+
"""LangChain ``BaseMemory`` implementation backed by HipCortex REST API.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
client: Configured :class:`~hipcortex.client.HipCortexClient`.
|
|
43
|
+
session_id: Scopes all memory reads/writes to this actor identifier.
|
|
44
|
+
memory_key: Key used when injecting history into the chain's prompt vars
|
|
45
|
+
(default: ``"history"``).
|
|
46
|
+
human_prefix: Label prepended to human turns in the history string.
|
|
47
|
+
ai_prefix: Label prepended to AI turns in the history string.
|
|
48
|
+
max_records: Max records fetched per ``load_memory_variables`` call.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
# Not using pydantic model here to stay LangChain version-agnostic
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
client: HipCortexClient,
|
|
56
|
+
session_id: str = "default",
|
|
57
|
+
memory_key: str = "history",
|
|
58
|
+
human_prefix: str = "Human",
|
|
59
|
+
ai_prefix: str = "AI",
|
|
60
|
+
max_records: int = 50,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.client = client
|
|
63
|
+
self.session_id = session_id
|
|
64
|
+
self.memory_key = memory_key
|
|
65
|
+
self.human_prefix = human_prefix
|
|
66
|
+
self.ai_prefix = ai_prefix
|
|
67
|
+
self.max_records = max_records
|
|
68
|
+
|
|
69
|
+
# Required by BaseMemory -------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def memory_variables(self) -> List[str]:
|
|
73
|
+
return [self.memory_key]
|
|
74
|
+
|
|
75
|
+
def load_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
|
76
|
+
"""Fetch conversation history from HipCortex and format as a string."""
|
|
77
|
+
records = self.client.get_conversation_history(
|
|
78
|
+
self.session_id, limit=self.max_records
|
|
79
|
+
)
|
|
80
|
+
# Sort oldest-first
|
|
81
|
+
records.sort(key=lambda r: r.get("timestamp", ""))
|
|
82
|
+
|
|
83
|
+
lines: List[str] = []
|
|
84
|
+
for rec in records:
|
|
85
|
+
action = rec.get("action", "")
|
|
86
|
+
target = rec.get("target", "")
|
|
87
|
+
if action == "human_message":
|
|
88
|
+
lines.append(f"{self.human_prefix}: {target}")
|
|
89
|
+
elif action == "ai_message":
|
|
90
|
+
lines.append(f"{self.ai_prefix}: {target}")
|
|
91
|
+
|
|
92
|
+
return {self.memory_key: "\n".join(lines)}
|
|
93
|
+
|
|
94
|
+
def save_context(self, inputs: Dict[str, Any], outputs: Dict[str, Any]) -> None:
|
|
95
|
+
"""Persist a human→AI exchange to HipCortex."""
|
|
96
|
+
human_text = inputs.get("input") or inputs.get("human_input") or ""
|
|
97
|
+
ai_text = outputs.get("output") or outputs.get("response") or ""
|
|
98
|
+
if human_text:
|
|
99
|
+
self.client.add_human_message(self.session_id, str(human_text))
|
|
100
|
+
if ai_text:
|
|
101
|
+
self.client.add_ai_message(self.session_id, str(ai_text))
|
|
102
|
+
|
|
103
|
+
def clear(self) -> None:
|
|
104
|
+
"""Invoke GDPR forget for the current session."""
|
|
105
|
+
self.client.forget(self.session_id)
|
|
106
|
+
|
|
107
|
+
# Optional helper --------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def get_messages(self) -> List[Any]:
|
|
110
|
+
"""Return conversation history as LangChain ``BaseMessage`` objects.
|
|
111
|
+
|
|
112
|
+
Requires LangChain to be installed; raises ``ImportError`` otherwise.
|
|
113
|
+
"""
|
|
114
|
+
if not _LANGCHAIN_AVAILABLE:
|
|
115
|
+
raise ImportError("langchain is required: pip install langchain")
|
|
116
|
+
records = self.client.get_conversation_history(
|
|
117
|
+
self.session_id, limit=self.max_records
|
|
118
|
+
)
|
|
119
|
+
records.sort(key=lambda r: r.get("timestamp", ""))
|
|
120
|
+
messages: List[BaseMessage] = []
|
|
121
|
+
for rec in records:
|
|
122
|
+
target = rec.get("target", "")
|
|
123
|
+
if rec.get("action") == "human_message":
|
|
124
|
+
messages.append(HumanMessage(content=target))
|
|
125
|
+
elif rec.get("action") == "ai_message":
|
|
126
|
+
messages.append(AIMessage(content=target))
|
|
127
|
+
return messages
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AsyncHipCortexMemory:
|
|
131
|
+
"""Async-native LangChain BaseMemory backed by AsyncHipCortexClient.
|
|
132
|
+
|
|
133
|
+
Use with async LangChain chains (LangChain 0.2+, FastAPI, Django async).
|
|
134
|
+
Implements aload_memory_variables and asave_context as coroutines.
|
|
135
|
+
|
|
136
|
+
Usage::
|
|
137
|
+
|
|
138
|
+
from hipcortex import AsyncHipCortexClient
|
|
139
|
+
from hipcortex.langchain_memory import AsyncHipCortexMemory
|
|
140
|
+
|
|
141
|
+
async def chat(user_input: str):
|
|
142
|
+
client = AsyncHipCortexClient("http://localhost:3030")
|
|
143
|
+
memory = AsyncHipCortexMemory(client=client, session_id="user-42")
|
|
144
|
+
history = await memory.aload_memory_variables({})
|
|
145
|
+
await memory.asave_context({"input": user_input}, {"output": ai_response})
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
client: "Any",
|
|
151
|
+
session_id: str = "default",
|
|
152
|
+
memory_key: str = "history",
|
|
153
|
+
human_prefix: str = "Human",
|
|
154
|
+
ai_prefix: str = "AI",
|
|
155
|
+
max_limit: int = 50,
|
|
156
|
+
) -> None:
|
|
157
|
+
self._client = client
|
|
158
|
+
self.session_id = session_id
|
|
159
|
+
self.memory_key = memory_key
|
|
160
|
+
self.human_prefix = human_prefix
|
|
161
|
+
self.ai_prefix = ai_prefix
|
|
162
|
+
self.max_limit = max_limit
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def memory_variables(self) -> List[str]:
|
|
166
|
+
return [self.memory_key]
|
|
167
|
+
|
|
168
|
+
async def aload_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
|
169
|
+
"""Fetch conversation history as formatted string (async)."""
|
|
170
|
+
records = await self._client.get_conversation_history(
|
|
171
|
+
self.session_id, limit=self.max_limit
|
|
172
|
+
)
|
|
173
|
+
records.sort(key=lambda r: r.get("timestamp", ""))
|
|
174
|
+
lines: List[str] = []
|
|
175
|
+
for rec in records:
|
|
176
|
+
action = rec.get("action", "")
|
|
177
|
+
target = rec.get("target", "")
|
|
178
|
+
if action == "human_message":
|
|
179
|
+
lines.append(f"{self.human_prefix}: {target}")
|
|
180
|
+
elif action == "ai_message":
|
|
181
|
+
lines.append(f"{self.ai_prefix}: {target}")
|
|
182
|
+
return {self.memory_key: "\n".join(lines)}
|
|
183
|
+
|
|
184
|
+
async def asave_context(
|
|
185
|
+
self, inputs: Dict[str, Any], outputs: Dict[str, Any]
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Persist a human->AI exchange asynchronously."""
|
|
188
|
+
human_text = inputs.get("input") or inputs.get("human_input") or ""
|
|
189
|
+
ai_text = outputs.get("output") or outputs.get("response") or ""
|
|
190
|
+
if human_text:
|
|
191
|
+
await self._client.add_human_message(self.session_id, str(human_text))
|
|
192
|
+
if ai_text:
|
|
193
|
+
await self._client.add_ai_message(self.session_id, str(ai_text))
|
|
194
|
+
|
|
195
|
+
async def aclear(self) -> None:
|
|
196
|
+
"""GDPR forget for this session."""
|
|
197
|
+
await self._client.forget(self.session_id)
|
|
198
|
+
|
|
199
|
+
def load_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
|
200
|
+
"""Sync fallback using asyncio.run()."""
|
|
201
|
+
import asyncio
|
|
202
|
+
import concurrent.futures
|
|
203
|
+
try:
|
|
204
|
+
loop = asyncio.get_event_loop()
|
|
205
|
+
if loop.is_running():
|
|
206
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
207
|
+
return pool.submit(asyncio.run, self.aload_memory_variables(inputs)).result()
|
|
208
|
+
return loop.run_until_complete(self.aload_memory_variables(inputs))
|
|
209
|
+
except RuntimeError:
|
|
210
|
+
return asyncio.run(self.aload_memory_variables(inputs))
|
|
211
|
+
|
|
212
|
+
def save_context(self, inputs: Dict[str, Any], outputs: Dict[str, Any]) -> None:
|
|
213
|
+
"""Sync fallback using asyncio.run()."""
|
|
214
|
+
import asyncio
|
|
215
|
+
import concurrent.futures
|
|
216
|
+
try:
|
|
217
|
+
loop = asyncio.get_event_loop()
|
|
218
|
+
if loop.is_running():
|
|
219
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
220
|
+
pool.submit(asyncio.run, self.asave_context(inputs, outputs)).result()
|
|
221
|
+
return
|
|
222
|
+
loop.run_until_complete(self.asave_context(inputs, outputs))
|
|
223
|
+
except RuntimeError:
|
|
224
|
+
asyncio.run(self.asave_context(inputs, outputs))
|
|
225
|
+
|
|
226
|
+
def clear(self) -> None:
|
|
227
|
+
"""Sync fallback using asyncio.run()."""
|
|
228
|
+
import asyncio
|
|
229
|
+
import concurrent.futures
|
|
230
|
+
try:
|
|
231
|
+
loop = asyncio.get_event_loop()
|
|
232
|
+
if loop.is_running():
|
|
233
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
234
|
+
pool.submit(asyncio.run, self.aclear()).result()
|
|
235
|
+
return
|
|
236
|
+
loop.run_until_complete(self.aclear())
|
|
237
|
+
except RuntimeError:
|
|
238
|
+
asyncio.run(self.aclear())
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""LlamaIndex storage context wrapper backed by HipCortex.
|
|
2
|
+
|
|
3
|
+
Provides a ``HipCortexStorageContext`` that can be passed wherever LlamaIndex
|
|
4
|
+
accepts a ``StorageContext``, and a ``HipCortexChatStore`` compatible with
|
|
5
|
+
``SimpleChatStore`` for chat-engine memory.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from hipcortex import HipCortexClient
|
|
10
|
+
from hipcortex.llamaindex_storage import HipCortexChatStore
|
|
11
|
+
from llama_index.core.memory import ChatMemoryBuffer
|
|
12
|
+
|
|
13
|
+
client = HipCortexClient(base_url="http://localhost:3000")
|
|
14
|
+
store = HipCortexChatStore(client=client)
|
|
15
|
+
|
|
16
|
+
memory = ChatMemoryBuffer.from_defaults(
|
|
17
|
+
token_limit=3000,
|
|
18
|
+
chat_store=store,
|
|
19
|
+
chat_store_key="user-42",
|
|
20
|
+
)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
26
|
+
|
|
27
|
+
from .client import HipCortexClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HipCortexChatStore:
|
|
31
|
+
"""Drop-in for ``SimpleChatStore`` in LlamaIndex chat engines.
|
|
32
|
+
|
|
33
|
+
Stores each chat message as a HipCortex ``MemoryRecord`` keyed by
|
|
34
|
+
``session_key`` (the ``chat_store_key`` param in LlamaIndex).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, client: HipCortexClient) -> None:
|
|
38
|
+
self.client = client
|
|
39
|
+
|
|
40
|
+
# LlamaIndex SimpleChatStore interface ----------------------------------
|
|
41
|
+
|
|
42
|
+
def set_messages(self, key: str, messages: Sequence[Any]) -> None:
|
|
43
|
+
"""Overwrite messages for ``key`` by forgetting then re-adding."""
|
|
44
|
+
self.client.forget(key)
|
|
45
|
+
for msg in messages:
|
|
46
|
+
role = getattr(msg, "role", "user")
|
|
47
|
+
content = getattr(msg, "content", str(msg))
|
|
48
|
+
action = "ai_message" if role in ("assistant", "ai") else "human_message"
|
|
49
|
+
self.client.add_memory(
|
|
50
|
+
actor=key,
|
|
51
|
+
action=action,
|
|
52
|
+
target=content,
|
|
53
|
+
record_type="Temporal" if role != "assistant" else "Reflexion",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def get_messages(self, key: str) -> List[Any]:
|
|
57
|
+
"""Return raw record dicts (caller converts to ChatMessage objects)."""
|
|
58
|
+
records = self.client.get_conversation_history(key, limit=200)
|
|
59
|
+
records.sort(key=lambda r: r.get("timestamp", ""))
|
|
60
|
+
return records
|
|
61
|
+
|
|
62
|
+
def add_message(self, key: str, message: Any) -> None:
|
|
63
|
+
role = getattr(message, "role", "user")
|
|
64
|
+
content = getattr(message, "content", str(message))
|
|
65
|
+
action = "ai_message" if role in ("assistant", "ai") else "human_message"
|
|
66
|
+
self.client.add_memory(
|
|
67
|
+
actor=key,
|
|
68
|
+
action=action,
|
|
69
|
+
target=content,
|
|
70
|
+
record_type="Temporal" if role != "assistant" else "Reflexion",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def delete_messages(self, key: str) -> Optional[List[Any]]:
|
|
74
|
+
msgs = self.get_messages(key)
|
|
75
|
+
self.client.forget(key)
|
|
76
|
+
return msgs
|
|
77
|
+
|
|
78
|
+
def delete_message(self, key: str, idx: int) -> Optional[Any]:
|
|
79
|
+
"""Not efficiently supported; falls back to forget-and-reinsert."""
|
|
80
|
+
msgs = self.get_messages(key)
|
|
81
|
+
if idx < 0 or idx >= len(msgs):
|
|
82
|
+
return None
|
|
83
|
+
removed = msgs.pop(idx)
|
|
84
|
+
self.set_messages(key, msgs) # type: ignore[arg-type]
|
|
85
|
+
return removed
|
|
86
|
+
|
|
87
|
+
def get_keys(self) -> List[str]:
|
|
88
|
+
"""Returns empty list — full key enumeration requires graph query."""
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class HipCortexStorageContext:
|
|
93
|
+
"""Lightweight wrapper that holds a ``HipCortexChatStore`` and exposes
|
|
94
|
+
a ``StorageContext``-like surface for LlamaIndex.
|
|
95
|
+
|
|
96
|
+
For simple use-cases (chat engines, memory buffers) this is sufficient.
|
|
97
|
+
For full index persistence (VectorStore, DocStore, IndexStore) use
|
|
98
|
+
LlamaIndex's native backends alongside HipCortex for chat memory only.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, client: HipCortexClient) -> None:
|
|
102
|
+
self.client = client
|
|
103
|
+
self.chat_store = HipCortexChatStore(client=client)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_url(cls, base_url: str = "http://localhost:3000") -> "HipCortexStorageContext":
|
|
107
|
+
return cls(client=HipCortexClient(base_url=base_url))
|