diff --git a/docs/sdk-rust-e2e-test-run.md b/docs/sdk-rust-e2e-test-run.md new file mode 100644 index 000000000..1b70a7792 --- /dev/null +++ b/docs/sdk-rust-e2e-test-run.md @@ -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 diff --git a/docs/tinyhumansai-sdk.md b/docs/tinyhumansai-sdk.md new file mode 100644 index 000000000..4412543f9 --- /dev/null +++ b/docs/tinyhumansai-sdk.md @@ -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 +``` + +**`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` | No | `Doc` (default), `Chat`, or `Email` | +| `metadata` | `Option` | No | Arbitrary JSON metadata | +| `priority` | `Option` | No | `High`, `Medium`, or `Low` | +| `created_at` | `Option` | No | Unix timestamp (ms) | +| `updated_at` | `Option` | 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` | The job ID | +| `state` | `Option` | `pending`, `processing`, `completed`, `failed`, etc. | +| `endpoint` | `Option` | API endpoint that created the job | +| `attempts` | `Option` | Number of execution attempts | +| `error` | `Option` | Error message if failed | +| `response` | `Option` | Full ingestion result (stats, timings, usage) on completion | +| `created_at` | `Option` | ISO 8601 timestamp | +| `started_at` | `Option` | ISO 8601 timestamp | +| `completed_at` | `Option` | 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` | 30 000 | Max wait in milliseconds | +| `poll_interval_ms` | `Option` | 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` | `"namespace"` | Filter by namespace | +| `include_references` | `Option` | `"includeReferences"` | Include source chunk metadata | +| `max_chunks` | `Option` | `"maxChunks"` | Max chunks to retrieve | +| `document_ids` | `Option>` | `"documentIds"` | Filter to specific documents | +| `llm_query` | `Option` | `"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` | `"namespace"` | Namespace to recall from | +| `max_chunks` | `Option` | `"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` | Filter by namespace | +| `limit` | `Option` | Max results to return | +| `offset` | `Option` | 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 { + // 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` inside a `Mutex>` (`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 + metadata, // Option + priority, // Option + created_at, // Option + updated_at, // Option + document_id, // Option — 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` (the raw `context` field). + +```rust +let ctx: Option = 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 | diff --git a/skills b/skills index 0685a6af4..e856b8e75 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 0685a6af4ef3ddea4fb26364dd72ea574f307314 +Subproject commit e856b8e756ad7ea86f36dbd1697542e76a8d3571 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9b02b47de..202416d22 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 47fe4961f..d746ea7c6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/ai/MEMORY.md b/src-tauri/ai/MEMORY.md index 9c0fd38b2..ff0e034ff 100644 --- a/src-tauri/ai/MEMORY.md +++ b/src-tauri/ai/MEMORY.md @@ -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": "", "query": ""}` + +**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. diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index 2d0919d0a..a9a1ea124 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -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 = 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 = 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\": \"\", \"query\": \"\"}}" + ); + + 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::().await { + Ok(completion) => { + let content = completion + .choices + .first() + .and_then(|c| c.message.content.as_deref()) + .unwrap_or(""); + + match serde_json::from_str::(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() => { diff --git a/src-tauri/src/memory/mod.rs b/src-tauri/src/memory/mod.rs index ce83b4416..995a118ba 100644 --- a/src-tauri/src/memory/mod.rs +++ b/src-tauri/src/memory/mod.rs @@ -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 { 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