sulcus 0.3.0__tar.gz
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.
- sulcus-0.3.0/.gitignore +61 -0
- sulcus-0.3.0/LICENSE +21 -0
- sulcus-0.3.0/PKG-INFO +180 -0
- sulcus-0.3.0/README.md +148 -0
- sulcus-0.3.0/pyproject.toml +41 -0
- sulcus-0.3.0/sulcus/__init__.py +24 -0
- sulcus-0.3.0/sulcus/client.py +919 -0
sulcus-0.3.0/.gitignore
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
target/
|
|
2
|
+
target/**
|
|
3
|
+
node_modules/
|
|
4
|
+
*/node_modules/**
|
|
5
|
+
.env
|
|
6
|
+
.env.local
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.log
|
|
10
|
+
.DS_Store
|
|
11
|
+
.vscode/
|
|
12
|
+
.idea/
|
|
13
|
+
*.swp
|
|
14
|
+
*.swo
|
|
15
|
+
.next/
|
|
16
|
+
.nuxt/
|
|
17
|
+
out/
|
|
18
|
+
.cache/
|
|
19
|
+
.turbo/
|
|
20
|
+
.yarn/cache/
|
|
21
|
+
.yarn/unplugged/
|
|
22
|
+
.pnp.*
|
|
23
|
+
coverage/
|
|
24
|
+
*.tsbuildinfo
|
|
25
|
+
.eslintcache
|
|
26
|
+
.prettierignore
|
|
27
|
+
Cargo.lock
|
|
28
|
+
*.pdb
|
|
29
|
+
*.rlib
|
|
30
|
+
.cargo/
|
|
31
|
+
.rustup/
|
|
32
|
+
rust-toolchain.lock
|
|
33
|
+
tools/openclaw-integration/node_modules/*
|
|
34
|
+
**/.fastembed_cache/**
|
|
35
|
+
tmp/**
|
|
36
|
+
tmp**
|
|
37
|
+
|
|
38
|
+
# SULCUS Security Gaps
|
|
39
|
+
vm_info.json
|
|
40
|
+
*.tar.gz
|
|
41
|
+
sorted_timings.txt
|
|
42
|
+
id_rsa
|
|
43
|
+
id_ed25519
|
|
44
|
+
*.pem
|
|
45
|
+
*.key
|
|
46
|
+
tools/openclaw-integration/tmp-home/
|
|
47
|
+
orchestration_logs/
|
|
48
|
+
reviewer_report.txt
|
|
49
|
+
final_review.txt
|
|
50
|
+
agent*.txt
|
|
51
|
+
validator_*.txt
|
|
52
|
+
*.db
|
|
53
|
+
! crates/sulcus-local/tests/data/*.db
|
|
54
|
+
benchmark_server.sh
|
|
55
|
+
deploy_azure.sh
|
|
56
|
+
update_azure.sh
|
|
57
|
+
deploy_hardened_server.sh
|
|
58
|
+
packages/sulcus-web/.build-bust
|
|
59
|
+
packages/membench/**/__pycache__
|
|
60
|
+
__pycache__/
|
|
61
|
+
*.pyc
|
sulcus-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SULCUS Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sulcus-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sulcus
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Sulcus — Thermodynamic Memory for AI Agents
|
|
5
|
+
Project-URL: Homepage, https://sulcus.dforge.ca
|
|
6
|
+
Project-URL: Documentation, https://sulcus.dforge.ca/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/digitalforgeca/sulcus
|
|
8
|
+
Project-URL: Issues, https://github.com/digitalforgeca/sulcus/issues
|
|
9
|
+
Author-email: Guardrail Technologies <hello@sulcus.dev>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,ai,llm,mcp,memory,thermodynamic
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: async
|
|
26
|
+
Requires-Dist: httpx>=0.25.0; extra == 'async'
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Sulcus Python SDK
|
|
34
|
+
|
|
35
|
+
**Thermodynamic memory for AI agents.** Zero dependencies.
|
|
36
|
+
|
|
37
|
+
Sulcus is a memory system where physics decides what to forget. Memories have heat — hot memories are instantly accessible, cold ones fade naturally. CRDT sync keeps agents in lockstep.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install sulcus
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For async support:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install sulcus[async]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from sulcus import Sulcus
|
|
55
|
+
|
|
56
|
+
client = Sulcus(api_key="sk-...")
|
|
57
|
+
|
|
58
|
+
# Remember something
|
|
59
|
+
client.remember("User prefers dark mode", memory_type="preference")
|
|
60
|
+
client.remember("Meeting with design team at 3pm", memory_type="episodic")
|
|
61
|
+
client.remember("API rate limit is 1000 req/min", memory_type="semantic")
|
|
62
|
+
|
|
63
|
+
# Search memories
|
|
64
|
+
results = client.search("dark mode")
|
|
65
|
+
for m in results:
|
|
66
|
+
print(f"[{m.memory_type}] {m.pointer_summary} (heat: {m.current_heat:.2f})")
|
|
67
|
+
|
|
68
|
+
# List hot memories
|
|
69
|
+
memories = client.list(limit=10)
|
|
70
|
+
|
|
71
|
+
# Update a memory
|
|
72
|
+
client.update(memories[0].id, label="Updated preference")
|
|
73
|
+
|
|
74
|
+
# Pin important memories (prevents decay)
|
|
75
|
+
client.pin(memories[0].id)
|
|
76
|
+
|
|
77
|
+
# Forget
|
|
78
|
+
client.forget(memories[0].id)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Async
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import asyncio
|
|
85
|
+
from sulcus import AsyncSulcus
|
|
86
|
+
|
|
87
|
+
async def main():
|
|
88
|
+
async with AsyncSulcus(api_key="sk-...") as client:
|
|
89
|
+
await client.remember("async memory", memory_type="semantic")
|
|
90
|
+
results = await client.search("async")
|
|
91
|
+
print(results)
|
|
92
|
+
|
|
93
|
+
asyncio.run(main())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Self-Hosted
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
client = Sulcus(
|
|
100
|
+
api_key="your-key",
|
|
101
|
+
base_url="http://localhost:4200",
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Memory Lifecycle Control
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# Store with full control over retention
|
|
109
|
+
client.remember(
|
|
110
|
+
"Deploy procedure for production",
|
|
111
|
+
memory_type="procedural",
|
|
112
|
+
decay_class="permanent", # volatile | normal | stable | permanent
|
|
113
|
+
is_pinned=True, # Prevents decay below min_heat
|
|
114
|
+
min_heat=0.5, # Floor — never decays below this
|
|
115
|
+
key_points=["docker build", "az containerapp update", "DEPLOY_TS trick"],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Bulk update multiple memories at once
|
|
119
|
+
client.bulk_update(
|
|
120
|
+
ids=["mem-1", "mem-2", "mem-3"],
|
|
121
|
+
is_pinned=True,
|
|
122
|
+
decay_class="stable",
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Memory Types
|
|
127
|
+
|
|
128
|
+
| Type | Description | Default Decay |
|
|
129
|
+
|------|-------------|---------------|
|
|
130
|
+
| `episodic` | Events, conversations, experiences | Fast |
|
|
131
|
+
| `semantic` | Facts, knowledge, definitions | Slow |
|
|
132
|
+
| `preference` | User preferences, settings | Medium |
|
|
133
|
+
| `procedural` | How-to knowledge, workflows | Slow |
|
|
134
|
+
| `fact` | Stable knowledge, decisions | Near-permanent |
|
|
135
|
+
|
|
136
|
+
## API
|
|
137
|
+
|
|
138
|
+
### `Sulcus(api_key, base_url?, namespace?, timeout?)`
|
|
139
|
+
|
|
140
|
+
Create a client. `base_url` defaults to Sulcus Cloud.
|
|
141
|
+
|
|
142
|
+
### `.remember(content, *, memory_type?, decay_class?, is_pinned?, min_heat?, key_points?, namespace?) -> Memory`
|
|
143
|
+
|
|
144
|
+
Store a memory with full lifecycle control. `decay_class` controls retention speed (`volatile`, `normal`, `stable`, `permanent`). `key_points` are indexed for better recall.
|
|
145
|
+
|
|
146
|
+
### `.search(query, *, limit?, memory_type?, namespace?) -> list[Memory]`
|
|
147
|
+
|
|
148
|
+
Text search. Results sorted by heat (most active first).
|
|
149
|
+
|
|
150
|
+
### `.list(*, limit?, offset?, memory_type?, namespace?) -> list[Memory]`
|
|
151
|
+
|
|
152
|
+
List memories with optional filters.
|
|
153
|
+
|
|
154
|
+
### `.get(memory_id) -> Memory`
|
|
155
|
+
|
|
156
|
+
Get a single memory by ID.
|
|
157
|
+
|
|
158
|
+
### `.update(memory_id, *, label?, memory_type?, is_pinned?, namespace?, heat?) -> Memory`
|
|
159
|
+
|
|
160
|
+
Update fields on a memory.
|
|
161
|
+
|
|
162
|
+
### `.forget(memory_id) -> bool`
|
|
163
|
+
|
|
164
|
+
Permanently delete a memory.
|
|
165
|
+
|
|
166
|
+
### `.pin(memory_id) / .unpin(memory_id) -> Memory`
|
|
167
|
+
|
|
168
|
+
Pin/unpin a memory. Pinned memories don't decay.
|
|
169
|
+
|
|
170
|
+
### `.whoami() -> dict`
|
|
171
|
+
|
|
172
|
+
Get account/org info.
|
|
173
|
+
|
|
174
|
+
### `.metrics() -> dict`
|
|
175
|
+
|
|
176
|
+
Get storage and health metrics.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
sulcus-0.3.0/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Sulcus Python SDK
|
|
2
|
+
|
|
3
|
+
**Thermodynamic memory for AI agents.** Zero dependencies.
|
|
4
|
+
|
|
5
|
+
Sulcus is a memory system where physics decides what to forget. Memories have heat — hot memories are instantly accessible, cold ones fade naturally. CRDT sync keeps agents in lockstep.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install sulcus
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For async support:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install sulcus[async]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from sulcus import Sulcus
|
|
23
|
+
|
|
24
|
+
client = Sulcus(api_key="sk-...")
|
|
25
|
+
|
|
26
|
+
# Remember something
|
|
27
|
+
client.remember("User prefers dark mode", memory_type="preference")
|
|
28
|
+
client.remember("Meeting with design team at 3pm", memory_type="episodic")
|
|
29
|
+
client.remember("API rate limit is 1000 req/min", memory_type="semantic")
|
|
30
|
+
|
|
31
|
+
# Search memories
|
|
32
|
+
results = client.search("dark mode")
|
|
33
|
+
for m in results:
|
|
34
|
+
print(f"[{m.memory_type}] {m.pointer_summary} (heat: {m.current_heat:.2f})")
|
|
35
|
+
|
|
36
|
+
# List hot memories
|
|
37
|
+
memories = client.list(limit=10)
|
|
38
|
+
|
|
39
|
+
# Update a memory
|
|
40
|
+
client.update(memories[0].id, label="Updated preference")
|
|
41
|
+
|
|
42
|
+
# Pin important memories (prevents decay)
|
|
43
|
+
client.pin(memories[0].id)
|
|
44
|
+
|
|
45
|
+
# Forget
|
|
46
|
+
client.forget(memories[0].id)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Async
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from sulcus import AsyncSulcus
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
async with AsyncSulcus(api_key="sk-...") as client:
|
|
57
|
+
await client.remember("async memory", memory_type="semantic")
|
|
58
|
+
results = await client.search("async")
|
|
59
|
+
print(results)
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Self-Hosted
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
client = Sulcus(
|
|
68
|
+
api_key="your-key",
|
|
69
|
+
base_url="http://localhost:4200",
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Memory Lifecycle Control
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# Store with full control over retention
|
|
77
|
+
client.remember(
|
|
78
|
+
"Deploy procedure for production",
|
|
79
|
+
memory_type="procedural",
|
|
80
|
+
decay_class="permanent", # volatile | normal | stable | permanent
|
|
81
|
+
is_pinned=True, # Prevents decay below min_heat
|
|
82
|
+
min_heat=0.5, # Floor — never decays below this
|
|
83
|
+
key_points=["docker build", "az containerapp update", "DEPLOY_TS trick"],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Bulk update multiple memories at once
|
|
87
|
+
client.bulk_update(
|
|
88
|
+
ids=["mem-1", "mem-2", "mem-3"],
|
|
89
|
+
is_pinned=True,
|
|
90
|
+
decay_class="stable",
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Memory Types
|
|
95
|
+
|
|
96
|
+
| Type | Description | Default Decay |
|
|
97
|
+
|------|-------------|---------------|
|
|
98
|
+
| `episodic` | Events, conversations, experiences | Fast |
|
|
99
|
+
| `semantic` | Facts, knowledge, definitions | Slow |
|
|
100
|
+
| `preference` | User preferences, settings | Medium |
|
|
101
|
+
| `procedural` | How-to knowledge, workflows | Slow |
|
|
102
|
+
| `fact` | Stable knowledge, decisions | Near-permanent |
|
|
103
|
+
|
|
104
|
+
## API
|
|
105
|
+
|
|
106
|
+
### `Sulcus(api_key, base_url?, namespace?, timeout?)`
|
|
107
|
+
|
|
108
|
+
Create a client. `base_url` defaults to Sulcus Cloud.
|
|
109
|
+
|
|
110
|
+
### `.remember(content, *, memory_type?, decay_class?, is_pinned?, min_heat?, key_points?, namespace?) -> Memory`
|
|
111
|
+
|
|
112
|
+
Store a memory with full lifecycle control. `decay_class` controls retention speed (`volatile`, `normal`, `stable`, `permanent`). `key_points` are indexed for better recall.
|
|
113
|
+
|
|
114
|
+
### `.search(query, *, limit?, memory_type?, namespace?) -> list[Memory]`
|
|
115
|
+
|
|
116
|
+
Text search. Results sorted by heat (most active first).
|
|
117
|
+
|
|
118
|
+
### `.list(*, limit?, offset?, memory_type?, namespace?) -> list[Memory]`
|
|
119
|
+
|
|
120
|
+
List memories with optional filters.
|
|
121
|
+
|
|
122
|
+
### `.get(memory_id) -> Memory`
|
|
123
|
+
|
|
124
|
+
Get a single memory by ID.
|
|
125
|
+
|
|
126
|
+
### `.update(memory_id, *, label?, memory_type?, is_pinned?, namespace?, heat?) -> Memory`
|
|
127
|
+
|
|
128
|
+
Update fields on a memory.
|
|
129
|
+
|
|
130
|
+
### `.forget(memory_id) -> bool`
|
|
131
|
+
|
|
132
|
+
Permanently delete a memory.
|
|
133
|
+
|
|
134
|
+
### `.pin(memory_id) / .unpin(memory_id) -> Memory`
|
|
135
|
+
|
|
136
|
+
Pin/unpin a memory. Pinned memories don't decay.
|
|
137
|
+
|
|
138
|
+
### `.whoami() -> dict`
|
|
139
|
+
|
|
140
|
+
Get account/org info.
|
|
141
|
+
|
|
142
|
+
### `.metrics() -> dict`
|
|
143
|
+
|
|
144
|
+
Get storage and health metrics.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sulcus"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Sulcus — Thermodynamic Memory for AI Agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Guardrail Technologies", email = "hello@sulcus.dev" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["memory", "ai", "agents", "mcp", "llm", "thermodynamic"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Zero required dependencies — stdlib only
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
async = ["httpx>=0.25.0"]
|
|
35
|
+
dev = ["pytest", "pytest-asyncio", "httpx"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://sulcus.dforge.ca"
|
|
39
|
+
Documentation = "https://sulcus.dforge.ca/docs"
|
|
40
|
+
Repository = "https://github.com/digitalforgeca/sulcus"
|
|
41
|
+
Issues = "https://github.com/digitalforgeca/sulcus/issues"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Sulcus — Persistent Memory with Reactive Triggers for AI Agents.
|
|
2
|
+
|
|
3
|
+
Minimal Python SDK. Zero required dependencies beyond the stdlib.
|
|
4
|
+
Optional: httpx for async support.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from sulcus import Sulcus
|
|
8
|
+
|
|
9
|
+
client = Sulcus(api_key="sk-...")
|
|
10
|
+
client.remember("User prefers dark mode", memory_type="preference")
|
|
11
|
+
results = client.search("dark mode")
|
|
12
|
+
|
|
13
|
+
# Triggers — reactive rules on your memory graph
|
|
14
|
+
client.create_trigger("on_recall", "pin", name="Auto-pin recalled memories")
|
|
15
|
+
client.create_trigger("on_store", "notify",
|
|
16
|
+
name="Procedure alert",
|
|
17
|
+
action_config={"message": "New procedure: {label}"},
|
|
18
|
+
filter_memory_type="procedural")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from sulcus.client import Sulcus, AsyncSulcus, SulcusError, Memory
|
|
22
|
+
|
|
23
|
+
__version__ = "0.3.0"
|
|
24
|
+
__all__ = ["Sulcus", "AsyncSulcus", "SulcusError", "Memory", "__version__"]
|
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
"""Sulcus Python SDK — zero-dependency client for the Sulcus Memory API.
|
|
2
|
+
|
|
3
|
+
Uses only urllib from the standard library. Install `httpx` for async support.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import urllib.request
|
|
10
|
+
import urllib.error
|
|
11
|
+
from dataclasses import dataclass, field, asdict
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Types
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Memory:
|
|
21
|
+
"""A single memory node from the Sulcus golden index."""
|
|
22
|
+
id: str
|
|
23
|
+
pointer_summary: str
|
|
24
|
+
memory_type: str = "episodic"
|
|
25
|
+
current_heat: float = 0.0
|
|
26
|
+
base_utility: float = 0.0
|
|
27
|
+
is_pinned: bool = False
|
|
28
|
+
modality: str = "text"
|
|
29
|
+
namespace: str = "default"
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Memory":
|
|
33
|
+
# Handle both field name variants across endpoints
|
|
34
|
+
summary = d.get("pointer_summary") or d.get("label", "")
|
|
35
|
+
heat = d.get("current_heat") or d.get("heat") or 0.0
|
|
36
|
+
return cls(
|
|
37
|
+
id=str(d.get("id", "")),
|
|
38
|
+
pointer_summary=summary,
|
|
39
|
+
memory_type=d.get("memory_type", "episodic"),
|
|
40
|
+
current_heat=float(heat),
|
|
41
|
+
base_utility=float(d.get("base_utility", 0)),
|
|
42
|
+
is_pinned=bool(d.get("is_pinned", False)),
|
|
43
|
+
modality=d.get("modality", "text"),
|
|
44
|
+
namespace=d.get("namespace", "default"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
48
|
+
return asdict(self)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SulcusError(Exception):
|
|
52
|
+
"""Raised when the Sulcus API returns an error."""
|
|
53
|
+
def __init__(self, status: int, message: str):
|
|
54
|
+
self.status = status
|
|
55
|
+
self.message = message
|
|
56
|
+
super().__init__(f"SulcusError({status}): {message}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Sync Client (stdlib only — zero dependencies)
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
class Sulcus:
|
|
64
|
+
"""Synchronous Sulcus client. Uses only urllib (stdlib).
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
api_key: Sulcus API key (sk-... format or legacy token).
|
|
68
|
+
base_url: Server URL. Defaults to Sulcus Cloud.
|
|
69
|
+
namespace: Default namespace for operations.
|
|
70
|
+
timeout: HTTP timeout in seconds.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
DEFAULT_URL = "https://server.sulcus.dforge.ca"
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
api_key: str,
|
|
78
|
+
base_url: str = DEFAULT_URL,
|
|
79
|
+
namespace: str = "default",
|
|
80
|
+
timeout: int = 30,
|
|
81
|
+
):
|
|
82
|
+
self.api_key = api_key
|
|
83
|
+
self.base_url = base_url.rstrip("/")
|
|
84
|
+
self.namespace = namespace
|
|
85
|
+
self.timeout = timeout
|
|
86
|
+
|
|
87
|
+
# -- Core API ----------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def remember(
|
|
90
|
+
self,
|
|
91
|
+
content: str,
|
|
92
|
+
*,
|
|
93
|
+
memory_type: str = "episodic",
|
|
94
|
+
heat: float = 0.8,
|
|
95
|
+
namespace: Optional[str] = None,
|
|
96
|
+
decay_class: Optional[str] = None,
|
|
97
|
+
is_pinned: bool = False,
|
|
98
|
+
min_heat: Optional[float] = None,
|
|
99
|
+
key_points: Optional[List[str]] = None,
|
|
100
|
+
) -> Memory:
|
|
101
|
+
"""Store a memory. Returns the created Memory node.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
content: The text to remember. Supports Markdown formatting —
|
|
105
|
+
use headers, lists, and emphasis to structure key points.
|
|
106
|
+
memory_type: One of 'episodic', 'semantic', 'preference',
|
|
107
|
+
'procedural', 'moment'.
|
|
108
|
+
heat: Initial heat (0.0–1.0). Higher = more accessible.
|
|
109
|
+
namespace: Override the default namespace.
|
|
110
|
+
decay_class: Decay speed override — 'fast', 'normal', 'slow',
|
|
111
|
+
'glacial'. Overrides the default for the memory_type.
|
|
112
|
+
is_pinned: Pin to prevent decay entirely.
|
|
113
|
+
min_heat: Floor heat value (0.0–1.0). Memory never decays below this.
|
|
114
|
+
key_points: Key takeaways as a list of strings. Stored as
|
|
115
|
+
structured metadata for better recall and context building.
|
|
116
|
+
"""
|
|
117
|
+
body: Dict[str, Any] = {
|
|
118
|
+
"label": content,
|
|
119
|
+
"memory_type": memory_type,
|
|
120
|
+
"heat": heat,
|
|
121
|
+
"namespace": namespace or self.namespace,
|
|
122
|
+
}
|
|
123
|
+
if decay_class is not None:
|
|
124
|
+
body["decay_class"] = decay_class
|
|
125
|
+
if is_pinned:
|
|
126
|
+
body["is_pinned"] = True
|
|
127
|
+
if min_heat is not None:
|
|
128
|
+
body["min_heat"] = min_heat
|
|
129
|
+
if key_points:
|
|
130
|
+
body["key_points"] = key_points
|
|
131
|
+
data = self._post("/api/v1/agent/nodes", body)
|
|
132
|
+
return Memory.from_dict(data)
|
|
133
|
+
|
|
134
|
+
def search(
|
|
135
|
+
self,
|
|
136
|
+
query: str,
|
|
137
|
+
*,
|
|
138
|
+
limit: int = 20,
|
|
139
|
+
memory_type: Optional[str] = None,
|
|
140
|
+
namespace: Optional[str] = None,
|
|
141
|
+
) -> List[Memory]:
|
|
142
|
+
"""Search memories by text. Returns matching nodes sorted by heat.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
query: Search text (case-insensitive substring match).
|
|
146
|
+
limit: Max results (1–100).
|
|
147
|
+
memory_type: Filter by type.
|
|
148
|
+
namespace: Filter by namespace.
|
|
149
|
+
"""
|
|
150
|
+
body: Dict[str, Any] = {"query": query, "limit": limit}
|
|
151
|
+
if memory_type:
|
|
152
|
+
body["memory_type"] = memory_type
|
|
153
|
+
if namespace:
|
|
154
|
+
body["namespace"] = namespace
|
|
155
|
+
data = self._post("/api/v1/agent/search", body)
|
|
156
|
+
return [Memory.from_dict(m) for m in data]
|
|
157
|
+
|
|
158
|
+
def list(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
page: int = 1,
|
|
162
|
+
page_size: int = 25,
|
|
163
|
+
memory_type: Optional[str] = None,
|
|
164
|
+
namespace: Optional[str] = None,
|
|
165
|
+
pinned: Optional[bool] = None,
|
|
166
|
+
search: Optional[str] = None,
|
|
167
|
+
sort: str = "current_heat",
|
|
168
|
+
order: str = "desc",
|
|
169
|
+
) -> List[Memory]:
|
|
170
|
+
"""List memories with pagination and filters.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
page: Page number (1-indexed).
|
|
174
|
+
page_size: Results per page (1–100).
|
|
175
|
+
memory_type: Filter by type.
|
|
176
|
+
namespace: Filter by namespace.
|
|
177
|
+
pinned: Filter by pinned status.
|
|
178
|
+
search: Text search within pointer_summary.
|
|
179
|
+
sort: Sort field (current_heat, updated_at, memory_type).
|
|
180
|
+
order: Sort order (asc, desc).
|
|
181
|
+
"""
|
|
182
|
+
params = f"?page={page}&page_size={page_size}&sort={sort}&order={order}"
|
|
183
|
+
if memory_type:
|
|
184
|
+
params += f"&memory_type={memory_type}"
|
|
185
|
+
if namespace:
|
|
186
|
+
params += f"&namespace={namespace}"
|
|
187
|
+
if pinned is not None:
|
|
188
|
+
params += f"&pinned={'true' if pinned else 'false'}"
|
|
189
|
+
if search:
|
|
190
|
+
params += f"&search={search}"
|
|
191
|
+
data = self._get(f"/api/v1/agent/nodes{params}")
|
|
192
|
+
nodes = data if isinstance(data, list) else (data.get("nodes") or data.get("items") or [])
|
|
193
|
+
return [Memory.from_dict(m) for m in nodes]
|
|
194
|
+
|
|
195
|
+
def get(self, memory_id: str) -> Memory:
|
|
196
|
+
"""Get a single memory by ID."""
|
|
197
|
+
data = self._get(f"/api/v1/agent/nodes/{memory_id}")
|
|
198
|
+
return Memory.from_dict(data)
|
|
199
|
+
|
|
200
|
+
def update(
|
|
201
|
+
self,
|
|
202
|
+
memory_id: str,
|
|
203
|
+
*,
|
|
204
|
+
label: Optional[str] = None,
|
|
205
|
+
memory_type: Optional[str] = None,
|
|
206
|
+
is_pinned: Optional[bool] = None,
|
|
207
|
+
namespace: Optional[str] = None,
|
|
208
|
+
heat: Optional[float] = None,
|
|
209
|
+
) -> Memory:
|
|
210
|
+
"""Update a memory node. Only provided fields are changed."""
|
|
211
|
+
body: Dict[str, Any] = {}
|
|
212
|
+
if label is not None:
|
|
213
|
+
body["label"] = label
|
|
214
|
+
if memory_type is not None:
|
|
215
|
+
body["memory_type"] = memory_type
|
|
216
|
+
if is_pinned is not None:
|
|
217
|
+
body["is_pinned"] = is_pinned
|
|
218
|
+
if namespace is not None:
|
|
219
|
+
body["namespace"] = namespace
|
|
220
|
+
if heat is not None:
|
|
221
|
+
body["current_heat"] = heat
|
|
222
|
+
data = self._patch(f"/api/v1/agent/nodes/{memory_id}", body)
|
|
223
|
+
if data:
|
|
224
|
+
return Memory.from_dict(data)
|
|
225
|
+
# Server may return empty 200; re-fetch the node
|
|
226
|
+
return self.get(memory_id)
|
|
227
|
+
|
|
228
|
+
def forget(self, memory_id: str) -> bool:
|
|
229
|
+
"""Delete a memory permanently. Returns True on success."""
|
|
230
|
+
self._delete(f"/api/v1/agent/nodes/{memory_id}")
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
def pin(self, memory_id: str) -> Memory:
|
|
234
|
+
"""Pin a memory (prevents heat decay)."""
|
|
235
|
+
return self.update(memory_id, is_pinned=True)
|
|
236
|
+
|
|
237
|
+
def unpin(self, memory_id: str) -> Memory:
|
|
238
|
+
"""Unpin a memory (resumes heat decay)."""
|
|
239
|
+
return self.update(memory_id, is_pinned=False)
|
|
240
|
+
|
|
241
|
+
def bulk_update(
|
|
242
|
+
self,
|
|
243
|
+
ids: List[str],
|
|
244
|
+
*,
|
|
245
|
+
label: Optional[str] = None,
|
|
246
|
+
memory_type: Optional[str] = None,
|
|
247
|
+
is_pinned: Optional[bool] = None,
|
|
248
|
+
namespace: Optional[str] = None,
|
|
249
|
+
heat: Optional[float] = None,
|
|
250
|
+
) -> Dict[str, Any]:
|
|
251
|
+
"""Apply the same update to multiple memories at once.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
ids: List of memory UUIDs to update.
|
|
255
|
+
label: New label/summary (applied to all).
|
|
256
|
+
memory_type: New type (applied to all).
|
|
257
|
+
is_pinned: Pin/unpin all.
|
|
258
|
+
namespace: Move all to this namespace.
|
|
259
|
+
heat: Set heat on all (0.0–1.0).
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Dict with 'updated' count and any 'errors'.
|
|
263
|
+
"""
|
|
264
|
+
body: Dict[str, Any] = {"ids": ids}
|
|
265
|
+
if label is not None:
|
|
266
|
+
body["label"] = label
|
|
267
|
+
if memory_type is not None:
|
|
268
|
+
body["memory_type"] = memory_type
|
|
269
|
+
if is_pinned is not None:
|
|
270
|
+
body["is_pinned"] = is_pinned
|
|
271
|
+
if namespace is not None:
|
|
272
|
+
body["namespace"] = namespace
|
|
273
|
+
if heat is not None:
|
|
274
|
+
body["current_heat"] = heat
|
|
275
|
+
return self._post("/api/v1/agent/nodes/bulk-patch", body)
|
|
276
|
+
|
|
277
|
+
# -- Account & Org ----------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def whoami(self) -> Dict[str, Any]:
|
|
280
|
+
"""Get tenant/org info for the current API key."""
|
|
281
|
+
return self._get("/api/v1/org")
|
|
282
|
+
|
|
283
|
+
def update_org(self, **kwargs) -> Dict[str, Any]:
|
|
284
|
+
"""Update org settings (name, etc.)."""
|
|
285
|
+
return self._patch("/api/v1/org", kwargs)
|
|
286
|
+
|
|
287
|
+
def invite_member(self, email: str, role: str = "member") -> Dict[str, Any]:
|
|
288
|
+
"""Invite a member to the org by email."""
|
|
289
|
+
return self._post("/api/v1/org/invite", {"email": email, "role": role})
|
|
290
|
+
|
|
291
|
+
def remove_member(self, user_id: str) -> bool:
|
|
292
|
+
"""Remove a member from the org."""
|
|
293
|
+
self._request("DELETE", "/api/v1/org/members", {"user_id": user_id})
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
def metrics(self) -> Dict[str, Any]:
|
|
297
|
+
"""Get storage and health metrics."""
|
|
298
|
+
return self._get("/api/v1/metrics")
|
|
299
|
+
|
|
300
|
+
def dashboard(self) -> Dict[str, Any]:
|
|
301
|
+
"""Get dashboard statistics (total nodes, heat distribution, etc.)."""
|
|
302
|
+
return self._get("/api/v1/admin/dashboard")
|
|
303
|
+
|
|
304
|
+
def graph(self) -> Dict[str, Any]:
|
|
305
|
+
"""Get the memory graph visualization data (nodes + edges)."""
|
|
306
|
+
return self._get("/api/v1/admin/visualize/graph")
|
|
307
|
+
|
|
308
|
+
# -- API Keys ----------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def list_keys(self) -> List[Dict[str, Any]]:
|
|
311
|
+
"""List all API keys for the current tenant."""
|
|
312
|
+
data = self._get("/api/v1/keys")
|
|
313
|
+
return data if isinstance(data, list) else data.get("keys", [])
|
|
314
|
+
|
|
315
|
+
def create_key(self, name: str = "") -> Dict[str, Any]:
|
|
316
|
+
"""Create a new API key. Returns the key (shown only once).
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
name: Human-readable label for this key.
|
|
320
|
+
"""
|
|
321
|
+
return self._post("/api/v1/keys", {"name": name})
|
|
322
|
+
|
|
323
|
+
def revoke_key(self, key_id: str) -> bool:
|
|
324
|
+
"""Revoke an API key permanently."""
|
|
325
|
+
self._delete(f"/api/v1/keys/{key_id}")
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
# -- Thermodynamic Engine ----------------------------------------------
|
|
329
|
+
|
|
330
|
+
def get_thermo_config(self) -> Dict[str, Any]:
|
|
331
|
+
"""Get the current thermodynamic engine configuration.
|
|
332
|
+
|
|
333
|
+
Returns the per-tenant config (or defaults if no custom config set),
|
|
334
|
+
plus the default values for reference.
|
|
335
|
+
"""
|
|
336
|
+
return self._get("/api/v1/settings/thermo")
|
|
337
|
+
|
|
338
|
+
def set_thermo_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
339
|
+
"""Update the thermodynamic engine configuration.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
config: Full ThermoConfig object with decay_profiles, resonance,
|
|
343
|
+
tick, consolidation, active_index, reinforcement sections.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
The saved config.
|
|
347
|
+
"""
|
|
348
|
+
return self._patch("/api/v1/settings/thermo", config)
|
|
349
|
+
|
|
350
|
+
def feedback(
|
|
351
|
+
self,
|
|
352
|
+
memory_id: str,
|
|
353
|
+
signal: str,
|
|
354
|
+
) -> Dict[str, Any]:
|
|
355
|
+
"""Send recall quality feedback for a memory node.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
memory_id: UUID of the memory node.
|
|
359
|
+
signal: One of 'relevant', 'irrelevant', 'outdated'.
|
|
360
|
+
- relevant: boosts heat + stability (spaced repetition)
|
|
361
|
+
- irrelevant: reduces heat/stability, accelerates decay
|
|
362
|
+
- outdated: nearly kills the memory, sets valid_until=now()
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Dict with heat_before, heat_after, stability_before, stability_after.
|
|
366
|
+
"""
|
|
367
|
+
return self._post("/api/v1/feedback", {
|
|
368
|
+
"node_id": memory_id,
|
|
369
|
+
"signal": signal,
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
def recall_analytics(self, period: str = "30d") -> Dict[str, Any]:
|
|
373
|
+
"""Get recall quality analytics with tuning suggestions.
|
|
374
|
+
|
|
375
|
+
Returns per-type stats (relevance ratio, signal counts) and
|
|
376
|
+
suggestions for half-life adjustments based on feedback patterns.
|
|
377
|
+
"""
|
|
378
|
+
return self._get("/api/v1/analytics/recall")
|
|
379
|
+
|
|
380
|
+
# -- Hot Nodes ---------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def hot_nodes(self, limit: int = 20) -> List[Memory]:
|
|
383
|
+
"""Return the hottest memories by current_heat (descending).
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
limit: Maximum number of nodes to return (default 20).
|
|
387
|
+
"""
|
|
388
|
+
data = self._get(f"/api/v1/agent/hot_nodes?limit={limit}")
|
|
389
|
+
return [Memory.from_dict(n) for n in data] if isinstance(data, list) else []
|
|
390
|
+
|
|
391
|
+
# -- Bulk Delete -------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
def bulk_delete(
|
|
394
|
+
self,
|
|
395
|
+
ids: Optional[List[str]] = None,
|
|
396
|
+
memory_type: Optional[str] = None,
|
|
397
|
+
namespace: Optional[str] = None,
|
|
398
|
+
) -> int:
|
|
399
|
+
"""Delete multiple memories at once.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
ids: Explicit list of node IDs to delete.
|
|
403
|
+
memory_type: Delete by memory type filter.
|
|
404
|
+
namespace: Delete by namespace filter.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Number of deleted memories.
|
|
408
|
+
"""
|
|
409
|
+
body: Dict[str, Any] = {}
|
|
410
|
+
if ids is not None:
|
|
411
|
+
body["ids"] = ids
|
|
412
|
+
if memory_type is not None:
|
|
413
|
+
body["memory_type"] = memory_type
|
|
414
|
+
if namespace is not None:
|
|
415
|
+
body["namespace"] = namespace
|
|
416
|
+
result = self._post("/api/v1/agent/nodes/bulk", body)
|
|
417
|
+
return result.get("deleted", 0) if isinstance(result, dict) else 0
|
|
418
|
+
|
|
419
|
+
# -- Activity ----------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
def activity(
|
|
422
|
+
self,
|
|
423
|
+
limit: int = 50,
|
|
424
|
+
cursor: Optional[str] = None,
|
|
425
|
+
) -> Dict[str, Any]:
|
|
426
|
+
"""Get the activity log for your tenant.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
limit: Maximum entries to return (default 50).
|
|
430
|
+
cursor: Pagination cursor from a previous response.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Dict with 'items' list and 'next_cursor'.
|
|
434
|
+
"""
|
|
435
|
+
params = f"?limit={limit}"
|
|
436
|
+
if cursor:
|
|
437
|
+
params += f"&cursor={cursor}"
|
|
438
|
+
return self._get(f"/api/v1/activity{params}")
|
|
439
|
+
|
|
440
|
+
# -- Gamification Profile -----------------------------------------------
|
|
441
|
+
|
|
442
|
+
def profile(self) -> Dict[str, Any]:
|
|
443
|
+
"""Get the gamification profile (XP, level, badges, streaks)."""
|
|
444
|
+
return self._get("/api/v1/gamification/profile")
|
|
445
|
+
|
|
446
|
+
# -- Triggers ----------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def list_triggers(self) -> List[Dict[str, Any]]:
|
|
449
|
+
"""List all active memory triggers.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
List of trigger objects with id, name, event, action, filters, etc.
|
|
453
|
+
"""
|
|
454
|
+
data = self._get("/api/v1/triggers")
|
|
455
|
+
return data.get("items") or data.get("triggers") or []
|
|
456
|
+
|
|
457
|
+
def create_trigger(
|
|
458
|
+
self,
|
|
459
|
+
event: str,
|
|
460
|
+
action: str,
|
|
461
|
+
*,
|
|
462
|
+
name: str = "",
|
|
463
|
+
description: str = "",
|
|
464
|
+
action_config: Optional[Dict[str, Any]] = None,
|
|
465
|
+
filter_memory_type: Optional[str] = None,
|
|
466
|
+
filter_namespace: Optional[str] = None,
|
|
467
|
+
filter_label_pattern: Optional[str] = None,
|
|
468
|
+
filter_heat_below: Optional[float] = None,
|
|
469
|
+
filter_heat_above: Optional[float] = None,
|
|
470
|
+
max_fires: Optional[int] = None,
|
|
471
|
+
cooldown_seconds: int = 0,
|
|
472
|
+
) -> Dict[str, Any]:
|
|
473
|
+
"""Create a reactive trigger on the memory graph.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
event: What fires the trigger. One of:
|
|
477
|
+
'on_store', 'on_recall', 'on_decay', 'on_boost',
|
|
478
|
+
'on_relate', 'on_threshold'.
|
|
479
|
+
action: What happens when fired. One of:
|
|
480
|
+
'notify', 'boost', 'pin', 'tag', 'deprecate', 'webhook'.
|
|
481
|
+
name: Human-readable trigger name.
|
|
482
|
+
description: What this trigger does.
|
|
483
|
+
action_config: Action-specific params. Examples:
|
|
484
|
+
notify: {"message": "Alert: {label}"}
|
|
485
|
+
boost: {"strength": 0.3, "target": "self"}
|
|
486
|
+
tag: {"label": "important"}
|
|
487
|
+
webhook: {"url": "https://...", "method": "POST"}
|
|
488
|
+
filter_memory_type: Only fire for this memory type.
|
|
489
|
+
filter_namespace: Only fire for this namespace.
|
|
490
|
+
filter_label_pattern: Case-insensitive pattern match on memory content.
|
|
491
|
+
filter_heat_below: Fire when heat drops below this value.
|
|
492
|
+
filter_heat_above: Fire when heat rises above this value.
|
|
493
|
+
max_fires: Maximum times this trigger can fire (None = unlimited).
|
|
494
|
+
cooldown_seconds: Minimum seconds between firings.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Dict with trigger_id and confirmation.
|
|
498
|
+
"""
|
|
499
|
+
body: Dict[str, Any] = {"event": event, "action": action}
|
|
500
|
+
if name:
|
|
501
|
+
body["name"] = name
|
|
502
|
+
if description:
|
|
503
|
+
body["description"] = description
|
|
504
|
+
if action_config:
|
|
505
|
+
body["action_config"] = action_config
|
|
506
|
+
if filter_memory_type:
|
|
507
|
+
body["filter_memory_type"] = filter_memory_type
|
|
508
|
+
if filter_namespace:
|
|
509
|
+
body["filter_namespace"] = filter_namespace
|
|
510
|
+
if filter_label_pattern:
|
|
511
|
+
body["filter_label_pattern"] = filter_label_pattern
|
|
512
|
+
if filter_heat_below is not None:
|
|
513
|
+
body["filter_heat_below"] = filter_heat_below
|
|
514
|
+
if filter_heat_above is not None:
|
|
515
|
+
body["filter_heat_above"] = filter_heat_above
|
|
516
|
+
if max_fires is not None:
|
|
517
|
+
body["max_fires"] = max_fires
|
|
518
|
+
if cooldown_seconds:
|
|
519
|
+
body["cooldown_seconds"] = cooldown_seconds
|
|
520
|
+
return self._post("/api/v1/triggers", body)
|
|
521
|
+
|
|
522
|
+
def update_trigger(
|
|
523
|
+
self,
|
|
524
|
+
trigger_id: str,
|
|
525
|
+
**kwargs,
|
|
526
|
+
) -> Dict[str, Any]:
|
|
527
|
+
"""Update a trigger. Pass any fields to change as keyword arguments.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
trigger_id: UUID of the trigger.
|
|
531
|
+
**kwargs: Fields to update (enabled, name, action_config,
|
|
532
|
+
max_fires, cooldown_seconds, reset_count=True).
|
|
533
|
+
"""
|
|
534
|
+
return self._patch(f"/api/v1/triggers/{trigger_id}", kwargs)
|
|
535
|
+
|
|
536
|
+
def delete_trigger(self, trigger_id: str) -> bool:
|
|
537
|
+
"""Delete a trigger and its history."""
|
|
538
|
+
self._delete(f"/api/v1/triggers/{trigger_id}")
|
|
539
|
+
return True
|
|
540
|
+
|
|
541
|
+
def trigger_history(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
542
|
+
"""Get trigger firing history.
|
|
543
|
+
|
|
544
|
+
Returns list of events with trigger_id, event, node_id, action, result, fired_at.
|
|
545
|
+
"""
|
|
546
|
+
data = self._get(f"/api/v1/triggers/history?limit={limit}")
|
|
547
|
+
return data.get("items") or data.get("history") or []
|
|
548
|
+
|
|
549
|
+
# -- HTTP primitives ---------------------------------------------------
|
|
550
|
+
|
|
551
|
+
def _headers(self) -> Dict[str, str]:
|
|
552
|
+
return {
|
|
553
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
554
|
+
"Content-Type": "application/json",
|
|
555
|
+
"User-Agent": f"sulcus-python/0.3.0",
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
def _request(self, method: str, path: str, body: Optional[Dict] = None) -> Any:
|
|
559
|
+
url = f"{self.base_url}{path}"
|
|
560
|
+
data = json.dumps(body).encode() if body else None
|
|
561
|
+
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
|
|
562
|
+
try:
|
|
563
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
564
|
+
raw = resp.read().decode()
|
|
565
|
+
if not raw:
|
|
566
|
+
return {}
|
|
567
|
+
return json.loads(raw)
|
|
568
|
+
except urllib.error.HTTPError as e:
|
|
569
|
+
body_text = e.read().decode() if e.fp else str(e)
|
|
570
|
+
raise SulcusError(e.code, body_text) from e
|
|
571
|
+
except urllib.error.URLError as e:
|
|
572
|
+
raise SulcusError(0, f"Connection failed: {e.reason}") from e
|
|
573
|
+
|
|
574
|
+
def _get(self, path: str) -> Any:
|
|
575
|
+
return self._request("GET", path)
|
|
576
|
+
|
|
577
|
+
def _post(self, path: str, body: Dict) -> Any:
|
|
578
|
+
return self._request("POST", path, body)
|
|
579
|
+
|
|
580
|
+
def _patch(self, path: str, body: Dict) -> Any:
|
|
581
|
+
return self._request("PATCH", path, body)
|
|
582
|
+
|
|
583
|
+
def _delete(self, path: str) -> Any:
|
|
584
|
+
return self._request("DELETE", path)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# ---------------------------------------------------------------------------
|
|
588
|
+
# Async Client (requires httpx — optional dependency)
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
class AsyncSulcus:
|
|
592
|
+
"""Async Sulcus client. Requires `httpx` (pip install sulcus[async]).
|
|
593
|
+
|
|
594
|
+
Same API as Sulcus but all methods are async.
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
DEFAULT_URL = "https://server.sulcus.dforge.ca"
|
|
598
|
+
|
|
599
|
+
def __init__(
|
|
600
|
+
self,
|
|
601
|
+
api_key: str,
|
|
602
|
+
base_url: str = DEFAULT_URL,
|
|
603
|
+
namespace: str = "default",
|
|
604
|
+
timeout: int = 30,
|
|
605
|
+
):
|
|
606
|
+
try:
|
|
607
|
+
import httpx
|
|
608
|
+
except ImportError:
|
|
609
|
+
raise ImportError(
|
|
610
|
+
"AsyncSulcus requires httpx. Install with: pip install sulcus[async]"
|
|
611
|
+
)
|
|
612
|
+
self.api_key = api_key
|
|
613
|
+
self.base_url = base_url.rstrip("/")
|
|
614
|
+
self.namespace = namespace
|
|
615
|
+
self._client = httpx.AsyncClient(
|
|
616
|
+
base_url=self.base_url,
|
|
617
|
+
headers={
|
|
618
|
+
"Authorization": f"Bearer {api_key}",
|
|
619
|
+
"Content-Type": "application/json",
|
|
620
|
+
"User-Agent": "sulcus-python/0.3.0",
|
|
621
|
+
},
|
|
622
|
+
timeout=timeout,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
async def remember(
|
|
626
|
+
self,
|
|
627
|
+
content: str,
|
|
628
|
+
*,
|
|
629
|
+
memory_type: str = "episodic",
|
|
630
|
+
heat: float = 0.8,
|
|
631
|
+
namespace: Optional[str] = None,
|
|
632
|
+
decay_class: Optional[str] = None,
|
|
633
|
+
is_pinned: bool = False,
|
|
634
|
+
min_heat: Optional[float] = None,
|
|
635
|
+
key_points: Optional[List[str]] = None,
|
|
636
|
+
) -> Memory:
|
|
637
|
+
body: Dict[str, Any] = {
|
|
638
|
+
"label": content,
|
|
639
|
+
"memory_type": memory_type,
|
|
640
|
+
"heat": heat,
|
|
641
|
+
"namespace": namespace or self.namespace,
|
|
642
|
+
}
|
|
643
|
+
if decay_class is not None:
|
|
644
|
+
body["decay_class"] = decay_class
|
|
645
|
+
if is_pinned:
|
|
646
|
+
body["is_pinned"] = True
|
|
647
|
+
if min_heat is not None:
|
|
648
|
+
body["min_heat"] = min_heat
|
|
649
|
+
if key_points:
|
|
650
|
+
body["key_points"] = key_points
|
|
651
|
+
resp = await self._client.post("/api/v1/agent/nodes", json=body)
|
|
652
|
+
resp.raise_for_status()
|
|
653
|
+
return Memory.from_dict(resp.json())
|
|
654
|
+
|
|
655
|
+
async def search(
|
|
656
|
+
self,
|
|
657
|
+
query: str,
|
|
658
|
+
*,
|
|
659
|
+
limit: int = 20,
|
|
660
|
+
memory_type: Optional[str] = None,
|
|
661
|
+
namespace: Optional[str] = None,
|
|
662
|
+
) -> List[Memory]:
|
|
663
|
+
body: Dict[str, Any] = {"query": query, "limit": limit}
|
|
664
|
+
if memory_type:
|
|
665
|
+
body["memory_type"] = memory_type
|
|
666
|
+
if namespace:
|
|
667
|
+
body["namespace"] = namespace
|
|
668
|
+
resp = await self._client.post("/api/v1/agent/search", json=body)
|
|
669
|
+
resp.raise_for_status()
|
|
670
|
+
return [Memory.from_dict(m) for m in resp.json()]
|
|
671
|
+
|
|
672
|
+
async def list(
|
|
673
|
+
self,
|
|
674
|
+
*,
|
|
675
|
+
page: int = 1,
|
|
676
|
+
page_size: int = 25,
|
|
677
|
+
memory_type: Optional[str] = None,
|
|
678
|
+
namespace: Optional[str] = None,
|
|
679
|
+
pinned: Optional[bool] = None,
|
|
680
|
+
search: Optional[str] = None,
|
|
681
|
+
sort: str = "current_heat",
|
|
682
|
+
order: str = "desc",
|
|
683
|
+
) -> List[Memory]:
|
|
684
|
+
params: Dict[str, Any] = {
|
|
685
|
+
"page": page, "page_size": page_size,
|
|
686
|
+
"sort": sort, "order": order,
|
|
687
|
+
}
|
|
688
|
+
if memory_type:
|
|
689
|
+
params["memory_type"] = memory_type
|
|
690
|
+
if namespace:
|
|
691
|
+
params["namespace"] = namespace
|
|
692
|
+
if pinned is not None:
|
|
693
|
+
params["pinned"] = str(pinned).lower()
|
|
694
|
+
if search:
|
|
695
|
+
params["search"] = search
|
|
696
|
+
resp = await self._client.get("/api/v1/agent/nodes", params=params)
|
|
697
|
+
resp.raise_for_status()
|
|
698
|
+
data = resp.json()
|
|
699
|
+
nodes = data if isinstance(data, list) else (data.get("nodes") or data.get("items") or [])
|
|
700
|
+
return [Memory.from_dict(m) for m in nodes]
|
|
701
|
+
|
|
702
|
+
async def get(self, memory_id: str) -> Memory:
|
|
703
|
+
resp = await self._client.get(f"/api/v1/agent/nodes/{memory_id}")
|
|
704
|
+
resp.raise_for_status()
|
|
705
|
+
return Memory.from_dict(resp.json())
|
|
706
|
+
|
|
707
|
+
async def update(
|
|
708
|
+
self,
|
|
709
|
+
memory_id: str,
|
|
710
|
+
*,
|
|
711
|
+
label: Optional[str] = None,
|
|
712
|
+
memory_type: Optional[str] = None,
|
|
713
|
+
is_pinned: Optional[bool] = None,
|
|
714
|
+
namespace: Optional[str] = None,
|
|
715
|
+
heat: Optional[float] = None,
|
|
716
|
+
) -> Memory:
|
|
717
|
+
body: Dict[str, Any] = {}
|
|
718
|
+
if label is not None:
|
|
719
|
+
body["label"] = label
|
|
720
|
+
if memory_type is not None:
|
|
721
|
+
body["memory_type"] = memory_type
|
|
722
|
+
if is_pinned is not None:
|
|
723
|
+
body["is_pinned"] = is_pinned
|
|
724
|
+
if namespace is not None:
|
|
725
|
+
body["namespace"] = namespace
|
|
726
|
+
if heat is not None:
|
|
727
|
+
body["current_heat"] = heat
|
|
728
|
+
resp = await self._client.patch(f"/api/v1/agent/nodes/{memory_id}", json=body)
|
|
729
|
+
resp.raise_for_status()
|
|
730
|
+
return Memory.from_dict(resp.json())
|
|
731
|
+
|
|
732
|
+
async def forget(self, memory_id: str) -> bool:
|
|
733
|
+
resp = await self._client.delete(f"/api/v1/agent/nodes/{memory_id}")
|
|
734
|
+
resp.raise_for_status()
|
|
735
|
+
return True
|
|
736
|
+
|
|
737
|
+
async def pin(self, memory_id: str) -> Memory:
|
|
738
|
+
return await self.update(memory_id, is_pinned=True)
|
|
739
|
+
|
|
740
|
+
async def unpin(self, memory_id: str) -> Memory:
|
|
741
|
+
return await self.update(memory_id, is_pinned=False)
|
|
742
|
+
|
|
743
|
+
async def bulk_update(
|
|
744
|
+
self,
|
|
745
|
+
ids: List[str],
|
|
746
|
+
*,
|
|
747
|
+
label: Optional[str] = None,
|
|
748
|
+
memory_type: Optional[str] = None,
|
|
749
|
+
is_pinned: Optional[bool] = None,
|
|
750
|
+
namespace: Optional[str] = None,
|
|
751
|
+
heat: Optional[float] = None,
|
|
752
|
+
) -> Dict[str, Any]:
|
|
753
|
+
body: Dict[str, Any] = {"ids": ids}
|
|
754
|
+
if label is not None:
|
|
755
|
+
body["label"] = label
|
|
756
|
+
if memory_type is not None:
|
|
757
|
+
body["memory_type"] = memory_type
|
|
758
|
+
if is_pinned is not None:
|
|
759
|
+
body["is_pinned"] = is_pinned
|
|
760
|
+
if namespace is not None:
|
|
761
|
+
body["namespace"] = namespace
|
|
762
|
+
if heat is not None:
|
|
763
|
+
body["current_heat"] = heat
|
|
764
|
+
resp = await self._client.post("/api/v1/agent/nodes/bulk-patch", json=body)
|
|
765
|
+
resp.raise_for_status()
|
|
766
|
+
return resp.json()
|
|
767
|
+
|
|
768
|
+
async def whoami(self) -> Dict[str, Any]:
|
|
769
|
+
resp = await self._client.get("/api/v1/org")
|
|
770
|
+
resp.raise_for_status()
|
|
771
|
+
return resp.json()
|
|
772
|
+
|
|
773
|
+
async def update_org(self, **kwargs) -> Dict[str, Any]:
|
|
774
|
+
resp = await self._client.patch("/api/v1/org", json=kwargs)
|
|
775
|
+
resp.raise_for_status()
|
|
776
|
+
return resp.json()
|
|
777
|
+
|
|
778
|
+
async def invite_member(self, email: str, role: str = "member") -> Dict[str, Any]:
|
|
779
|
+
resp = await self._client.post("/api/v1/org/invite", json={"email": email, "role": role})
|
|
780
|
+
resp.raise_for_status()
|
|
781
|
+
return resp.json()
|
|
782
|
+
|
|
783
|
+
async def remove_member(self, user_id: str) -> bool:
|
|
784
|
+
resp = await self._client.request("DELETE", "/api/v1/org/members", json={"user_id": user_id})
|
|
785
|
+
resp.raise_for_status()
|
|
786
|
+
return True
|
|
787
|
+
|
|
788
|
+
async def metrics(self) -> Dict[str, Any]:
|
|
789
|
+
resp = await self._client.get("/api/v1/metrics")
|
|
790
|
+
resp.raise_for_status()
|
|
791
|
+
return resp.json()
|
|
792
|
+
|
|
793
|
+
async def dashboard(self) -> Dict[str, Any]:
|
|
794
|
+
resp = await self._client.get("/api/v1/admin/dashboard")
|
|
795
|
+
resp.raise_for_status()
|
|
796
|
+
return resp.json()
|
|
797
|
+
|
|
798
|
+
async def graph(self) -> Dict[str, Any]:
|
|
799
|
+
resp = await self._client.get("/api/v1/admin/visualize/graph")
|
|
800
|
+
resp.raise_for_status()
|
|
801
|
+
return resp.json()
|
|
802
|
+
|
|
803
|
+
async def list_keys(self) -> List[Dict[str, Any]]:
|
|
804
|
+
resp = await self._client.get("/api/v1/keys")
|
|
805
|
+
resp.raise_for_status()
|
|
806
|
+
data = resp.json()
|
|
807
|
+
return data if isinstance(data, list) else data.get("keys", [])
|
|
808
|
+
|
|
809
|
+
async def create_key(self, name: str = "") -> Dict[str, Any]:
|
|
810
|
+
resp = await self._client.post("/api/v1/keys", json={"name": name})
|
|
811
|
+
resp.raise_for_status()
|
|
812
|
+
return resp.json()
|
|
813
|
+
|
|
814
|
+
async def revoke_key(self, key_id: str) -> bool:
|
|
815
|
+
resp = await self._client.delete(f"/api/v1/keys/{key_id}")
|
|
816
|
+
resp.raise_for_status()
|
|
817
|
+
return True
|
|
818
|
+
|
|
819
|
+
async def get_thermo_config(self) -> Dict[str, Any]:
|
|
820
|
+
resp = await self._client.get("/api/v1/settings/thermo")
|
|
821
|
+
resp.raise_for_status()
|
|
822
|
+
return resp.json()
|
|
823
|
+
|
|
824
|
+
async def set_thermo_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
825
|
+
resp = await self._client.patch("/api/v1/settings/thermo", json=config)
|
|
826
|
+
resp.raise_for_status()
|
|
827
|
+
return resp.json()
|
|
828
|
+
|
|
829
|
+
async def feedback(self, memory_id: str, signal: str) -> Dict[str, Any]:
|
|
830
|
+
resp = await self._client.post("/api/v1/feedback", json={
|
|
831
|
+
"node_id": memory_id,
|
|
832
|
+
"signal": signal,
|
|
833
|
+
})
|
|
834
|
+
resp.raise_for_status()
|
|
835
|
+
return resp.json()
|
|
836
|
+
|
|
837
|
+
async def recall_analytics(self) -> Dict[str, Any]:
|
|
838
|
+
resp = await self._client.get("/api/v1/analytics/recall")
|
|
839
|
+
resp.raise_for_status()
|
|
840
|
+
return resp.json()
|
|
841
|
+
|
|
842
|
+
async def hot_nodes(self, limit: int = 20) -> List[Memory]:
|
|
843
|
+
resp = await self._client.get(f"/api/v1/agent/hot_nodes?limit={limit}")
|
|
844
|
+
resp.raise_for_status()
|
|
845
|
+
data = resp.json()
|
|
846
|
+
return [Memory.from_dict(n) for n in data] if isinstance(data, list) else []
|
|
847
|
+
|
|
848
|
+
async def bulk_delete(
|
|
849
|
+
self,
|
|
850
|
+
ids: Optional[List[str]] = None,
|
|
851
|
+
memory_type: Optional[str] = None,
|
|
852
|
+
namespace: Optional[str] = None,
|
|
853
|
+
) -> int:
|
|
854
|
+
body: Dict[str, Any] = {}
|
|
855
|
+
if ids is not None:
|
|
856
|
+
body["ids"] = ids
|
|
857
|
+
if memory_type is not None:
|
|
858
|
+
body["memory_type"] = memory_type
|
|
859
|
+
if namespace is not None:
|
|
860
|
+
body["namespace"] = namespace
|
|
861
|
+
resp = await self._client.post("/api/v1/agent/nodes/bulk", json=body)
|
|
862
|
+
resp.raise_for_status()
|
|
863
|
+
result = resp.json()
|
|
864
|
+
return result.get("deleted", 0) if isinstance(result, dict) else 0
|
|
865
|
+
|
|
866
|
+
async def activity(self, limit: int = 50, cursor: Optional[str] = None) -> Dict[str, Any]:
|
|
867
|
+
params = f"?limit={limit}"
|
|
868
|
+
if cursor:
|
|
869
|
+
params += f"&cursor={cursor}"
|
|
870
|
+
resp = await self._client.get(f"/api/v1/activity{params}")
|
|
871
|
+
resp.raise_for_status()
|
|
872
|
+
return resp.json()
|
|
873
|
+
|
|
874
|
+
async def profile(self) -> Dict[str, Any]:
|
|
875
|
+
resp = await self._client.get("/api/v1/gamification/profile")
|
|
876
|
+
resp.raise_for_status()
|
|
877
|
+
return resp.json()
|
|
878
|
+
|
|
879
|
+
async def list_triggers(self) -> List[Dict[str, Any]]:
|
|
880
|
+
resp = await self._client.get("/api/v1/triggers")
|
|
881
|
+
resp.raise_for_status()
|
|
882
|
+
data = resp.json()
|
|
883
|
+
return data.get("items") or data.get("triggers") or []
|
|
884
|
+
|
|
885
|
+
async def create_trigger(
|
|
886
|
+
self,
|
|
887
|
+
event: str,
|
|
888
|
+
action: str,
|
|
889
|
+
**kwargs,
|
|
890
|
+
) -> Dict[str, Any]:
|
|
891
|
+
body = {"event": event, "action": action, **kwargs}
|
|
892
|
+
resp = await self._client.post("/api/v1/triggers", json=body)
|
|
893
|
+
resp.raise_for_status()
|
|
894
|
+
return resp.json()
|
|
895
|
+
|
|
896
|
+
async def update_trigger(self, trigger_id: str, **kwargs) -> Dict[str, Any]:
|
|
897
|
+
resp = await self._client.patch(f"/api/v1/triggers/{trigger_id}", json=kwargs)
|
|
898
|
+
resp.raise_for_status()
|
|
899
|
+
return resp.json()
|
|
900
|
+
|
|
901
|
+
async def delete_trigger(self, trigger_id: str) -> bool:
|
|
902
|
+
resp = await self._client.delete(f"/api/v1/triggers/{trigger_id}")
|
|
903
|
+
resp.raise_for_status()
|
|
904
|
+
return True
|
|
905
|
+
|
|
906
|
+
async def trigger_history(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
907
|
+
resp = await self._client.get(f"/api/v1/triggers/history?limit={limit}")
|
|
908
|
+
resp.raise_for_status()
|
|
909
|
+
data = resp.json()
|
|
910
|
+
return data.get("items") or data.get("history") or []
|
|
911
|
+
|
|
912
|
+
async def close(self):
|
|
913
|
+
await self._client.aclose()
|
|
914
|
+
|
|
915
|
+
async def __aenter__(self):
|
|
916
|
+
return self
|
|
917
|
+
|
|
918
|
+
async def __aexit__(self, *args):
|
|
919
|
+
await self.close()
|