mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Feat/chat issue (#441)
* feat: display app version in settings panel * fix(onboarding): auto-refresh accessibility state after grant (#351) * style(onboarding): apply formatter for issue #351 fix * fix(onboarding): ESLint + typed mock for ScreenPermissionsStep; clear flag in handler Consolidate tauriCommands imports and drop redundant mock cast. Handle granted accessibility in focus/visibility callback instead of a follow-up effect. Made-with: Cursor * feat(env): add support for custom dotenv path and update dependencies - Introduced an optional environment variable `OPENHUMAN_DOTENV_PATH` to specify a custom path for dotenv files, enhancing configuration flexibility. - Updated `Cargo.toml` to include the `dotenvy` dependency for improved dotenv file handling. - Enhanced the `.env.example` file with a new comment for the custom dotenv path. - Added data-testid attributes and button types in `SkillDebugModal` and `Skills` components for better testability. - Created new tests for Gmail and Notion third-party skills to ensure proper functionality of sync and debug tools. - Added documentation for memory sync functions to clarify usage patterns and function details. * fix: address CodeRabbit review on PR #441 - Dotenv: treat empty OPENHUMAN_DOTENV_PATH as unset; propagate from_path errors - Document OPENHUMAN_DOTENV_PATH parent-env requirement in .env.example - Memory docs: MD040 fence language; clarify skill namespace vs integration id - QuickJS bootstrap: modern helpers, generic platform.notify log, template URLs - Skills UI: type=button on close/settings; async waitFor in sync tests - Gmail OAuth e2e: workspace env matches MemoryClient; env/engine drop guards; redact secrets from logs - Add replace_global_engine for test teardown Made-with: Cursor
This commit is contained in:
@@ -34,6 +34,10 @@ OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc
|
||||
OPENHUMAN_CORE_RUN_MODE=child
|
||||
# [optional] Override path to openhuman core binary (leave blank for auto-detection)
|
||||
OPENHUMAN_CORE_BIN=
|
||||
# [optional] Explicit .env path for `openhuman serve` / `openhuman run` (loaded before the server starts).
|
||||
# Must be set in the parent environment (exported in your shell or service manager). It is read before
|
||||
# any dotenv file is loaded, so defining OPENHUMAN_DOTENV_PATH inside a .env file cannot select that file.
|
||||
# OPENHUMAN_DOTENV_PATH=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config overrides (override config.toml values at runtime)
|
||||
|
||||
Generated
+7
@@ -1874,6 +1874,12 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
@@ -5135,6 +5141,7 @@ dependencies = [
|
||||
"dialoguer",
|
||||
"directories",
|
||||
"dirs 5.0.1",
|
||||
"dotenvy",
|
||||
"enigo",
|
||||
"env_logger",
|
||||
"fantoccini",
|
||||
|
||||
@@ -60,6 +60,7 @@ prost = { version = "0.14", default-features = false }
|
||||
postgres = { version = "0.19", features = ["with-chrono-0_4"] }
|
||||
chrono-tz = "0.10"
|
||||
dialoguer = { version = "0.12", features = ["fuzzy-select"] }
|
||||
dotenvy = "0.15"
|
||||
console = "0.16"
|
||||
glob = "0.3"
|
||||
regex = "1.10"
|
||||
|
||||
@@ -129,6 +129,7 @@ export default function SkillDebugModal({ skillId, skillName, onClose }: SkillDe
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-stone-400 hover:text-stone-900 transition-colors rounded-lg hover:bg-stone-100">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -142,6 +143,8 @@ export default function SkillDebugModal({ skillId, skillName, onClose }: SkillDe
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
data-testid={tab.id === 'tools' ? 'skill-debug-tab-tools' : 'skill-debug-tab-state'}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
@@ -273,6 +276,8 @@ function ToolsTab({
|
||||
<div key={tool.name} className="border border-stone-200 rounded-xl overflow-hidden">
|
||||
{/* Tool header */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`skill-debug-tool-header-${tool.name}`}
|
||||
onClick={() => setExpandedTool(isExpanded ? null : tool.name)}
|
||||
className="w-full flex items-center justify-between p-3 text-left hover:bg-stone-50 transition-colors">
|
||||
<div className="min-w-0">
|
||||
@@ -309,6 +314,7 @@ function ToolsTab({
|
||||
Arguments (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
data-testid={`skill-debug-tool-args-${tool.name}`}
|
||||
value={toolArgs[tool.name] ?? '{}'}
|
||||
onChange={e => setToolArgs({ ...toolArgs, [tool.name]: e.target.value })}
|
||||
placeholder="{}"
|
||||
@@ -319,6 +325,8 @@ function ToolsTab({
|
||||
|
||||
{/* Call button */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`skill-debug-execute-${tool.name}`}
|
||||
onClick={() => onCallTool(tool.name)}
|
||||
disabled={isLoading}
|
||||
className="w-full py-2 text-xs font-medium bg-primary-50 text-primary-500 border border-primary-500/30 rounded-lg hover:bg-primary-100 disabled:opacity-50 transition-colors flex items-center justify-center gap-2">
|
||||
|
||||
@@ -282,10 +282,13 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
|
||||
<>
|
||||
{/* Sync */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={isSyncing ? undefined : handleSync}
|
||||
disabled={isSyncing}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-stone-400 hover:text-stone-700 hover:bg-stone-100 transition-colors disabled:opacity-40"
|
||||
title="Sync">
|
||||
title="Sync"
|
||||
aria-label={`Sync ${skill.name}`}
|
||||
data-testid={`skill-sync-button-${skill.id}`}>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
@@ -301,6 +304,7 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
|
||||
</button>
|
||||
{/* Settings */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onSetup();
|
||||
@@ -324,6 +328,8 @@ function SkillCard({ skill, onSetup }: SkillCardProps) {
|
||||
</button>
|
||||
{/* Debug */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`skill-debug-button-${skill.id}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setDebugOpen(true);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Skills page — 3rd Party Skills: manual sync triggers core `openhuman.skills_sync`
|
||||
* via `skillManager.triggerSync` (see `lib/skills/manager.ts`).
|
||||
*/
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import Skills from '../Skills';
|
||||
|
||||
const gmailRegistryEntry = {
|
||||
id: 'gmail',
|
||||
name: 'Gmail',
|
||||
version: '1.1.0',
|
||||
description: 'Read and send email via Gmail.',
|
||||
runtime: 'quickjs',
|
||||
entry: 'index.js',
|
||||
auto_start: false,
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
setup: { required: true, label: 'Connect Gmail' },
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
triggerSync: vi.fn().mockResolvedValue(undefined),
|
||||
startSkill: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChannelDefinitions', () => ({
|
||||
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/skills/manager', () => ({
|
||||
skillManager: { triggerSync: mocks.triggerSync, startSkill: mocks.startSkill },
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/skills/skillsApi', () => ({
|
||||
installSkill: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/skills/hooks', () => ({
|
||||
useAvailableSkills: () => ({ skills: [gmailRegistryEntry], loading: false, refresh: vi.fn() }),
|
||||
useSkillConnectionStatus: (skillId: string) => (skillId === 'gmail' ? 'connected' : 'offline'),
|
||||
useSkillState: () => ({
|
||||
connection_status: 'connected',
|
||||
auth_status: 'authenticated',
|
||||
syncInProgress: false,
|
||||
syncProgress: 0,
|
||||
syncProgressMessage: '',
|
||||
}),
|
||||
useSkillDataDirectoryStats: () => undefined,
|
||||
}));
|
||||
|
||||
describe('Skills page — 3rd Party Gmail sync', () => {
|
||||
beforeEach(() => {
|
||||
mocks.triggerSync.mockClear();
|
||||
mocks.startSkill.mockClear();
|
||||
});
|
||||
|
||||
it('renders 3rd Party Skills and Gmail with a Sync control when connected', async () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
expect(screen.getByRole('heading', { name: '3rd Party Skills' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Gmail')).toBeInTheDocument();
|
||||
|
||||
const syncBtn = screen.getByTestId('skill-sync-button-gmail');
|
||||
expect(syncBtn).toBeInTheDocument();
|
||||
expect(syncBtn).toHaveAttribute('aria-label', 'Sync Gmail');
|
||||
|
||||
fireEvent.click(syncBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.triggerSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mocks.triggerSync).toHaveBeenCalledWith('gmail');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Skills page — 3rd Party Notion: debug modal tool execution matches
|
||||
* `openhuman-skills/src/core/notion/live-test.ts` tool exercises (sections 5b + 7).
|
||||
* UI path: Skills → Debug → Tools tab → expand tool → Execute → `openhuman.skills_call_tool`.
|
||||
*/
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import Skills from '../Skills';
|
||||
|
||||
const notionRegistryEntry = {
|
||||
id: 'notion',
|
||||
name: 'Notion',
|
||||
version: '1.2.0',
|
||||
description: 'Notion workspace integration.',
|
||||
runtime: 'quickjs',
|
||||
entry: 'index.js',
|
||||
auto_start: false,
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
setup: { required: true, label: 'Connect Notion' },
|
||||
};
|
||||
|
||||
const notionSnapshot = {
|
||||
skill_id: 'notion',
|
||||
name: 'Notion',
|
||||
status: 'running',
|
||||
tools: [
|
||||
{ name: 'sync-status', description: 'Sync status', inputSchema: { type: 'object' } },
|
||||
{ name: 'list-users', description: 'List users', inputSchema: { type: 'object' } },
|
||||
{ name: 'search', description: 'Search', inputSchema: { type: 'object' } },
|
||||
{ name: 'list-pages', description: 'List pages', inputSchema: { type: 'object' } },
|
||||
{ name: 'list-databases', description: 'List databases', inputSchema: { type: 'object' } },
|
||||
],
|
||||
error: null as string | null,
|
||||
state: { connection_status: 'connected', auth_status: 'authenticated', is_initialized: true },
|
||||
setup_complete: true,
|
||||
connection_status: 'connected',
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
triggerSync: vi.fn().mockResolvedValue(undefined),
|
||||
startSkill: vi.fn().mockResolvedValue(undefined),
|
||||
callCoreRpc: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: [{ type: 'text', text: '{"ok":true}' }], is_error: false }),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChannelDefinitions', () => ({
|
||||
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/skills/manager', () => ({
|
||||
skillManager: { triggerSync: mocks.triggerSync, startSkill: mocks.startSkill },
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/skills/skillsApi', () => ({
|
||||
installSkill: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: mocks.callCoreRpc }));
|
||||
|
||||
vi.mock('../../lib/skills/hooks', () => ({
|
||||
useAvailableSkills: () => ({ skills: [notionRegistryEntry], loading: false, refresh: vi.fn() }),
|
||||
useSkillConnectionStatus: (skillId: string) => (skillId === 'notion' ? 'connected' : 'offline'),
|
||||
useSkillState: () => ({
|
||||
connection_status: 'connected',
|
||||
auth_status: 'authenticated',
|
||||
syncInProgress: false,
|
||||
syncProgress: 0,
|
||||
syncProgressMessage: '',
|
||||
}),
|
||||
useSkillDataDirectoryStats: () => undefined,
|
||||
useSkillSnapshot: (skillId: string | undefined) => (skillId === 'notion' ? notionSnapshot : null),
|
||||
}));
|
||||
|
||||
function expectToolCallArgs(toolName: string, args: Record<string, unknown>) {
|
||||
expect(mocks.callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.skills_call_tool',
|
||||
params: { skill_id: 'notion', tool_name: toolName, arguments: args },
|
||||
});
|
||||
}
|
||||
|
||||
describe('Skills page — Notion debug tools (live-test parity)', () => {
|
||||
beforeEach(() => {
|
||||
mocks.triggerSync.mockClear();
|
||||
mocks.startSkill.mockClear();
|
||||
mocks.callCoreRpc.mockClear();
|
||||
});
|
||||
|
||||
it('opens debug modal and executes tools via openhuman.skills_call_tool', async () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
expect(screen.getByRole('heading', { name: '3rd Party Skills' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('skill-debug-button-notion'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Debug: Notion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('skill-debug-tab-tools'));
|
||||
|
||||
// --- Section 5b / 7: same tools as live-test.ts (defaults + search query) ---
|
||||
const steps: Array<{ tool: string; argsJson?: string }> = [
|
||||
{ tool: 'sync-status' },
|
||||
{ tool: 'list-users' },
|
||||
{ tool: 'search', argsJson: '{"query":"test"}' },
|
||||
{ tool: 'list-pages', argsJson: '{"page_size":10}' },
|
||||
{ tool: 'list-databases' },
|
||||
];
|
||||
|
||||
for (const step of steps) {
|
||||
mocks.callCoreRpc.mockClear();
|
||||
fireEvent.click(screen.getByTestId(`skill-debug-tool-header-${step.tool}`));
|
||||
if (step.argsJson) {
|
||||
fireEvent.change(screen.getByTestId(`skill-debug-tool-args-${step.tool}`), {
|
||||
target: { value: step.argsJson },
|
||||
});
|
||||
}
|
||||
fireEvent.click(screen.getByTestId(`skill-debug-execute-${step.tool}`));
|
||||
await waitFor(() => {
|
||||
expect(mocks.callCoreRpc).toHaveBeenCalled();
|
||||
});
|
||||
const parsed =
|
||||
step.argsJson !== undefined ? JSON.parse(step.argsJson) : ({} as Record<string, unknown>);
|
||||
expectToolCallArgs(step.tool, parsed);
|
||||
}
|
||||
|
||||
// --- live-test: repeat list-* with tryCache:false (same tool rows, new args) ---
|
||||
mocks.callCoreRpc.mockClear();
|
||||
fireEvent.click(screen.getByTestId('skill-debug-tool-header-list-pages'));
|
||||
fireEvent.change(screen.getByTestId('skill-debug-tool-args-list-pages'), {
|
||||
target: { value: '{"page_size":10,"tryCache":false}' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('skill-debug-execute-list-pages'));
|
||||
await waitFor(() => expect(mocks.callCoreRpc).toHaveBeenCalled());
|
||||
expectToolCallArgs('list-pages', { page_size: 10, tryCache: false });
|
||||
|
||||
mocks.callCoreRpc.mockClear();
|
||||
fireEvent.click(screen.getByTestId('skill-debug-tool-header-list-databases'));
|
||||
fireEvent.change(screen.getByTestId('skill-debug-tool-args-list-databases'), {
|
||||
target: { value: '{"tryCache":false}' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('skill-debug-execute-list-databases'));
|
||||
await waitFor(() => expect(mocks.callCoreRpc).toHaveBeenCalled());
|
||||
expectToolCallArgs('list-databases', { tryCache: false });
|
||||
|
||||
mocks.callCoreRpc.mockClear();
|
||||
fireEvent.click(screen.getByTestId('skill-debug-tool-header-list-users'));
|
||||
fireEvent.change(screen.getByTestId('skill-debug-tool-args-list-users'), {
|
||||
target: { value: '{"tryCache":false}' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('skill-debug-execute-list-users'));
|
||||
await waitFor(() => expect(mocks.callCoreRpc).toHaveBeenCalled());
|
||||
expectToolCallArgs('list-users', { tryCache: false });
|
||||
});
|
||||
|
||||
it('Sync control calls skillManager.triggerSync(notion)', async () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
fireEvent.click(screen.getByTestId('skill-sync-button-notion'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.triggerSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mocks.triggerSync).toHaveBeenCalledWith('notion');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
# Memory Module — Consumer Reference
|
||||
|
||||
How to use the memory layer from services outside `src/openhuman/memory/`.
|
||||
|
||||
**Rule**: Always use `MemoryClient` (`src/openhuman/memory/store/client.rs`). Never call `UnifiedMemory` directly — it's internal to the memory module.
|
||||
|
||||
---
|
||||
|
||||
## Which Function to Use
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```text
|
||||
Need to store data?
|
||||
|
|
||||
+-- Structured key-value pair (config, state, counters)?
|
||||
| -> kv_set()
|
||||
|
|
||||
+-- Full document (text, sync payload, user content)?
|
||||
| |
|
||||
| +-- Ephemeral / high-frequency (screen captures, ticks)?
|
||||
| | -> put_doc_light() (no embedding, no graph extraction)
|
||||
| |
|
||||
| +-- Important content that should be semantically searchable?
|
||||
| | -> put_doc() (embeds + background graph extraction)
|
||||
| |
|
||||
| +-- Rich content needing entity/relation extraction NOW?
|
||||
| -> ingest_doc() (full synchronous pipeline, slower)
|
||||
|
|
||||
+-- Knowledge graph fact (entity-relation-entity)?
|
||||
-> graph_upsert()
|
||||
|
||||
Need to read data?
|
||||
|
|
||||
+-- Have a user query / search string?
|
||||
| -> query_namespace() (returns text for LLM prompt)
|
||||
| -> query_namespace_context_data() (returns structured data)
|
||||
|
|
||||
+-- Need recent context, no specific query?
|
||||
| -> recall_namespace() (returns text)
|
||||
| -> recall_namespace_context_data() (returns structured data)
|
||||
| -> recall_namespace_memories() (returns individual hits)
|
||||
|
|
||||
+-- Looking up a specific key?
|
||||
| -> kv_get()
|
||||
|
|
||||
+-- Listing what exists?
|
||||
| -> list_documents(), list_namespaces(), kv_list_namespace()
|
||||
|
|
||||
+-- Querying entity relationships?
|
||||
-> graph_query()
|
||||
|
||||
Need to delete data?
|
||||
|
|
||||
+-- Single document? -> delete_document()
|
||||
+-- Single KV entry? -> kv_delete()
|
||||
+-- All data for a skill (e.g. on disconnect/revoke)? -> clear_skill_memory()
|
||||
+-- All data in namespace? -> clear_namespace()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Writing Data
|
||||
|
||||
#### `put_doc()` — General-purpose document storage
|
||||
|
||||
Use when content should be **semantically searchable** later. Embeds the content and enqueues background graph extraction.
|
||||
|
||||
```rust
|
||||
client.put_doc(NamespaceDocumentInput {
|
||||
namespace: "autocomplete-memory".into(),
|
||||
key: Some(format!("completion:{timestamp:018}")),
|
||||
title: "Accepted completion".into(),
|
||||
content: format!("{context}\n---\n{suggestion}"),
|
||||
source_type: Some("autocomplete".into()),
|
||||
metadata: Some(json!({ "app_name": app, "timestamp_ms": ts })),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
```
|
||||
|
||||
**Used by**: Skills JS `memory.insert()`, Autocomplete (searchable completions), Subconscious (working-memory docs).
|
||||
|
||||
#### `put_doc_light()` — High-frequency / ephemeral storage
|
||||
|
||||
Use when data is **written often** and doesn't need embedding or graph extraction. Much faster than `put_doc()`.
|
||||
|
||||
```rust
|
||||
client.put_doc_light(NamespaceDocumentInput {
|
||||
namespace: "vision".into(),
|
||||
key: Some(format!("screen_intelligence_{id}")),
|
||||
title: format!("Screen: {app_name} — {window_title}"),
|
||||
content: yaml_frontmatter_and_text,
|
||||
..Default::default()
|
||||
}).await?;
|
||||
```
|
||||
|
||||
**Used by**: Screen Intelligence (vision summaries every few seconds).
|
||||
|
||||
#### `ingest_doc()` — Full synchronous ingestion
|
||||
|
||||
Use when you need the complete pipeline (chunking, embedding, entity extraction, relation extraction) to **finish before proceeding**. Blocks until done. Prefer `put_doc()` unless you need synchronous guarantees.
|
||||
|
||||
**Used by**: Skills event loop (ingesting sync content into vector graph).
|
||||
|
||||
#### `kv_set()` — Structured key-value storage
|
||||
|
||||
Use for **small, structured data** you'll look up by exact key. Not semantically searchable.
|
||||
|
||||
```rust
|
||||
client.kv_set(
|
||||
Some("autocomplete"), // namespace (None = global)
|
||||
&format!("accepted:{ts:018}"), // key
|
||||
&json!({ "context": ctx, "suggestion": s }),
|
||||
).await?;
|
||||
```
|
||||
|
||||
**Used by**: Autocomplete (completion records keyed by timestamp).
|
||||
|
||||
#### `graph_upsert()` — Knowledge graph facts
|
||||
|
||||
Use to store **entity-relation-entity triples**. You rarely call this directly — `put_doc()` and `ingest_doc()` extract graph relations automatically. Only call `graph_upsert()` when you have an explicit fact without an associated document.
|
||||
|
||||
---
|
||||
|
||||
### Reading Data
|
||||
|
||||
#### `query_namespace()` — Semantic search (text)
|
||||
|
||||
Use when you have a **search string** and want relevant content. Returns formatted text ready for LLM prompt injection.
|
||||
|
||||
```rust
|
||||
let context = client.query_namespace(
|
||||
"autocomplete-memory", // namespace
|
||||
&last_80_chars, // query
|
||||
10, // max chunks
|
||||
).await?;
|
||||
```
|
||||
|
||||
**Used by**: Autocomplete (matching past completions to current context), Frontend (user-initiated search in MemoryWorkspace).
|
||||
|
||||
#### `query_namespace_context_data()` — Semantic search (structured)
|
||||
|
||||
Same query, but returns `NamespaceRetrievalContext` with individual hits, entities, relations, and score breakdowns. Use when you need to process results programmatically.
|
||||
|
||||
#### `recall_namespace()` — Recent context (text)
|
||||
|
||||
Use when you need **recent memory** but don't have a query. Returns most recent docs as formatted text.
|
||||
|
||||
```rust
|
||||
if let Ok(Some(text)) = client.recall_namespace(&ns, 3).await {
|
||||
// top 3 chunks of recent context
|
||||
}
|
||||
```
|
||||
|
||||
**Used by**: Subconscious (fetching context per namespace for situation report).
|
||||
|
||||
#### `recall_namespace_memories()` — Recent context (individual hits)
|
||||
|
||||
Returns `Vec<NamespaceMemoryHit>` instead of text. Use when you need to iterate hits and inspect scores.
|
||||
|
||||
#### `recall_namespace_context_data()` — Recent context (structured)
|
||||
|
||||
Returns `NamespaceRetrievalContext`. Use when you need the full retrieval object (hits + entities + relations).
|
||||
|
||||
#### `kv_get()` — Exact key lookup
|
||||
|
||||
```rust
|
||||
let val = client.kv_get(Some("autocomplete"), "accepted:000001719000000").await?;
|
||||
```
|
||||
|
||||
#### `kv_list_namespace()` — List all KV entries
|
||||
|
||||
Enumerate all key-value pairs in a namespace. Useful for trimming, history display, or bulk operations.
|
||||
|
||||
**Used by**: Autocomplete (trimming beyond 50 entries, settings UI, bulk clear).
|
||||
|
||||
#### `list_documents()` — List document metadata
|
||||
|
||||
Returns JSON with document metadata (not full content). Useful for delta analysis or trimming.
|
||||
|
||||
**Used by**: Subconscious (finding docs updated since last tick), Autocomplete (trimming beyond 200 docs).
|
||||
|
||||
#### `graph_query()` — Query knowledge graph
|
||||
|
||||
Find entity relationships. Filter by optional namespace, subject, and/or predicate.
|
||||
|
||||
```rust
|
||||
let relations = client.graph_query(None, None, None).await?;
|
||||
```
|
||||
|
||||
**Used by**: Subconscious (relations for situation report), Frontend (knowledge graph display).
|
||||
|
||||
---
|
||||
|
||||
### Deleting Data
|
||||
|
||||
| Function | When to use |
|
||||
|----------|-------------|
|
||||
| `delete_document(namespace, id)` | Remove a specific document |
|
||||
| `kv_delete(namespace, key)` | Remove a specific KV entry |
|
||||
| `clear_skill_memory(skill_id, integration_id)` | Disconnect / revoke: clears skill-scoped memory in the shared `skill-{skill_id}` namespace. Storage is not isolated per integration—multiple integrations share that namespace; `integration_id` identifies the integration in the API contract (see implementation in `MemoryClient::clear_skill_memory`) |
|
||||
| `clear_namespace(namespace)` | Wipe an arbitrary namespace |
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Fire-and-forget writes** — Spawn a background task to avoid blocking:
|
||||
|
||||
```rust
|
||||
let client = memory_client.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = client.put_doc(input).await {
|
||||
log::warn!("memory write failed: {e}");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Trim-after-write** — Cap growth after each insert (e.g., max 50 KV entries, max 200 docs). Use `kv_list_namespace()` or `list_documents()` then delete oldest.
|
||||
|
||||
**Init on app startup** — Frontend calls `syncMemoryClientToken()` in `CoreStateProvider.tsx` on mount and token refresh. Memory subsystem must be initialized before queries run.
|
||||
|
||||
---
|
||||
|
||||
## Namespace Convention
|
||||
|
||||
| Namespace | Owner | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `skill-{skill_id}` | Skills event loop | Raw state blobs + per-page docs (e.g., `skill-notion`, `skill-gmail`) |
|
||||
| `global` | Skills working_memory.rs | Extracted user facts (preferences, goals, entities) |
|
||||
| `conversations` | Agent/inference | Conversation context |
|
||||
| `autocomplete` | Autocomplete | Accepted completion records (KV) |
|
||||
| `autocomplete-memory` | Autocomplete | Searchable completion documents |
|
||||
| `vision` | Screen intelligence | Vision summaries from screen captures |
|
||||
|
||||
---
|
||||
|
||||
## MemoryClient API Reference
|
||||
|
||||
**File**: `src/openhuman/memory/store/client.rs`
|
||||
|
||||
### Documents
|
||||
|
||||
| Function | Line | Type | Description |
|
||||
|----------|------|------|-------------|
|
||||
| `put_doc(NamespaceDocumentInput) -> Result<String>` | 105 | WRITE | Stores document; background graph extraction |
|
||||
| `put_doc_light(NamespaceDocumentInput) -> Result<String>` | 125 | WRITE | Lightweight insert; no embedding or extraction |
|
||||
| `ingest_doc(MemoryIngestionRequest) -> Result<MemoryIngestionResult>` | 132 | WRITE | Full synchronous ingestion pipeline |
|
||||
| `store_skill_sync(...)` | 143 | WRITE | Skill-specific upsert into `skill-{skill_id}` namespace |
|
||||
| `list_documents(Option<&str>) -> Result<Value>` | 184 | READ | Lists documents, optionally by namespace |
|
||||
| `delete_document(&str, &str) -> Result<Value>` | 197 | DELETE | Deletes by namespace + document ID |
|
||||
|
||||
### Namespaces
|
||||
|
||||
| Function | Line | Type | Description |
|
||||
|----------|------|------|-------------|
|
||||
| `list_namespaces() -> Result<Vec<String>>` | 192 | READ | Lists all namespaces |
|
||||
| `clear_namespace(&str) -> Result<()>` | 206 | DELETE | Clears all data in a namespace |
|
||||
| `clear_skill_memory(&str, &str) -> Result<()>` | 211 | DELETE | Clears documents in the shared `skill-{skill_id}` namespace; second arg is the integration identifier passed from disconnect flows |
|
||||
|
||||
### Query / Recall
|
||||
|
||||
| Function | Line | Type | Description |
|
||||
|----------|------|------|-------------|
|
||||
| `query_namespace(&str, &str, u32) -> Result<String>` | 234 | READ | Semantic query; returns text |
|
||||
| `query_namespace_context_data(&str, &str, u32) -> Result<NamespaceRetrievalContext>` | 246 | READ | Semantic query; returns structured data |
|
||||
| `recall_namespace(&str, u32) -> Result<Option<String>>` | 258 | READ | Recent context; returns text |
|
||||
| `recall_namespace_context_data(&str, u32) -> Result<NamespaceRetrievalContext>` | 269 | READ | Recent context; returns structured data |
|
||||
| `recall_namespace_memories(&str, u32) -> Result<Vec<NamespaceMemoryHit>>` | 280 | READ | Recent context; returns individual hits |
|
||||
|
||||
### Key-Value
|
||||
|
||||
| Function | Line | Type | Description |
|
||||
|----------|------|------|-------------|
|
||||
| `kv_set(Option<&str>, &str, &Value) -> Result<()>` | 289 | WRITE | Sets KV pair (`None` namespace = global) |
|
||||
| `kv_get(Option<&str>, &str) -> Result<Option<Value>>` | 302 | READ | Gets KV value |
|
||||
| `kv_delete(Option<&str>, &str) -> Result<bool>` | 314 | DELETE | Deletes KV pair |
|
||||
| `kv_list_namespace(&str) -> Result<Vec<Value>>` | 322 | READ | Lists all KV pairs in namespace |
|
||||
|
||||
### Knowledge Graph
|
||||
|
||||
| Function | Line | Type | Description |
|
||||
|----------|------|------|-------------|
|
||||
| `graph_upsert(Option<&str>, &str, &str, &str, &Value) -> Result<()>` | 330 | WRITE | Upserts relation triple (`None` namespace = global) |
|
||||
| `graph_query(Option<&str>, Option<&str>, Option<&str>) -> Result<Vec<Value>>` | 356 | READ | Queries graph with optional filters |
|
||||
|
||||
---
|
||||
|
||||
## RPC Method Names
|
||||
|
||||
For callers going through JSON-RPC (frontend or external). Each maps 1:1 to a `MemoryClient` method above.
|
||||
|
||||
| RPC Method | Type | MemoryClient equivalent |
|
||||
|------------|------|------------------------|
|
||||
| `openhuman.memory_init` | INIT | — (initializes subsystem) |
|
||||
| `openhuman.memory_doc_put` | WRITE | `put_doc()` |
|
||||
| `openhuman.memory_doc_ingest` | WRITE | `ingest_doc()` |
|
||||
| `openhuman.memory_doc_list` | READ | `list_documents()` |
|
||||
| `openhuman.memory_doc_delete` | DELETE | `delete_document()` |
|
||||
| `openhuman.memory_namespace_list` | READ | `list_namespaces()` |
|
||||
| `openhuman.memory_list_namespaces` | READ | `list_namespaces()` (structured envelope) |
|
||||
| `openhuman.memory_list_documents` | READ | `list_documents()` (structured envelope) |
|
||||
| `openhuman.memory_delete_document` | DELETE | `delete_document()` (structured envelope) |
|
||||
| `openhuman.memory_clear_namespace` | DELETE | `clear_namespace()` |
|
||||
| `openhuman.memory_context_query` | READ | `query_namespace()` |
|
||||
| `openhuman.memory_context_recall` | READ | `recall_namespace()` |
|
||||
| `openhuman.memory_query_namespace` | READ | `query_namespace_context_data()` |
|
||||
| `openhuman.memory_recall_context` | READ | `recall_namespace_context_data()` |
|
||||
| `openhuman.memory_recall_memories` | READ | `recall_namespace_memories()` |
|
||||
| `openhuman.memory_kv_set` | WRITE | `kv_set()` |
|
||||
| `openhuman.memory_kv_get` | READ | `kv_get()` |
|
||||
| `openhuman.memory_kv_delete` | DELETE | `kv_delete()` |
|
||||
| `openhuman.memory_kv_list_namespace` | READ | `kv_list_namespace()` |
|
||||
| `openhuman.memory_graph_upsert` | WRITE | `graph_upsert()` |
|
||||
| `openhuman.memory_graph_query` | READ | `graph_query()` |
|
||||
| `openhuman.ai_list_memory_files` | READ | — (file I/O, not MemoryClient) |
|
||||
| `openhuman.ai_read_memory_file` | READ | — (file I/O) |
|
||||
| `openhuman.ai_write_memory_file` | WRITE | — (file I/O) |
|
||||
|
||||
---
|
||||
|
||||
## Frontend TypeScript Wrappers
|
||||
|
||||
**File**: `app/src/utils/tauriCommands/memory.ts`
|
||||
|
||||
Each calls the corresponding RPC method above via `core_rpc_relay`.
|
||||
|
||||
| Function | Line | RPC Method |
|
||||
|----------|------|------------|
|
||||
| `syncMemoryClientToken(token)` | 82 | `openhuman.memory_init` |
|
||||
| `memoryListDocuments(namespace?)` | 102 | `openhuman.memory_list_documents` |
|
||||
| `memoryListNamespaces()` | 117 | `openhuman.memory_list_namespaces` |
|
||||
| `memoryDeleteDocument(id, ns)` | 132 | `openhuman.memory_delete_document` |
|
||||
| `memoryClearNamespace(ns)` | 145 | `openhuman.memory_clear_namespace` |
|
||||
| `memoryQueryNamespace(ns, query, max?)` | 158 | `openhuman.memory_query_namespace` |
|
||||
| `memoryRecallNamespace(ns, max?)` | 173 | `openhuman.memory_recall_context` |
|
||||
| `memoryGraphQuery(ns?, subj?, pred?)` | 187 | `openhuman.memory_graph_query` |
|
||||
| `memoryDocIngest(params)` | 210 | `openhuman.memory_doc_ingest` |
|
||||
| `aiListMemoryFiles(dir?)` | 229 | `openhuman.ai_list_memory_files` |
|
||||
| `aiReadMemoryFile(path)` | 246 | `openhuman.ai_read_memory_file` |
|
||||
| `aiWriteMemoryFile(path, content)` | 261 | `openhuman.ai_write_memory_file` |
|
||||
|
||||
---
|
||||
|
||||
## Key Data Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `NamespaceDocumentInput` | Document upsert payload (namespace, content, metadata, tags, source_type, priority) |
|
||||
| `MemoryIngestionRequest` | Full ingestion request with config (chunking, embedding, extraction flags) |
|
||||
| `MemoryIngestionResult` | Ingestion result (document_id, chunk_count, entities, relations) |
|
||||
| `NamespaceRetrievalContext` | Structured retrieval (hits, entities, relations, chunks) |
|
||||
| `NamespaceMemoryHit` | Individual memory item with score breakdown |
|
||||
| `GraphRelationRecord` | Knowledge graph triple (subject, predicate, object, evidence, namespace) |
|
||||
| `RetrievalScoreBreakdown` | Score components: graph, vector, keyword, episodic, freshness |
|
||||
|
||||
All types defined in `src/openhuman/memory/store/types.rs`.
|
||||
@@ -69,6 +69,24 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads key/value pairs from a dotenv file into the process environment.
|
||||
///
|
||||
/// Precedence: variables already set in the environment are **not** overwritten.
|
||||
/// Order: `OPENHUMAN_DOTENV_PATH` (if set to a non-empty path), else `.env` in the current working directory.
|
||||
fn load_dotenv_for_server() -> Result<()> {
|
||||
match std::env::var("OPENHUMAN_DOTENV_PATH") {
|
||||
Ok(path) if !path.trim().is_empty() => {
|
||||
dotenvy::from_path(&path).map_err(|e| {
|
||||
anyhow::anyhow!("failed to load dotenv from OPENHUMAN_DOTENV_PATH={path}: {e}")
|
||||
})?;
|
||||
}
|
||||
_ => {
|
||||
let _ = dotenvy::dotenv();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles the `run` subcommand to start the core HTTP/JSON-RPC server.
|
||||
///
|
||||
/// Parses flags for port, host, and optional Socket.IO support.
|
||||
@@ -77,6 +95,8 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
|
||||
///
|
||||
/// * `args` - Command-line arguments for the `run` command.
|
||||
fn run_server_command(args: &[String]) -> Result<()> {
|
||||
load_dotenv_for_server()?;
|
||||
|
||||
let mut port: Option<u16> = None;
|
||||
let mut host: Option<String> = None;
|
||||
let mut socketio_enabled = true;
|
||||
|
||||
@@ -26,7 +26,7 @@ pub mod utils;
|
||||
pub mod working_memory;
|
||||
|
||||
pub use ops::*;
|
||||
pub use qjs_engine::{global_engine, require_engine, set_global_engine};
|
||||
pub use qjs_engine::{global_engine, replace_global_engine, require_engine, set_global_engine};
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_skills_controller_schemas,
|
||||
all_registered_controllers as all_skills_registered_controllers,
|
||||
|
||||
@@ -19,6 +19,11 @@ pub fn set_global_engine(engine: Arc<RuntimeEngine>) {
|
||||
*GLOBAL_ENGINE.write() = Some(engine);
|
||||
}
|
||||
|
||||
/// Swap the global engine and return the previous value (for isolated tests / harnesses).
|
||||
pub fn replace_global_engine(engine: Option<Arc<RuntimeEngine>>) -> Option<Arc<RuntimeEngine>> {
|
||||
std::mem::replace(&mut *GLOBAL_ENGINE.write(), engine)
|
||||
}
|
||||
|
||||
/// Get a clone of the global RuntimeEngine Arc.
|
||||
/// Returns `None` if the engine has not been initialized yet.
|
||||
pub fn global_engine() -> Option<Arc<RuntimeEngine>> {
|
||||
|
||||
+42
-22
@@ -699,6 +699,22 @@ globalThis.__platform = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Base URL for OAuth proxy and webhook API calls.
|
||||
* Uses BACKEND_URL, then VITE_BACKEND_URL, then production default.
|
||||
* Trailing slashes are stripped.
|
||||
*/
|
||||
globalThis.__resolveBackendBaseUrl = () => {
|
||||
let raw = (__platform.env('BACKEND_URL') || __platform.env('VITE_BACKEND_URL') || '').trim();
|
||||
if (!raw) {
|
||||
return 'https://api.tinyhumans.ai';
|
||||
}
|
||||
while (raw.length > 0 && raw.charAt(raw.length - 1) === '/') {
|
||||
raw = raw.slice(0, -1);
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
// High-level wrappers for skills (QuickJS bridge)
|
||||
globalThis.db = {
|
||||
exec: function (sql, params) {
|
||||
@@ -735,6 +751,13 @@ globalThis.platform = {
|
||||
env: function (key) {
|
||||
return __platform.env(key);
|
||||
},
|
||||
/**
|
||||
* Desktop-style notification hook. The QuickJS host has no system tray UI here;
|
||||
* avoid logging title/body (may contain PII); shim exists so sync flows do not throw "not a function".
|
||||
*/
|
||||
notify: function (_title, _body) {
|
||||
console.log('[platform.notify] notification requested');
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -847,18 +870,18 @@ globalThis.data = {
|
||||
body: JSON.stringify({ error: 'No OAuth credential. Complete OAuth setup first.' }),
|
||||
};
|
||||
}
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.tinyhumans.ai';
|
||||
var jwtToken = __ops.get_session_token() || '';
|
||||
var cleanPath = path.charAt(0) === '/' ? path.slice(1) : path;
|
||||
var credentialId = globalThis.__oauthCredential.credentialId;
|
||||
var clientKey = globalThis.__oauthClientKey || null;
|
||||
const backendUrl = globalThis.__resolveBackendBaseUrl();
|
||||
const jwtToken = __ops.get_session_token() || '';
|
||||
const cleanPath = path.charAt(0) === '/' ? path.slice(1) : path;
|
||||
const credentialId = globalThis.__oauthCredential.credentialId;
|
||||
const clientKey = globalThis.__oauthClientKey || null;
|
||||
|
||||
// Use encrypted proxy when client key share is available
|
||||
var proxyUrl;
|
||||
let proxyUrl;
|
||||
if (clientKey) {
|
||||
proxyUrl = backendUrl + '/proxy/encrypted/' + credentialId + '/' + cleanPath;
|
||||
proxyUrl = `${backendUrl}/proxy/encrypted/${credentialId}/${cleanPath}`;
|
||||
} else {
|
||||
proxyUrl = backendUrl + '/proxy/by-id/' + credentialId + '/' + cleanPath;
|
||||
proxyUrl = `${backendUrl}/proxy/by-id/${credentialId}/${cleanPath}`;
|
||||
}
|
||||
|
||||
var method = (options && options.method) || 'GET';
|
||||
@@ -906,16 +929,13 @@ globalThis.data = {
|
||||
revoke: function () {
|
||||
if (__oauthCredential) {
|
||||
try {
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.tinyhumans.ai';
|
||||
var jwtToken = __ops.get_session_token() || '';
|
||||
var revokeOpts = {
|
||||
const backendUrl = globalThis.__resolveBackendBaseUrl();
|
||||
const jwtToken = __ops.get_session_token() || '';
|
||||
const revokeOpts = {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwtToken },
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwtToken}` },
|
||||
};
|
||||
net.fetch(
|
||||
backendUrl + '/auth/integrations/' + __oauthCredential.credentialId,
|
||||
revokeOpts
|
||||
);
|
||||
net.fetch(`${backendUrl}/auth/integrations/${__oauthCredential.credentialId}`, revokeOpts);
|
||||
} catch (e) {
|
||||
/* best effort */
|
||||
}
|
||||
@@ -1142,10 +1162,10 @@ globalThis.webhook = {
|
||||
* @returns {Promise<{id: string, uuid: string, webhookUrl: string}>}
|
||||
*/
|
||||
createTunnel: async function (name, description) {
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.tinyhumans.ai';
|
||||
var jwtToken = __ops.get_session_token() || '';
|
||||
const backendUrl = globalThis.__resolveBackendBaseUrl();
|
||||
const jwtToken = __ops.get_session_token() || '';
|
||||
|
||||
var result = await net.fetch(backendUrl + '/webhooks/core', {
|
||||
var result = await net.fetch(`${backendUrl}/webhooks/core`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -1204,10 +1224,10 @@ globalThis.webhook = {
|
||||
}
|
||||
|
||||
// Delete from backend first
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.tinyhumans.ai';
|
||||
var jwtToken = __ops.get_session_token() || '';
|
||||
const backendUrl = globalThis.__resolveBackendBaseUrl();
|
||||
const jwtToken = __ops.get_session_token() || '';
|
||||
|
||||
var result = await net.fetch(backendUrl + '/webhooks/core/' + registration.backend_tunnel_id, {
|
||||
var result = await net.fetch(`${backendUrl}/webhooks/core/${registration.backend_tunnel_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer ' + jwtToken },
|
||||
timeout: 10000,
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Gmail OAuth proxy E2E over HTTP JSON-RPC.
|
||||
//!
|
||||
//! Verifies the full flow:
|
||||
//! 1. Start runtime + Gmail skill through HTTP JSON-RPC
|
||||
//! 2. Send `oauth/complete` with `credentialId` + `clientKeyShare`
|
||||
//! 3. Call `get-profile` via `openhuman.skills_call_tool`
|
||||
//! 4. Assert tool call succeeds against the staging backend.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use openhuman_core::core::jsonrpc::build_core_http_router;
|
||||
use openhuman_core::openhuman::memory::MemoryClient;
|
||||
use openhuman_core::openhuman::skills::qjs_engine::{replace_global_engine, RuntimeEngine};
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn try_find_skills_dir() -> Option<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("SKILL_DEBUG_DIR") {
|
||||
let p = PathBuf::from(&dir);
|
||||
return if p.exists() { Some(p) } else { None };
|
||||
}
|
||||
if let Ok(dir) = std::env::var("SKILLS_LOCAL_DIR") {
|
||||
let p = PathBuf::from(&dir);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let cwd = std::env::current_dir().expect("cwd");
|
||||
for candidate in &[
|
||||
cwd.join("../openhuman-skills/skills"),
|
||||
cwd.join("openhuman-skills/skills"),
|
||||
cwd.join("../alphahuman/skills/skills"),
|
||||
] {
|
||||
if candidate.exists() {
|
||||
return Some(candidate.canonicalize().unwrap());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
macro_rules! require_skills_dir {
|
||||
() => {
|
||||
match try_find_skills_dir() {
|
||||
Some(dir) => dir,
|
||||
None => {
|
||||
eprintln!("SKIPPED: no skills directory available (set SKILL_DEBUG_DIR)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fn serve_on_ephemeral(
|
||||
app: Router,
|
||||
) -> (
|
||||
SocketAddr,
|
||||
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
||||
) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
(addr, handle)
|
||||
}
|
||||
|
||||
async fn rpc_call(base: &str, id: i64, method: &str, params: Value) -> Value {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.expect("client");
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
let url = format!("{}/rpc", base.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("POST {url}: {e}"));
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"HTTP error {} for {}",
|
||||
resp.status(),
|
||||
method
|
||||
);
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("json for {method}: {e}"))
|
||||
}
|
||||
|
||||
fn assert_rpc_ok(resp: &Value, context: &str) -> Value {
|
||||
if let Some(err) = resp.get("error") {
|
||||
panic!("{context}: unexpected JSON-RPC error: {err}");
|
||||
}
|
||||
resp.get("result")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("{context}: missing result field: {resp}"))
|
||||
}
|
||||
|
||||
/// Restores prior process environment for keys we mutate in this test (runs on panic too).
|
||||
struct ProcessEnvGuard {
|
||||
home: Option<OsString>,
|
||||
backend_url: Option<OsString>,
|
||||
jwt_token: Option<OsString>,
|
||||
openhuman_workspace: Option<OsString>,
|
||||
vite_backend_url: Option<OsString>,
|
||||
}
|
||||
|
||||
impl ProcessEnvGuard {
|
||||
fn apply(
|
||||
home: &std::path::Path,
|
||||
workspace_dir: &std::path::Path,
|
||||
backend_url: &str,
|
||||
jwt: &str,
|
||||
) -> Self {
|
||||
let guard = Self {
|
||||
home: std::env::var_os("HOME"),
|
||||
backend_url: std::env::var_os("BACKEND_URL"),
|
||||
jwt_token: std::env::var_os("JWT_TOKEN"),
|
||||
openhuman_workspace: std::env::var_os("OPENHUMAN_WORKSPACE"),
|
||||
vite_backend_url: std::env::var_os("VITE_BACKEND_URL"),
|
||||
};
|
||||
unsafe {
|
||||
std::env::set_var("HOME", home.as_os_str());
|
||||
std::env::set_var("BACKEND_URL", backend_url);
|
||||
std::env::set_var("JWT_TOKEN", jwt);
|
||||
std::env::set_var("OPENHUMAN_WORKSPACE", workspace_dir.as_os_str());
|
||||
std::env::remove_var("VITE_BACKEND_URL");
|
||||
}
|
||||
guard
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match self.home.take() {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match self.backend_url.take() {
|
||||
Some(v) => std::env::set_var("BACKEND_URL", v),
|
||||
None => std::env::remove_var("BACKEND_URL"),
|
||||
}
|
||||
match self.jwt_token.take() {
|
||||
Some(v) => std::env::set_var("JWT_TOKEN", v),
|
||||
None => std::env::remove_var("JWT_TOKEN"),
|
||||
}
|
||||
match self.openhuman_workspace.take() {
|
||||
Some(v) => std::env::set_var("OPENHUMAN_WORKSPACE", v),
|
||||
None => std::env::remove_var("OPENHUMAN_WORKSPACE"),
|
||||
}
|
||||
match self.vite_backend_url.take() {
|
||||
Some(v) => std::env::set_var("VITE_BACKEND_URL", v),
|
||||
None => std::env::remove_var("VITE_BACKEND_URL"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobalEngineGuard {
|
||||
previous: Option<Arc<RuntimeEngine>>,
|
||||
}
|
||||
|
||||
impl GlobalEngineGuard {
|
||||
fn install(engine: Arc<RuntimeEngine>) -> Self {
|
||||
let previous = replace_global_engine(Some(engine));
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GlobalEngineGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = replace_global_engine(self.previous.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn gmail_tool_call_sends_encrypted_oauth_proxy_headers() {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
|
||||
let skills_dir = require_skills_dir!();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let data_dir = home.join("skills_data");
|
||||
let workspace_dir = home.join("workspace");
|
||||
std::fs::create_dir_all(&data_dir).expect("create data dir");
|
||||
std::fs::create_dir_all(&workspace_dir).expect("create workspace dir");
|
||||
let backend_url = "https://staging-api.alphahuman.xyz";
|
||||
|
||||
// Isolated HOME + env for JWT and backend routing used by oauth.fetch.
|
||||
let openhuman_dir = home.join(".openhuman");
|
||||
std::fs::create_dir_all(&openhuman_dir).expect("create .openhuman");
|
||||
std::fs::write(
|
||||
openhuman_dir.join("config.toml"),
|
||||
r#"api_url = "http://127.0.0.1:1"
|
||||
default_model = "test"
|
||||
[secrets]
|
||||
encrypt = false
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let test_jwt = match std::env::var("JWT_TOKEN") {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => {
|
||||
eprintln!("`SKIPPED`: set JWT_TOKEN to run staging OAuth proxy E2E");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let credential_id = match std::env::var("CREDENTIAL_ID") {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => {
|
||||
eprintln!("SKIPPED: set CREDENTIAL_ID to run staging OAuth proxy E2E");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client_key_share = match std::env::var("CLIENT_KEY_SHARE") {
|
||||
Ok(v) if !v.trim().is_empty() => v,
|
||||
_ => {
|
||||
eprintln!("SKIPPED: set CLIENT_KEY_SHARE to run staging OAuth proxy E2E");
|
||||
return;
|
||||
}
|
||||
};
|
||||
eprintln!("[gmail-oauth-proxy-e2e] JWT_TOKEN loaded");
|
||||
eprintln!("[gmail-oauth-proxy-e2e] CREDENTIAL_ID loaded");
|
||||
eprintln!("[gmail-oauth-proxy-e2e] CLIENT_KEY_SHARE loaded");
|
||||
|
||||
// Quick staging reachability check.
|
||||
let health = reqwest::Client::new()
|
||||
.get(format!("{backend_url}/settings"))
|
||||
.header("Authorization", format!("Bearer {test_jwt}"))
|
||||
.send()
|
||||
.await;
|
||||
match health {
|
||||
Ok(resp) => eprintln!(
|
||||
"[gmail-oauth-proxy-e2e] staging /settings -> {}",
|
||||
resp.status()
|
||||
),
|
||||
Err(err) => panic!("failed to reach staging backend {backend_url}: {err}"),
|
||||
}
|
||||
|
||||
// Isolated env + global engine so parallel tests do not leak state.
|
||||
let _env_guard = ProcessEnvGuard::apply(home, &workspace_dir, backend_url, &test_jwt);
|
||||
let engine = Arc::new(RuntimeEngine::new(data_dir).expect("engine"));
|
||||
engine.set_skills_source_dir(skills_dir);
|
||||
let _engine_guard = GlobalEngineGuard::install(engine.clone());
|
||||
|
||||
// Start HTTP JSON-RPC server.
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{}", rpc_addr);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// 1) Start skill
|
||||
let start = rpc_call(
|
||||
&rpc_base,
|
||||
1,
|
||||
"openhuman.skills_start",
|
||||
json!({ "skill_id": "gmail" }),
|
||||
)
|
||||
.await;
|
||||
let _ = assert_rpc_ok(&start, "skills_start");
|
||||
|
||||
// 2) Inject OAuth credential + client key share
|
||||
let oauth_complete = rpc_call(
|
||||
&rpc_base,
|
||||
2,
|
||||
"openhuman.skills_rpc",
|
||||
json!({
|
||||
"skill_id": "gmail",
|
||||
"method": "oauth/complete",
|
||||
"params": {
|
||||
"credentialId": credential_id,
|
||||
"provider": "gmail",
|
||||
"grantedScopes": ["https://www.googleapis.com/auth/gmail.readonly"],
|
||||
"clientKeyShare": client_key_share
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let _ = assert_rpc_ok(&oauth_complete, "skills_rpc oauth/complete");
|
||||
|
||||
// 3) Call get-profile tool via runtime JSON-RPC
|
||||
let call_tool = rpc_call(
|
||||
&rpc_base,
|
||||
3,
|
||||
"openhuman.skills_call_tool",
|
||||
json!({
|
||||
"skill_id": "gmail",
|
||||
"tool_name": "get-profile",
|
||||
"arguments": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
eprintln!(
|
||||
"[gmail-oauth-proxy-e2e] skills_call_tool completed (has_error={})",
|
||||
call_tool.get("error").is_some()
|
||||
);
|
||||
let tool_result = assert_rpc_ok(&call_tool, "skills_call_tool get-profile");
|
||||
eprintln!(
|
||||
"[gmail-oauth-proxy-e2e] get-profile tool result keys: {:?}",
|
||||
tool_result
|
||||
.as_object()
|
||||
.map(|m| m.keys().collect::<Vec<_>>())
|
||||
);
|
||||
assert_eq!(
|
||||
tool_result.get("is_error").and_then(|v| v.as_bool()),
|
||||
Some(false),
|
||||
"expected get-profile tool call to succeed"
|
||||
);
|
||||
|
||||
// 4) Validate tool payload shape from real staging response
|
||||
let content_text = tool_result
|
||||
.get("content")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|item| item.get("text"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
!content_text.is_empty(),
|
||||
"expected text payload in tool result content"
|
||||
);
|
||||
let parsed: Value = serde_json::from_str(content_text)
|
||||
.unwrap_or_else(|e| panic!("tool text payload should be JSON: {e}; got {content_text}"));
|
||||
assert_eq!(parsed.get("success").and_then(|v| v.as_bool()), Some(true));
|
||||
eprintln!("[gmail-oauth-proxy-e2e] get-profile JSON: success flag present");
|
||||
|
||||
// 5) Trigger sync via controller RPC (routes to skill/sync)
|
||||
let sync = rpc_call(
|
||||
&rpc_base,
|
||||
4,
|
||||
"openhuman.skills_sync",
|
||||
json!({ "skill_id": "gmail" }),
|
||||
)
|
||||
.await;
|
||||
eprintln!(
|
||||
"[gmail-oauth-proxy-e2e] skills_sync RPC completed (has_error={})",
|
||||
sync.get("error").is_some()
|
||||
);
|
||||
let sync_result = assert_rpc_ok(&sync, "openhuman.skills_sync");
|
||||
eprintln!(
|
||||
"[gmail-oauth-proxy-e2e] skills_sync result keys: {:?}",
|
||||
sync_result
|
||||
.as_object()
|
||||
.map(|m| m.keys().collect::<Vec<_>>())
|
||||
);
|
||||
|
||||
// 6) Verify memory persistence in skill-gmail namespace (async)
|
||||
let memory_client = MemoryClient::from_workspace_dir(workspace_dir.clone())
|
||||
.expect("MemoryClient::from_workspace_dir");
|
||||
let namespace = "skill-gmail";
|
||||
let mut docs_count = 0usize;
|
||||
let mut last_docs_payload = json!({});
|
||||
for _ in 0..10 {
|
||||
let docs = memory_client
|
||||
.list_documents(Some(namespace))
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("list_documents({namespace}) failed: {e}"));
|
||||
docs_count = docs
|
||||
.get("documents")
|
||||
.and_then(|d| d.as_array())
|
||||
.map(|arr| arr.len())
|
||||
.unwrap_or(0);
|
||||
last_docs_payload = docs;
|
||||
if docs_count > 0 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
assert!(
|
||||
docs_count > 0,
|
||||
"expected memory docs in namespace '{namespace}' after skills_sync; workspace={}, last_payload={}",
|
||||
workspace_dir.display(),
|
||||
serde_json::to_string(&last_docs_payload).unwrap_or_else(|_| last_docs_payload.to_string())
|
||||
);
|
||||
eprintln!(
|
||||
"[gmail-oauth-proxy-e2e] memory docs in {}: {}",
|
||||
namespace, docs_count
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
let _ = rpc_call(
|
||||
&rpc_base,
|
||||
5,
|
||||
"openhuman.skills_stop",
|
||||
json!({ "skill_id": "gmail" }),
|
||||
)
|
||||
.await;
|
||||
rpc_join.abort();
|
||||
}
|
||||
Reference in New Issue
Block a user