mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
chore: update TinyHumans AI SDK to version 0.1.6 and add documentation for Rust SDK E2E tests
- Updated the TinyHumans AI SDK version in Cargo.lock and Cargo.toml. - Added new documentation files for the Rust SDK E2E test run and TinyHumans AI SDK reference. - Updated the skills subproject commit reference. - Refactored memory client methods for improved functionality and consistency.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
# Rust SDK E2E Test Run — `example_e2e.rs`
|
||||
|
||||
**Run date:** 2026-03-27
|
||||
**Source:** `neocortex/packages/sdk-rust/tests/example_e2e.rs`
|
||||
**API base URL:** `https://staging-api.alphahuman.xyz`
|
||||
**Namespace:** `sdk-rust-e2e`
|
||||
**Document ID:** `sdk-rust-e2e-doc-single-1774605977640`
|
||||
|
||||
---
|
||||
|
||||
## What the test does
|
||||
|
||||
The file is a standalone end-to-end integration program (not a `#[test]`-annotated unit test). It exercises the `tinyhumansai` Rust SDK against the staging API in six sequential steps:
|
||||
|
||||
| Step | Operation | SDK method |
|
||||
|------|-----------|------------|
|
||||
| 1 | Insert a memory document | `insert_memory` |
|
||||
| 2 | Poll ingestion job until complete | `get_ingestion_job` + `wait_for_ingestion_job` |
|
||||
| 3 | List documents filtered by namespace | `list_documents` |
|
||||
| 4 | Fetch the specific document | `get_document` |
|
||||
| 5 | Semantic query over the namespace | `query_memory` |
|
||||
| 6 | Recall all memory context | `recall_memory` |
|
||||
|
||||
The document inserted contains sprint velocity data for four teams (Atlas, Beacon, Comet, Delta).
|
||||
|
||||
---
|
||||
|
||||
## How to run
|
||||
|
||||
The file has its own `async fn main()` (annotated with `#[tokio::main]`), so Cargo's default test harness intercepts it and reports 0 tests. To run it as intended, add `harness = false` to `Cargo.toml` temporarily:
|
||||
|
||||
```toml
|
||||
[[test]]
|
||||
name = "example_e2e"
|
||||
harness = false
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
cd neocortex/packages/sdk-rust
|
||||
cargo test --test example_e2e
|
||||
```
|
||||
|
||||
> **Note:** `TINYHUMANS_TOKEN` in the file is intentionally left blank. Populate it with a valid API token before running.
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step output
|
||||
|
||||
### Step 1 — `insertMemory`
|
||||
|
||||
**Endpoint:** `POST /memory/insert`
|
||||
|
||||
**Request body** (serialized from `InsertMemoryBody`; `priority`, `createdAt`, `updatedAt` omitted because they are `None` and marked `skip_serializing_if`):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Sprint Dataset - Team Velocity",
|
||||
"content": "Sprint snapshot: Team Atlas completed 42 story points with 3 blockers, Team Beacon completed 35 story points with 1 blocker, Team Comet completed 48 story points with 5 blockers, and Team Delta completed 39 story points with 2 blockers. The highest velocity team is Team Comet and the fewest blockers team is Team Beacon.",
|
||||
"namespace": "sdk-rust-e2e",
|
||||
"sourceType": "doc",
|
||||
"metadata": { "source": "example_e2e.rs" },
|
||||
"documentId": "sdk-rust-e2e-doc-single-1774605977640"
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** success
|
||||
**Job ID:** `a2a1396c-bcf5-4552-afc0-6c822bafd7c6`
|
||||
**Initial job state:** `pending`
|
||||
|
||||
```
|
||||
InsertMemoryResponse {
|
||||
success: true,
|
||||
data: InsertMemoryData {
|
||||
job_id: Some("a2a1396c-bcf5-4552-afc0-6c822bafd7c6"),
|
||||
state: Some("pending"),
|
||||
...
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — `getIngestionJob` + `waitForIngestionJob`
|
||||
|
||||
**Endpoint:** `GET /memory/ingestion/jobs/{jobId}` (no request body)
|
||||
|
||||
**URL:** `GET /memory/ingestion/jobs/a2a1396c-bcf5-4552-afc0-6c822bafd7c6`
|
||||
|
||||
`wait_for_ingestion_job` then polls the same endpoint repeatedly (every 1 s, up to 30 s) until the state is not in `{pending, queued, processing, in_progress, started}`.
|
||||
|
||||
Initial poll returned state `processing`, so the SDK waited. Job completed successfully.
|
||||
|
||||
**Final state:** `completed`
|
||||
**Completed at:** `2026-03-27T10:06:24.974Z`
|
||||
**Ingestion latency:** `2.6173 s`
|
||||
|
||||
Key stats from the completed job response:
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Chunks new | 1 |
|
||||
| Chunks total | 1 |
|
||||
| Chunks deduplicated | 0 |
|
||||
| Entities extracted | 15 |
|
||||
| Relations extracted | 25 |
|
||||
| Sections | 1 |
|
||||
| Source type | `doc` |
|
||||
| Embedding tokens used | 244 |
|
||||
| Cost (USD) | $0.00000488 |
|
||||
|
||||
Timing breakdown (selected):
|
||||
|
||||
| Stage | Seconds |
|
||||
|-------|---------|
|
||||
| Chunking | 0.000826 |
|
||||
| Chunk embedding | 0.2688 |
|
||||
| Chunk storage | 0.2049 |
|
||||
| Entity extraction | 0.8131 |
|
||||
| Entity embedding | 0.0476 |
|
||||
| Graph structure | 0.2427 |
|
||||
| Relationship storage | 0.3800 |
|
||||
| Storage total | 1.2504 |
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — `listDocuments`
|
||||
|
||||
**Endpoint:** `GET /memory/documents?namespace=sdk-rust-e2e&limit=10&offset=0` (no request body)
|
||||
|
||||
This run uses the updated `list_documents(ListDocumentsParams { namespace, limit, offset })` signature (new in the local SDK). Passing `namespace` now filters results correctly — previous runs returned an empty array because no namespace filter was applied.
|
||||
|
||||
**4 documents returned** (all previous E2E runs in this namespace):
|
||||
|
||||
| Document ID | Created at |
|
||||
|-------------|------------|
|
||||
| `sdk-rust-e2e-doc-single-1774598994566` | 2026-03-27T08:09:56 |
|
||||
| `sdk-rust-e2e-doc-single-1774600415507` | 2026-03-27T08:33:37 |
|
||||
| `sdk-rust-e2e-doc-single-1774604625874` | 2026-03-27T09:43:47 |
|
||||
| `sdk-rust-e2e-doc-single-1774605977640` | 2026-03-27T10:06:20 ← this run |
|
||||
|
||||
All share `namespace: "sdk-rust-e2e"`, `title: "Sprint Dataset - Team Velocity"`, `chunk_count: 1`.
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — `getDocument`
|
||||
|
||||
**Endpoint:** `GET /memory/documents/{documentId}?namespace={namespace}` (no request body)
|
||||
|
||||
**URL:** `GET /memory/documents/sdk-rust-e2e-doc-single-1774605977640?namespace=sdk-rust-e2e`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"document_id": "sdk-rust-e2e-doc-single-1774605977640",
|
||||
"namespace": "sdk-rust-e2e",
|
||||
"title": "Sprint Dataset - Team Velocity",
|
||||
"chunk_count": 1,
|
||||
"chunk_ids": [-1427053832764092200],
|
||||
"created_at": "2026-03-27T10:06:20.655791+00:00",
|
||||
"updated_at": "2026-03-27T10:06:21.964953+00:00",
|
||||
"user_id": "69b12a6fd11460481185a040"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 5 — `queryMemory`
|
||||
|
||||
**Endpoint:** `POST /memory/query`
|
||||
|
||||
**Request body** (serialized from `QueryMemoryParams` with `#[serde(rename_all = "camelCase")]`; `documentIds` and `llmQuery` omitted because they are `None`):
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "Which team has the highest velocity and which team has the fewest blockers?",
|
||||
"includeReferences": true,
|
||||
"namespace": "sdk-rust-e2e",
|
||||
"maxChunks": 5.0
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** 1 chunk returned with score `19.117`
|
||||
|
||||
The relevant chunk was retrieved correctly. The LLM context message assembled by the API:
|
||||
|
||||
```
|
||||
## Sources
|
||||
|
||||
[1] Section: Sprint Dataset - Team Velocity
|
||||
[1] Sprint snapshot: Team Atlas completed 42 story points with 3 blockers,
|
||||
Team Beacon completed 35 story points with 1 blocker, Team Comet
|
||||
completed 48 story points with 5 blockers, and Team Delta completed 39
|
||||
story points with 2 blockers. The highest velocity team is Team Comet
|
||||
and the fewest blockers team is Team Beacon.
|
||||
```
|
||||
|
||||
Top entity mentions extracted from the chunk (by normalized importance):
|
||||
|
||||
| Entity | Normalized importance | Count |
|
||||
|--------|-----------------------|-------|
|
||||
| THE FEWEST BLOCKERS TEAM | 1.000 | 6 |
|
||||
| 42 STORY POINTS | 0.842 | 14 |
|
||||
| TEAM BEACON | 0.486 | 2 |
|
||||
| TEAM COMET | 0.476 | 2 |
|
||||
| THE HIGHEST VELOCITY TEAM | 0.440 | 4 |
|
||||
|
||||
**Usage:**
|
||||
- Embedding tokens: 20
|
||||
- Cost: $0.0000004
|
||||
- Cached: false
|
||||
|
||||
---
|
||||
|
||||
### Step 6 — `recallMemoryContext`
|
||||
|
||||
**Endpoint:** `POST /memory/recall`
|
||||
|
||||
**Request body** (serialized from `RecallMemoryParams` with `#[serde(rename_all = "camelCase")]`):
|
||||
|
||||
```json
|
||||
{
|
||||
"namespace": "sdk-rust-e2e",
|
||||
"maxChunks": 5.0
|
||||
}
|
||||
```
|
||||
|
||||
Recall (no query — returns all recent/relevant context) returned the same chunk with a higher score of `31.357` (recall scoring differs from query scoring).
|
||||
|
||||
**Counts:** 1 chunk, 0 entities, 0 relations
|
||||
**Latency:** 2.8183 s
|
||||
**Usage:** 0 tokens, $0 cost (recall is embedding-free)
|
||||
**Cached:** false
|
||||
|
||||
The LLM context message was identical to step 5.
|
||||
|
||||
---
|
||||
|
||||
## Changes since previous run
|
||||
|
||||
| Area | Previous run | This run |
|
||||
|------|-------------|----------|
|
||||
| `step3_list_documents` signature | `list_documents()` — no args | `list_documents(ListDocumentsParams { namespace, limit, offset })` |
|
||||
| Step 3 result | Empty `documents: []` (no filter) | 4 documents returned (namespace filter working) |
|
||||
| Job ID | `4b3cc8e2-...` | `a2a1396c-...` |
|
||||
| Document ID | `sdk-rust-e2e-doc-single-1774600415507` | `sdk-rust-e2e-doc-single-1774605977640` |
|
||||
| Ingestion latency | 1.7791 s | 2.6173 s |
|
||||
| Entity extraction time | 0.0117 s | 0.8131 s |
|
||||
|
||||
---
|
||||
|
||||
## Overall result
|
||||
|
||||
```
|
||||
E2E Rust SDK example completed.
|
||||
```
|
||||
|
||||
All 6 steps passed. The SDK correctly:
|
||||
- Inserted a document and received a job ID
|
||||
- Polled and waited for the ingestion job to reach `completed`
|
||||
- Listed documents filtered by namespace (returning all 4 prior E2E inserts)
|
||||
- Retrieved the document metadata by ID
|
||||
- Performed a semantic query and received the correct chunk with entity importance scores
|
||||
- Recalled memory context with latency and count metadata
|
||||
@@ -0,0 +1,548 @@
|
||||
# TinyHumans AI SDK — Reference & Project Integration
|
||||
|
||||
**Crate:** [`tinyhumansai`](https://crates.io/crates/tinyhumansai)
|
||||
**Version:** 0.1.6
|
||||
**License:** MIT
|
||||
**Repository:** https://github.com/tinyhumansai/neocortex/tree/main/packages/sdk-rust
|
||||
|
||||
The `tinyhumansai` Rust SDK is a typed async client for the TinyHumans Neocortex memory API. It supports inserting, querying, recalling, and deleting memory — plus ingestion job tracking, document management, and skill-data sync.
|
||||
|
||||
---
|
||||
|
||||
## Client Setup
|
||||
|
||||
### `TinyHumanConfig`
|
||||
|
||||
```rust
|
||||
let config = TinyHumanConfig::new("your-api-token");
|
||||
|
||||
// Override the base URL (optional)
|
||||
let config = config.with_base_url("https://staging-api.alphahuman.xyz");
|
||||
```
|
||||
|
||||
Base URL resolution order:
|
||||
1. `with_base_url(...)` call
|
||||
2. `TINYHUMANS_BASE_URL` env var
|
||||
3. `NEOCORTEX_BASE_URL` env var
|
||||
4. Default: `https://api.tinyhumans.ai`
|
||||
|
||||
### `TinyHumansMemoryClient::new`
|
||||
|
||||
```rust
|
||||
let client = TinyHumansMemoryClient::new(config)?;
|
||||
```
|
||||
|
||||
Validates that the token is non-empty. Returns `TinyHumansError::Validation` if it is.
|
||||
Sets a 30-second HTTP timeout on all requests. Uses `rustls` for TLS.
|
||||
|
||||
---
|
||||
|
||||
## SDK Functions
|
||||
|
||||
### `insert_memory`
|
||||
|
||||
**Endpoint:** `POST /memory/insert`
|
||||
|
||||
Ingests a document into the memory store. Returns a job ID — ingestion is asynchronous.
|
||||
|
||||
```rust
|
||||
let res = client.insert_memory(InsertMemoryParams {
|
||||
title: "Sprint Dataset - Team Velocity".to_string(),
|
||||
content: "...".to_string(),
|
||||
namespace: "sdk-rust-e2e".to_string(),
|
||||
document_id: "my-doc-id".to_string(),
|
||||
metadata: Some(serde_json::json!({ "source": "example.rs" })),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
|
||||
let job_id = res.data.job_id; // Option<String>
|
||||
```
|
||||
|
||||
**`InsertMemoryParams`**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | `String` | Yes | Document title |
|
||||
| `content` | `String` | Yes | Document text content |
|
||||
| `namespace` | `String` | Yes | Logical partition for the document |
|
||||
| `document_id` | `String` | Yes | Caller-supplied unique ID |
|
||||
| `source_type` | `Option<SourceType>` | No | `Doc` (default), `Chat`, or `Email` |
|
||||
| `metadata` | `Option<serde_json::Value>` | No | Arbitrary JSON metadata |
|
||||
| `priority` | `Option<Priority>` | No | `High`, `Medium`, or `Low` |
|
||||
| `created_at` | `Option<f64>` | No | Unix timestamp (ms) |
|
||||
| `updated_at` | `Option<f64>` | No | Unix timestamp (ms) |
|
||||
|
||||
**Serialised request body** (fields with `None` values are omitted):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "...",
|
||||
"content": "...",
|
||||
"namespace": "...",
|
||||
"sourceType": "doc",
|
||||
"metadata": { "source": "example.rs" },
|
||||
"documentId": "my-doc-id"
|
||||
}
|
||||
```
|
||||
|
||||
**`InsertMemoryResponse`**
|
||||
|
||||
```rust
|
||||
InsertMemoryResponse {
|
||||
success: true,
|
||||
data: InsertMemoryData {
|
||||
job_id: Some("a2a1396c-..."),
|
||||
state: Some("pending"),
|
||||
status: None,
|
||||
stats: None,
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `get_ingestion_job`
|
||||
|
||||
**Endpoint:** `GET /memory/ingestion/jobs/{jobId}`
|
||||
|
||||
Fetches the current status of an ingestion job.
|
||||
|
||||
```rust
|
||||
let res = client.get_ingestion_job("a2a1396c-bcf5-4552-afc0-6c822bafd7c6").await?;
|
||||
println!("{:?}", res.data.state); // Some("processing") | Some("completed") | ...
|
||||
```
|
||||
|
||||
**`IngestionJobStatusResponse`**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `job_id` | `Option<String>` | The job ID |
|
||||
| `state` | `Option<String>` | `pending`, `processing`, `completed`, `failed`, etc. |
|
||||
| `endpoint` | `Option<String>` | API endpoint that created the job |
|
||||
| `attempts` | `Option<f64>` | Number of execution attempts |
|
||||
| `error` | `Option<String>` | Error message if failed |
|
||||
| `response` | `Option<serde_json::Value>` | Full ingestion result (stats, timings, usage) on completion |
|
||||
| `created_at` | `Option<String>` | ISO 8601 timestamp |
|
||||
| `started_at` | `Option<String>` | ISO 8601 timestamp |
|
||||
| `completed_at` | `Option<String>` | ISO 8601 timestamp |
|
||||
|
||||
Completed response includes ingestion stats (chunk count, entity count, relation count, timings, embedding token usage, and cost in USD).
|
||||
|
||||
---
|
||||
|
||||
### `wait_for_ingestion_job`
|
||||
|
||||
Polls `get_ingestion_job` until the job reaches a terminal state.
|
||||
|
||||
```rust
|
||||
let res = client.wait_for_ingestion_job(
|
||||
"a2a1396c-...",
|
||||
Some(30_000), // timeout_ms
|
||||
Some(1_000), // poll_interval_ms
|
||||
).await?;
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `job_id` | `&str` | — | Job to poll |
|
||||
| `timeout_ms` | `Option<u64>` | 30 000 | Max wait in milliseconds |
|
||||
| `poll_interval_ms` | `Option<u64>` | 1 000 | Polling interval |
|
||||
|
||||
**Terminal states:** `completed`, `done`, `succeeded`, `success` → returns `Ok`.
|
||||
**Failure states:** `failed`, `error`, `cancelled` → returns `Err`.
|
||||
**Timeout:** returns `TinyHumansError::Api { status: 408 }`.
|
||||
|
||||
---
|
||||
|
||||
### `query_memory`
|
||||
|
||||
**Endpoint:** `POST /memory/query`
|
||||
|
||||
Semantic (RAG) query over stored memory. Returns ranked chunks relevant to the query.
|
||||
|
||||
```rust
|
||||
let res = client.query_memory(QueryMemoryParams {
|
||||
query: "Which team has the highest velocity?".to_string(),
|
||||
namespace: Some("sdk-rust-e2e".to_string()),
|
||||
include_references: Some(true),
|
||||
max_chunks: Some(5.0),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
```
|
||||
|
||||
**`QueryMemoryParams`** (`#[serde(rename_all = "camelCase")]`)
|
||||
|
||||
| Field | Type | Serialised as | Description |
|
||||
|-------|------|---------------|-------------|
|
||||
| `query` | `String` | `"query"` | The search query |
|
||||
| `namespace` | `Option<String>` | `"namespace"` | Filter by namespace |
|
||||
| `include_references` | `Option<bool>` | `"includeReferences"` | Include source chunk metadata |
|
||||
| `max_chunks` | `Option<f64>` | `"maxChunks"` | Max chunks to retrieve |
|
||||
| `document_ids` | `Option<Vec<String>>` | `"documentIds"` | Filter to specific documents |
|
||||
| `llm_query` | `Option<String>` | `"llmQuery"` | Override query sent to LLM |
|
||||
|
||||
**`QueryMemoryResponse`**
|
||||
|
||||
```rust
|
||||
QueryMemoryResponse {
|
||||
success: true,
|
||||
data: QueryMemoryData {
|
||||
context: Some(QueryContextOut {
|
||||
entities: [],
|
||||
relations: [],
|
||||
chunks: [ /* matched chunks with scores and entity_mentions */ ],
|
||||
}),
|
||||
usage: Some(Usage {
|
||||
embedding_tokens: 20,
|
||||
cost_usd: 0.0000004,
|
||||
llm_input_tokens: 0,
|
||||
llm_output_tokens: 0,
|
||||
}),
|
||||
cached: false,
|
||||
llm_context_message: Some("## Sources\n\n[1] ..."),
|
||||
response: None, // populated if LLM response was requested
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`llm_context_message` is a pre-formatted string ready for injection into an LLM prompt.
|
||||
|
||||
---
|
||||
|
||||
### `recall_memory`
|
||||
|
||||
**Endpoint:** `POST /memory/recall`
|
||||
|
||||
Recalls synthesised context from the Master memory node for a namespace — no query required. Returns the most relevant accumulated context.
|
||||
|
||||
```rust
|
||||
let res = client.recall_memory(RecallMemoryParams {
|
||||
namespace: Some("sdk-rust-e2e".to_string()),
|
||||
max_chunks: Some(5.0),
|
||||
}).await?;
|
||||
```
|
||||
|
||||
**`RecallMemoryParams`** (`#[serde(rename_all = "camelCase")]`)
|
||||
|
||||
| Field | Type | Serialised as | Description |
|
||||
|-------|------|---------------|-------------|
|
||||
| `namespace` | `Option<String>` | `"namespace"` | Namespace to recall from |
|
||||
| `max_chunks` | `Option<f64>` | `"maxChunks"` | Max chunks to return |
|
||||
|
||||
**`RecallMemoryResponse`**
|
||||
|
||||
```rust
|
||||
RecallMemoryResponse {
|
||||
success: true,
|
||||
data: RecallMemoryData {
|
||||
context: Some(/* raw JSON object with chunks, entities, relations */),
|
||||
llm_context_message: Some("## Sources\n\n[1] ..."),
|
||||
response: None,
|
||||
cached: false,
|
||||
latency_seconds: Some(2.8183),
|
||||
counts: Some(RecallCounts {
|
||||
num_chunks: 1,
|
||||
num_entities: 0,
|
||||
num_relations: 0,
|
||||
}),
|
||||
usage: Some(/* cost/token breakdown */),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recall is embedding-free (0 tokens, $0 cost) unlike `query_memory`.
|
||||
|
||||
---
|
||||
|
||||
### `delete_memory`
|
||||
|
||||
**Endpoint:** `POST /memory/admin/delete`
|
||||
|
||||
Deletes all memory for a namespace (or all memory if namespace is omitted).
|
||||
|
||||
```rust
|
||||
client.delete_memory(DeleteMemoryParams {
|
||||
namespace: Some("skill:gmail:user@example.com".to_string()),
|
||||
}).await?;
|
||||
```
|
||||
|
||||
**`DeleteMemoryResponse`**
|
||||
|
||||
```rust
|
||||
DeleteMemoryData {
|
||||
status: "ok",
|
||||
user_id: "...",
|
||||
namespace: Some("skill:gmail:user@example.com"),
|
||||
nodes_deleted: 42,
|
||||
message: "...",
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `list_documents`
|
||||
|
||||
**Endpoint:** `GET /memory/documents?namespace=...&limit=...&offset=...`
|
||||
|
||||
Lists ingested documents with optional namespace filtering and pagination.
|
||||
|
||||
```rust
|
||||
let res = client.list_documents(ListDocumentsParams {
|
||||
namespace: Some("sdk-rust-e2e".to_string()),
|
||||
limit: Some(10.0),
|
||||
offset: Some(0.0),
|
||||
}).await?;
|
||||
```
|
||||
|
||||
**`ListDocumentsParams`**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `namespace` | `Option<String>` | Filter by namespace |
|
||||
| `limit` | `Option<f64>` | Max results to return |
|
||||
| `offset` | `Option<f64>` | Pagination offset |
|
||||
|
||||
Returns `serde_json::Value` with a `data.documents` array. Each document includes `document_id`, `namespace`, `title`, `chunk_count`, `created_at`, `updated_at`, `user_id`.
|
||||
|
||||
---
|
||||
|
||||
### `get_document`
|
||||
|
||||
**Endpoint:** `GET /memory/documents/{documentId}?namespace={namespace}`
|
||||
|
||||
Fetches metadata for a single document by ID.
|
||||
|
||||
```rust
|
||||
let res = client.get_document("my-doc-id", Some("my-namespace")).await?;
|
||||
```
|
||||
|
||||
Returns `serde_json::Value` with `document_id`, `namespace`, `title`, `chunk_count`, `chunk_ids`, timestamps, and `user_id`.
|
||||
|
||||
---
|
||||
|
||||
### `delete_document`
|
||||
|
||||
**Endpoint:** `DELETE /memory/documents/{documentId}?namespace={namespace}`
|
||||
|
||||
Deletes a specific document from a namespace.
|
||||
|
||||
```rust
|
||||
client.delete_document("my-doc-id", "my-namespace").await?;
|
||||
```
|
||||
|
||||
Both `document_id` and `namespace` are required (validated before the request is sent).
|
||||
|
||||
---
|
||||
|
||||
### Other SDK Methods (Available, Not Used in Project)
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `insert_document` | `POST /memory/documents` | Insert via documents route |
|
||||
| `insert_documents_batch` | `POST /memory/documents/batch` | Batch insert documents |
|
||||
| `recall_memories` | `POST /memory/memories/recall` | Recall from Ebbinghaus bank |
|
||||
| `recall_memories_context` | `POST /memory/memories/context` | Recall context from memories |
|
||||
| `recall_thoughts` | `POST /memory/memories/thoughts` | Reflective thought generation |
|
||||
| `interact_memory` | `POST /memory/interact` | Record entity interactions |
|
||||
| `record_interactions` | `POST /memory/interactions` | Record interaction signals |
|
||||
| `query_memory_context` | `POST /memory/queries` | Query alias route |
|
||||
| `chat_memory_context` | `POST /memory/conversations` | Chat with memory context |
|
||||
| `chat_memory` | `POST /memory/chat` | Chat via DeltaNet cache |
|
||||
| `sync_memory` | `POST /memory/sync` | Sync OpenClaw workspace files |
|
||||
| `memory_health` | `GET /memory/health` | Health check |
|
||||
| `get_graph_snapshot` | `GET /memory/admin/graph-snapshot` | Admin graph data |
|
||||
|
||||
---
|
||||
|
||||
## Error Types
|
||||
|
||||
**`TinyHumansError`**
|
||||
|
||||
| Variant | When |
|
||||
|---------|------|
|
||||
| `Validation(String)` | Client-side validation failed (empty token, empty title, etc.) |
|
||||
| `Http(String)` | Network/transport error from `reqwest` |
|
||||
| `Api { message, status, body }` | Non-2xx response from the API |
|
||||
| `Decode(String)` | Failed to deserialise response JSON |
|
||||
|
||||
---
|
||||
|
||||
## Project Integration (`openhuman`)
|
||||
|
||||
### How the SDK is Initialised
|
||||
|
||||
**File:** `src-tauri/src/memory/mod.rs`
|
||||
|
||||
The project wraps `TinyHumansMemoryClient` in a `MemoryClient` struct. Construction happens at runtime via the `init_memory_client` Tauri command, using the user's JWT from Redux `authSlice.token` — not a hardcoded API key.
|
||||
|
||||
```rust
|
||||
pub fn from_token(jwt_token: String) -> Option<Self> {
|
||||
// Base URL resolved in order:
|
||||
// 1. OPENHUMAN_BASE_URL env var
|
||||
// 2. TINYHUMANS_BASE_URL env var
|
||||
// 3. get_backend_url() — app's configured backend
|
||||
let config = TinyHumanConfig::new(jwt_token).with_base_url(resolved_url);
|
||||
TinyHumansMemoryClient::new(config).ok().map(|inner| Self { inner })
|
||||
}
|
||||
```
|
||||
|
||||
The client is stored as `Arc<MemoryClient>` inside a `Mutex<Option<MemoryClientRef>>` (`MemoryState`), shared across Tauri commands.
|
||||
|
||||
---
|
||||
|
||||
### `MemoryClient` Wrapper Methods
|
||||
|
||||
The `MemoryClient` wrapper exposes higher-level methods used throughout the app:
|
||||
|
||||
#### `store_skill_sync`
|
||||
|
||||
Calls `insert_memory` then polls `ingestion_job_status` every **30 seconds** until the job is `completed` or `failed`. Used after skill OAuth completion and periodic skill syncs.
|
||||
|
||||
```rust
|
||||
client.store_skill_sync(
|
||||
skill_id, // becomes the namespace
|
||||
integration_id, // e.g. "user@example.com"
|
||||
title,
|
||||
content,
|
||||
source_type, // Option<SourceType>
|
||||
metadata, // Option<serde_json::Value>
|
||||
priority, // Option<Priority>
|
||||
created_at, // Option<f64>
|
||||
updated_at, // Option<f64>
|
||||
document_id, // Option<String> — auto-generated UUID if None
|
||||
).await?;
|
||||
```
|
||||
|
||||
> Note: polling interval is 30 s (fire-and-forget background task). The E2E test uses `wait_for_ingestion_job` with 1 s polling instead.
|
||||
|
||||
#### `query_skill_context`
|
||||
|
||||
Calls `query_memory` for a skill's namespace. Returns the `response` string from the API (LLM-synthesised answer).
|
||||
|
||||
```rust
|
||||
let context: String = client.query_skill_context(
|
||||
skill_id, // namespace
|
||||
integration_id, // unused currently
|
||||
"What emails were recently synced?",
|
||||
10, // max_chunks
|
||||
).await?;
|
||||
```
|
||||
|
||||
#### `recall_skill_context`
|
||||
|
||||
Calls `recall_memory` for a namespace. Returns `Option<serde_json::Value>` (the raw `context` field).
|
||||
|
||||
```rust
|
||||
let ctx: Option<serde_json::Value> = client.recall_skill_context(
|
||||
skill_id,
|
||||
integration_id,
|
||||
10, // max_chunks
|
||||
).await?;
|
||||
```
|
||||
|
||||
#### `clear_skill_memory`
|
||||
|
||||
Calls `delete_memory` with `namespace = skill_id`. Used on OAuth revoke / skill disconnect.
|
||||
|
||||
```rust
|
||||
client.clear_skill_memory("gmail", "user@example.com").await?;
|
||||
// → DELETE namespace "gmail"
|
||||
```
|
||||
|
||||
#### `query_namespace_context` / `recall_namespace_context`
|
||||
|
||||
Direct namespace versions of query/recall — bypass the `skill:{id}:{id}` namespace convention.
|
||||
|
||||
#### `list_documents` / `delete_document`
|
||||
|
||||
Thin pass-through wrappers over the SDK methods.
|
||||
|
||||
---
|
||||
|
||||
### Memory in the Chat Agentic Loop
|
||||
|
||||
**File:** `src-tauri/src/commands/chat.rs`
|
||||
|
||||
Every `chat_send` call (desktop) performs these memory operations before hitting the inference API:
|
||||
|
||||
**Step 2 — Conversation recall**
|
||||
|
||||
```rust
|
||||
mem.recall_skill_context("conversations", thread_id, 10).await
|
||||
```
|
||||
|
||||
Recalls context from the `conversations` namespace, keyed by `thread_id`. The result is injected into the user message as:
|
||||
|
||||
```
|
||||
[MEMORY_CONTEXT]
|
||||
{recalled context}
|
||||
[/MEMORY_CONTEXT]
|
||||
|
||||
{user message}
|
||||
```
|
||||
|
||||
**Step 2b — Skill context recall**
|
||||
|
||||
For every skill with registered tools, recalls its memory:
|
||||
|
||||
```rust
|
||||
mem.recall_skill_context(skill_id, skill_id, 10).await
|
||||
```
|
||||
|
||||
Each result is injected as:
|
||||
|
||||
```
|
||||
[{SKILL_ID}_CONTEXT]
|
||||
{recalled context}
|
||||
[/{SKILL_ID}_CONTEXT]
|
||||
```
|
||||
|
||||
The full assembled user message sent to the inference API looks like:
|
||||
|
||||
```
|
||||
## Project Context
|
||||
{openclaw_context — SOUL.md, IDENTITY.md, TOOLS.md, etc.}
|
||||
|
||||
User message: {original message}
|
||||
|
||||
[MEMORY_CONTEXT]
|
||||
{conversation memory}
|
||||
[/MEMORY_CONTEXT]
|
||||
|
||||
[GMAIL_CONTEXT]
|
||||
{gmail skill memory}
|
||||
[/GMAIL_CONTEXT]
|
||||
|
||||
{notion_context if present}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Namespace Conventions
|
||||
|
||||
| Context | Namespace pattern | Set by |
|
||||
|---------|-------------------|--------|
|
||||
| Skill sync (OAuth / periodic) | `{skill_id}` | `store_skill_sync` — uses `skill_id` directly as namespace |
|
||||
| Skill memory clear | `{skill_id}` | `clear_skill_memory` |
|
||||
| Conversation recall | `conversations` | `chat_send_inner` hardcoded |
|
||||
| Skill context recall (in chat) | `{skill_id}` | `chat_send_inner` per-skill loop |
|
||||
| E2E test | `sdk-rust-e2e` | `example_e2e.rs` |
|
||||
|
||||
---
|
||||
|
||||
### All SDK Calls in the Project
|
||||
|
||||
| Location | SDK method called | Purpose |
|
||||
|----------|-------------------|---------|
|
||||
| `memory/mod.rs::store_skill_sync` | `insert_memory` | Write skill sync data |
|
||||
| `memory/mod.rs::store_skill_sync` | `ingestion_job_status` (poll loop) | Wait for ingestion to complete |
|
||||
| `memory/mod.rs::query_skill_context` | `query_memory` | RAG query for skill context |
|
||||
| `memory/mod.rs::recall_skill_context` | `recall_memory` | Recall synthesised context |
|
||||
| `memory/mod.rs::recall_namespace_context` | `recall_memory` | Direct namespace recall |
|
||||
| `memory/mod.rs::query_namespace_context` | `query_memory` | Direct namespace query |
|
||||
| `memory/mod.rs::list_documents` | `list_documents` | List ingested documents |
|
||||
| `memory/mod.rs::delete_document` | `delete_document` | Remove a document |
|
||||
| `memory/mod.rs::clear_skill_memory` | `delete_memory` | Wipe skill namespace on disconnect |
|
||||
| `commands/chat.rs` (step 2) | via `recall_skill_context` | Inject conversation history into prompt |
|
||||
| `commands/chat.rs` (step 2b) | via `recall_skill_context` | Inject per-skill context into prompt |
|
||||
+1
-1
Submodule skills updated: 0685a6af4e...e856b8e756
Generated
+2
-2
@@ -8457,9 +8457,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinyhumansai"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "691a096ecaaeec23ac7bbcfb276058de9d4aff1c574ee074125d5e5080e17b7d"
|
||||
checksum = "8c731df99d616c1918cab54ee749e51f1e181675e2df1f30be3f46f1e9d9d727"
|
||||
dependencies = [
|
||||
"log",
|
||||
"reqwest 0.12.28",
|
||||
|
||||
@@ -138,7 +138,7 @@ opentelemetry_sdk = { version = "0.31", default-features = false, features = ["t
|
||||
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] }
|
||||
tokio-stream = { version = "0.1.18", features = ["full"] }
|
||||
url = "2"
|
||||
tinyhumansai = "0.1.5"
|
||||
tinyhumansai = "0.1.6"
|
||||
|
||||
# Optional integrations
|
||||
matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] }
|
||||
|
||||
@@ -106,3 +106,35 @@ OpenHuman is a cross-platform crypto community platform built with Tauri (React
|
||||
- **Respect rate limits** on all integrations — batch operations when possible
|
||||
- **Handle errors gracefully** — network issues and API failures are common in crypto infrastructure
|
||||
- **Default to caution** with financial topics — frame analysis as information, not advice
|
||||
|
||||
## Memory Layer
|
||||
|
||||
OpenHuman maintains a persistent memory layer (TinyHumans Neocortex) that stores skill sync data, conversation history, and integration state.
|
||||
|
||||
### recall_memory
|
||||
|
||||
Automatically called before every conversation turn. Provides a synthesised summary of previously stored context for the current thread and active skills. Injected into your context as `[MEMORY_CONTEXT]`.
|
||||
|
||||
### queryMemory
|
||||
|
||||
An active semantic search over stored memory. Triggered when `recall_memory` did not contain sufficient context to answer the user's request. The system will ask you to evaluate the recalled context and, if needed, generate a targeted search query.
|
||||
|
||||
**When asked for a sufficiency check, respond in JSON only — no other text:**
|
||||
|
||||
- If the recalled context is sufficient to answer the user: `{"needs_query": false}`
|
||||
- If more specific context is needed: `{"needs_query": true, "skill_id": "<skill namespace>", "query": "<your targeted question>"}`
|
||||
|
||||
**Choosing `skill_id`:**
|
||||
|
||||
- Use the skill namespace that holds the relevant data (e.g. `"notion"`, `"gmail"`, `"slack"`, `"github"`)
|
||||
- Use `"conversations"` for general conversation history not tied to a specific integration
|
||||
- The available skill namespaces are listed in the sufficiency-check prompt under `Available skill namespaces`
|
||||
|
||||
**Writing a good query:**
|
||||
|
||||
- Be specific and targeted — generic terms return poor results
|
||||
- Base the query on exactly what information is missing for the user's request
|
||||
- Bad: `"gmail data"` — Good: `"What emails arrived from alice@example.com about the Q1 budget report this week?"`
|
||||
- Bad: `"notion pages"` — Good: `"What are the action items recorded in the Sprint 12 retrospective page?"`
|
||||
|
||||
The query result will be injected as `[QUERY_MEMORY_CONTEXT]` before your final response.
|
||||
|
||||
@@ -615,6 +615,160 @@ async fn chat_send_inner(
|
||||
skill_contexts.len()
|
||||
);
|
||||
|
||||
// ── Step 2c: Query memory if recall context is insufficient ──────────
|
||||
//
|
||||
// Ask the LLM whether the recalled context is sufficient to answer the
|
||||
// user. If not, it generates a targeted query and we call queryMemory
|
||||
// to fetch extended context before building the final prompt.
|
||||
let query_memory_context: Option<String> = if let Some(ref mem) = memory_client {
|
||||
if cancel.is_cancelled() {
|
||||
return Err("Request cancelled".to_string());
|
||||
}
|
||||
|
||||
let recall_summary = memory_context.as_deref().unwrap_or("");
|
||||
let skill_summary = skill_contexts.join("\n");
|
||||
|
||||
// Build the list of valid skill IDs for the LLM to choose from.
|
||||
// "conversations" covers general conversation history.
|
||||
let mut available_skills: Vec<String> = skill_ids.iter().cloned().collect();
|
||||
available_skills.sort();
|
||||
available_skills.push("conversations".to_string());
|
||||
let skill_list = available_skills.join(", ");
|
||||
|
||||
let sufficiency_prompt = format!(
|
||||
"You are a memory sufficiency checker. Given the recalled memory context and the \
|
||||
user's question, decide if the recalled context contains enough specific information \
|
||||
to answer the user.\n\n\
|
||||
Recalled context:\n{recall_summary}\n{skill_summary}\n\n\
|
||||
User message: {user_message}\n\n\
|
||||
Available skill namespaces: {skill_list}\n\n\
|
||||
Respond in JSON only:\n\
|
||||
- If sufficient: {{\"needs_query\": false}}\n\
|
||||
- If more context needed: {{\"needs_query\": true, \"skill_id\": \"<skill namespace that holds the relevant data>\", \"query\": \"<specific targeted question>\"}}"
|
||||
);
|
||||
|
||||
let check_body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": sufficiency_prompt}],
|
||||
});
|
||||
|
||||
let check_url = format!("{}/openai/v1/chat/completions", backend_url);
|
||||
|
||||
log::info!("[chat] Step 2c: running memory sufficiency check");
|
||||
log::info!(
|
||||
"[chat] Step 2c request body: {}",
|
||||
serde_json::to_string_pretty(&check_body).unwrap_or_default()
|
||||
);
|
||||
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
client
|
||||
.post(&check_url)
|
||||
.header("Authorization", format!("Bearer {}", auth_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&check_body)
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) if resp.status().is_success() => {
|
||||
match resp.json::<ChatCompletionResponse>().await {
|
||||
Ok(completion) => {
|
||||
let content = completion
|
||||
.choices
|
||||
.first()
|
||||
.and_then(|c| c.message.content.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
match serde_json::from_str::<serde_json::Value>(content) {
|
||||
Ok(parsed) => {
|
||||
if parsed.get("needs_query").and_then(|v| v.as_bool())
|
||||
== Some(true)
|
||||
{
|
||||
if let Some(query) =
|
||||
parsed.get("query").and_then(|v| v.as_str())
|
||||
{
|
||||
let skill_id = parsed
|
||||
.get("skill_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("conversations");
|
||||
log::info!(
|
||||
"[chat] Sufficiency check: needs_query=true, skill_id={skill_id:?}, query={query:?}"
|
||||
);
|
||||
match mem
|
||||
.query_skill_context(
|
||||
skill_id,
|
||||
thread_id,
|
||||
query,
|
||||
10,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) if !result.is_empty() => {
|
||||
log::info!(
|
||||
"[chat] queryMemory returned {} chars",
|
||||
result.len()
|
||||
);
|
||||
Some(result)
|
||||
}
|
||||
Ok(_) => {
|
||||
log::info!(
|
||||
"[chat] queryMemory returned empty result"
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[chat] queryMemory failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"[chat] Sufficiency check: needs_query=true but no query field"
|
||||
);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
log::info!(
|
||||
"[chat] Sufficiency check: recall context is sufficient"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"[chat] Sufficiency check: failed to parse JSON response: {content}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[chat] Sufficiency check: failed to parse inference response: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Ok(resp)) => {
|
||||
log::warn!(
|
||||
"[chat] Sufficiency check: inference returned HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
None
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("[chat] Sufficiency check: network error: {e}");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("[chat] Sufficiency check: timed out after 30s, skipping queryMemory");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ── Step 3: Build processed user message ────────────────────────────
|
||||
let mut processed = user_message.to_string();
|
||||
|
||||
@@ -633,6 +787,12 @@ async fn chat_send_inner(
|
||||
processed = format!("{}\n\n{}", skill_contexts.join("\n\n"), processed);
|
||||
}
|
||||
|
||||
if let Some(ref qctx) = query_memory_context {
|
||||
processed = format!(
|
||||
"[QUERY_MEMORY_CONTEXT]\n{qctx}\n[/QUERY_MEMORY_CONTEXT]\n\n{processed}"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref notion) = notion_context {
|
||||
processed = format!("{}\n\n{}", notion, processed);
|
||||
}
|
||||
@@ -699,7 +859,7 @@ async fn chat_send_inner(
|
||||
loop_messages.len(),
|
||||
url
|
||||
);
|
||||
log::debug!(
|
||||
log::info!(
|
||||
"[chat] Request body: {}",
|
||||
serde_json::to_string_pretty(&request_body).unwrap_or_default()
|
||||
);
|
||||
@@ -1078,6 +1238,10 @@ async fn chat_send_mobile(
|
||||
model,
|
||||
messages.len()
|
||||
);
|
||||
log::info!(
|
||||
"[chat] Request body: {}",
|
||||
serde_json::to_string_pretty(&request_body).unwrap_or_default()
|
||||
);
|
||||
|
||||
let response = tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
|
||||
@@ -85,7 +85,7 @@ impl MemoryClient {
|
||||
let insert_resp = self
|
||||
.inner
|
||||
.insert_memory(InsertMemoryParams {
|
||||
document_id: Some(document_id_final),
|
||||
document_id: document_id_final,
|
||||
title: title.to_string(),
|
||||
content: content.to_string(),
|
||||
namespace: namespace.clone(),
|
||||
@@ -118,7 +118,7 @@ impl MemoryClient {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
|
||||
match self.inner.ingestion_job_status(&job_id).await {
|
||||
match self.inner.get_ingestion_job(&job_id).await {
|
||||
Ok(status_resp) => {
|
||||
let state = status_resp
|
||||
.data
|
||||
@@ -241,7 +241,7 @@ impl MemoryClient {
|
||||
/// List all ingested memory documents as returned by the API.
|
||||
pub async fn list_documents(&self) -> Result<serde_json::Value, String> {
|
||||
self.inner
|
||||
.list_documents()
|
||||
.list_documents(tinyhumansai::ListDocumentsParams::default())
|
||||
.await
|
||||
.map_err(|e| format!("Memory list documents failed: {e}"))
|
||||
}
|
||||
@@ -295,9 +295,9 @@ impl MemoryClient {
|
||||
pub async fn clear_skill_memory(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
integration_id: &str,
|
||||
_integration_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let namespace = format!("skill:{skill_id}:{integration_id}");
|
||||
let namespace = skill_id.to_string();
|
||||
log::info!("[memory] clear_skill_memory: entry (namespace={namespace})");
|
||||
log::debug!("[memory] clear_skill_memory: payload → namespace={namespace}");
|
||||
let result = self.inner
|
||||
|
||||
Reference in New Issue
Block a user