Improve local voice readiness and add Composio trigger history (#516)

* feat: enhance local AI asset state management and error handling

- Updated the Home component to improve readiness checks for local AI assets, ensuring a more robust UI experience.
- Refactored the LocalAiService to provide clearer state management for STT and TTS models, including handling on-demand downloads and error logging.
- Enhanced the voice status function to accurately reflect the availability of transcription backends, improving overall system reliability.
- Introduced warnings for on-demand model downloads to inform users about potential delays in functionality.

* feat: implement polling for voice server status in OverlayApp

- Added a polling mechanism to periodically check the voice server status every 2 seconds, ensuring the overlay remains in sync with the server state.
- Introduced logic to activate or dismiss the speech-to-text (STT) mode based on the server's recording or transcribing state, enhancing user experience and responsiveness.
- Utilized the existing `callCoreRpc` function to fetch server status, improving reliability in state management during brief disconnects or reconnections.

* feat: refine prompt handling in LocalAiService

- Updated the prompt construction logic in `LocalAiService` to enhance clarity and functionality.
- When `no_think` is set, the system prompt now includes a directive for the model to respond with only the final answer, improving response accuracy.
- Refactored the prompt and system parameters to ensure they are correctly formatted and passed to the `OllamaGenerateRequest`, enhancing overall request handling.

* feat: enhance STT readiness checks in VoicePanel and useVoiceSkillStatus

- Updated the STT readiness logic in `VoicePanel` to account for both 'ready' and 'ondemand' states, improving the accuracy of readiness assessments.
- Refined the `useVoiceSkillStatus` hook to ensure it only blocks when the local AI's STT state is explicitly 'missing', enhancing overall system reliability and user experience.

* feat(composio): implement ComposeIO trigger history component and hook

- Added `ComposeioTriggerHistory` component to display a list of ComposeIO trigger events with formatted timestamps and payloads.
- Introduced `useComposeioTriggerHistory` hook to manage fetching and state of trigger history entries, including error handling and loading states.
- Updated `Webhooks` page to integrate the new component and hook, replacing previous webhook activity display with ComposeIO trigger history.
- Created utility functions for formatting timestamps and payloads for better readability in the UI.
- Established backend support for fetching trigger history through new Tauri commands, ensuring robust data handling and storage.

* style: apply repo formatting fixes

* feat: add fs2 dependency and enhance ComposeIO trigger history handling

- Introduced the `fs2` crate to manage file locking, improving the reliability of file operations in the ComposeIO trigger history.
- Updated `ComposioTriggerHistoryStore` to utilize exclusive file locks during archive writing, ensuring data integrity.
- Enhanced error handling for file operations, providing clearer logging for failures related to file access and locking.
- Refactored the initialization logic for global trigger history to prevent duplicate setups, improving overall stability.
This commit is contained in:
Steven Enamakel
2026-04-12 16:43:33 -07:00
committed by GitHub
parent 0a09d72677
commit a115758c6c
22 changed files with 900 additions and 62 deletions
Generated
+11
View File
@@ -2184,6 +2184,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -4658,6 +4668,7 @@ dependencies = [
"env_logger",
"fantoccini",
"fastembed",
"fs2",
"futures",
"futures-util",
"hex",
+1
View File
@@ -90,6 +90,7 @@ hound = "3.5"
enigo = "0.3"
arboard = "3"
rdev = "0.5"
fs2 = "0.4"
matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] }
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
@@ -65,7 +65,16 @@ const VoicePanel = () => {
setSavedSettings(settingsResponse.result);
setServerStatus(serverResponse);
setVoiceStatus(voiceResponse);
setSttReady(assetsResponse.result.stt?.state === 'ready' && voiceResponse.stt_available);
const sttAssetState = assetsResponse.result.stt?.state;
const sttAssetOk = sttAssetState === 'ready' || sttAssetState === 'ondemand';
if (process.env.NODE_ENV !== 'production') {
console.debug('[VoicePanel:stt] readiness decision', {
sttAssetState,
sttAssetOk,
sttAvailable: voiceResponse.stt_available,
});
}
setSttReady(sttAssetOk && voiceResponse.stt_available);
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load voice settings';
@@ -0,0 +1,84 @@
import type { ComposioTriggerHistoryEntry } from '../../utils/tauriCommands';
interface ComposeioTriggerHistoryProps {
entries: ComposioTriggerHistoryEntry[];
}
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatPayload(payload: unknown): string {
try {
const formatted = JSON.stringify(payload, null, 2);
return formatted ?? String(payload);
} catch {
return String(payload);
}
}
export default function ComposeioTriggerHistory({ entries }: ComposeioTriggerHistoryProps) {
if (entries.length === 0) {
return (
<div className="space-y-3">
<h3 className="text-lg font-semibold text-stone-900">ComposeIO Trigger History</h3>
<p className="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-6 text-center text-sm text-stone-500">
No ComposeIO triggers have been captured yet.
</p>
</div>
);
}
return (
<div className="space-y-3">
<h3 className="text-lg font-semibold text-stone-900">
ComposeIO Trigger History{' '}
<span className="text-sm font-normal text-stone-400">({entries.length})</span>
</h3>
<div className="space-y-3">
{entries.map(entry => (
<article
key={`${entry.metadata_uuid}-${entry.received_at_ms}`}
className="rounded-2xl border border-stone-200 bg-stone-50/60 p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700">
{entry.toolkit}
</span>
<span className="rounded-full bg-sage-50 px-2.5 py-1 text-xs font-medium text-sage-700">
{entry.trigger}
</span>
<span className="text-xs text-stone-500">
{formatTimestamp(entry.received_at_ms)}
</span>
</div>
<dl className="mt-3 grid gap-2 text-sm text-stone-700 md:grid-cols-2">
<div>
<dt className="text-xs uppercase tracking-wide text-stone-400">Metadata ID</dt>
<dd className="font-mono text-xs break-all">{entry.metadata_id}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-stone-400">Metadata UUID</dt>
<dd className="font-mono text-xs break-all">{entry.metadata_uuid}</dd>
</div>
</dl>
<div className="mt-3">
<div className="mb-2 text-xs uppercase tracking-wide text-stone-400">Payload</div>
<pre className="max-h-64 overflow-auto rounded-xl bg-stone-900 px-3 py-3 text-xs text-stone-100">
{formatPayload(entry.payload)}
</pre>
</div>
</article>
))}
</div>
</div>
);
}
@@ -60,8 +60,12 @@ export function useVoiceSkillStatus(): VoiceSkillStatus {
const sttReady = useMemo(() => {
if (!voiceStatus) return false;
if (!voiceStatus.stt_available) return false;
// Also check Local AI asset state if available
if (localAi && localAi.stt_state !== 'ready') return false;
// The in-memory stt_state starts as "idle" and only flips to "ready"
// after the first download or transcription. The authoritative check
// is `voiceStatus.stt_available` (which inspects the filesystem and
// engine readiness). Only block when stt_state is explicitly an error
// state — "missing" means the model file really isn't on disk.
if (localAi && localAi.stt_state === 'missing') return false;
return true;
}, [voiceStatus, localAi]);
+111
View File
@@ -0,0 +1,111 @@
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCoreState } from '../providers/CoreStateProvider';
import {
type ComposioTriggerHistoryEntry,
openhumanComposioListTriggerHistory,
} from '../utils/tauriCommands';
const log = debug('composio:history');
const POLL_MS = 5000;
export interface ComposeioTriggerHistoryState {
archiveDir: string | null;
currentDayFile: string | null;
entries: ComposioTriggerHistoryEntry[];
loading: boolean;
error: string | null;
coreConnected: boolean;
refresh: () => Promise<void>;
}
export function useComposeioTriggerHistory(limit = 100): ComposeioTriggerHistoryState {
const { snapshot } = useCoreState();
const [archiveDir, setArchiveDir] = useState<string | null>(null);
const [currentDayFile, setCurrentDayFile] = useState<string | null>(null);
const [entries, setEntries] = useState<ComposioTriggerHistoryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [coreConnected, setCoreConnected] = useState(false);
const isRefreshingRef = useRef(false);
const sessionTokenRef = useRef(snapshot.sessionToken);
const clearHistory = useCallback(() => {
setArchiveDir(null);
setCurrentDayFile(null);
setEntries([]);
setLoading(false);
setError(null);
setCoreConnected(false);
}, []);
useEffect(() => {
sessionTokenRef.current = snapshot.sessionToken;
}, [snapshot.sessionToken]);
const refresh = useCallback(async () => {
if (isRefreshingRef.current) {
return;
}
if (!snapshot.sessionToken) {
clearHistory();
return;
}
const requestToken = snapshot.sessionToken;
isRefreshingRef.current = true;
setLoading(true);
try {
const response = await openhumanComposioListTriggerHistory(limit);
if (!sessionTokenRef.current || sessionTokenRef.current !== requestToken) {
return;
}
const result = response.result.result;
setArchiveDir(result.archive_dir);
setCurrentDayFile(result.current_day_file);
setEntries(result.entries);
setError(null);
setCoreConnected(true);
log('loaded %d composio trigger entries', result.entries.length);
} catch (refreshError) {
if (!sessionTokenRef.current || sessionTokenRef.current !== requestToken) {
return;
}
const message =
refreshError instanceof Error ? refreshError.message : 'Failed to load ComposeIO history';
setError(message);
setCoreConnected(false);
log('failed to load trigger history: %s', message);
} finally {
isRefreshingRef.current = false;
setLoading(false);
}
}, [clearHistory, limit, snapshot.sessionToken]);
useEffect(() => {
if (snapshot.sessionToken) {
return;
}
clearHistory();
}, [clearHistory, snapshot.sessionToken]);
useEffect(() => {
if (!snapshot.sessionToken) {
clearHistory();
return;
}
void refresh();
const timer = window.setInterval(() => {
void refresh();
}, POLL_MS);
return () => {
window.clearInterval(timer);
};
}, [clearHistory, refresh, snapshot.sessionToken]);
return { archiveDir, currentDayFile, entries, loading, error, coreConnected, refresh };
}
+71
View File
@@ -34,6 +34,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
import { callCoreRpc } from '../services/coreRpcClient';
import { CORE_RPC_URL } from '../utils/config';
const OVERLAY_IDLE_WIDTH = 50;
@@ -51,6 +52,7 @@ const DEFAULT_ATTENTION_TTL_MS = 6000;
const STT_RELEASE_LINGER_MS = 1500;
/** Placeholder bubble text while waiting for the first transcription. */
const STT_LISTENING_PLACEHOLDER = '"Listening…"';
let lastPollDebugTs = 0;
// ── State model ──────────────────────────────────────────────────────────
@@ -325,6 +327,75 @@ export default function OverlayApp() {
};
}, [clearDismissTimer, handleAttention, handleDictationToggle, handleDictationTranscription]);
// ── Poll voice server status as fallback sync ─────────────────────────
// Socket events are the primary state driver, but if an event is missed
// (reconnect, brief disconnect) the overlay can get stuck. Polling the
// actual server state every 2s corrects any drift.
const modeRef = useRef(mode);
useEffect(() => {
modeRef.current = mode;
}, [mode]);
useEffect(() => {
let disposed = false;
const poll = async () => {
try {
const res = await callCoreRpc<{
state: string;
hotkey: string;
activation_mode: string;
transcription_count: number;
last_error: string | null;
}>({ method: 'openhuman.voice_server_status' });
if (disposed) return;
const serverState = res.state; // 'stopped' | 'idle' | 'recording' | 'transcribing'
const currentMode = modeRef.current;
// Server is actively recording/transcribing but overlay is idle → show stt
if (
(serverState === 'recording' || serverState === 'transcribing') &&
currentMode !== 'stt'
) {
console.debug(
`[overlay] poll sync: server=${serverState}, overlay=${currentMode} → activating stt`
);
clearDismissTimer();
setMode('stt');
setBubble({
id: `stt-poll-${Date.now()}`,
text: serverState === 'transcribing' ? '"Transcribing…"' : STT_LISTENING_PLACEHOLDER,
tone: 'accent',
compact: true,
});
}
// Server is idle/stopped but overlay thinks it's in stt → dismiss
if ((serverState === 'idle' || serverState === 'stopped') && currentMode === 'stt') {
console.debug(`[overlay] poll sync: server=${serverState}, overlay=stt → dismissing`);
goIdle();
}
} catch (err) {
if (process.env.NODE_ENV !== 'production') {
const now = Date.now();
if (now - lastPollDebugTs > 5000) {
lastPollDebugTs = now;
console.debug('[overlay] RPC poll failed', err);
}
}
}
};
void poll();
const id = window.setInterval(() => void poll(), 2000);
return () => {
disposed = true;
window.clearInterval(id);
};
}, [clearDismissTimer, goIdle]);
// ── Window framing: resize / reposition on mode change ────────────────
const status: 'idle' | 'active' = mode === 'idle' ? 'idle' : 'active';
+2
View File
@@ -184,6 +184,7 @@ const Home = () => {
if (localAiStatus.state !== 'ready') return false;
const isDone = (state: string | undefined | null): boolean =>
state === 'ready' || state === 'disabled' || state === 'ondemand';
return (
isDone(localAiAssets.chat?.state) &&
isDone(localAiAssets.vision?.state) &&
@@ -192,6 +193,7 @@ const Home = () => {
isDone(localAiAssets.tts?.state)
);
}, [localAiStatus, localAiAssets]);
const isInstalling = localAiStatus?.state === 'installing';
const indeterminateDownload =
isInstalling ||
+40 -31
View File
@@ -1,28 +1,16 @@
import TunnelList from '../components/webhooks/TunnelList';
import WebhookActivity from '../components/webhooks/WebhookActivity';
import { useWebhooks } from '../hooks/useWebhooks';
import ComposeioTriggerHistory from '../components/webhooks/ComposeioTriggerHistory';
import { useComposeioTriggerHistory } from '../hooks/useComposeioTriggerHistory';
export default function Webhooks() {
const {
tunnels,
registrations,
activity,
loading,
error,
coreConnected,
createTunnel,
deleteTunnel,
refreshTunnels,
registerEcho,
unregisterEcho,
} = useWebhooks();
const { archiveDir, currentDayFile, entries, loading, error, coreConnected, refresh } =
useComposeioTriggerHistory(100);
if (loading && tunnels.length === 0) {
if (loading && entries.length === 0) {
return (
<div className="h-full flex items-center justify-center p-4 pt-6">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 border-t-primary-500" />
<span className="text-sm text-stone-500">Loading webhooks</span>
<span className="text-sm text-stone-500">Loading ComposeIO trigger history</span>
</div>
</div>
);
@@ -32,8 +20,8 @@ export default function Webhooks() {
<div className="h-full overflow-y-auto p-4 pt-6">
<div className="max-w-2xl mx-auto space-y-4">
{/* Connection status */}
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-stone-900">Webhooks</h2>
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-xl font-semibold text-stone-900">ComposeIO Triggers</h2>
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${
coreConnected ? 'bg-sage-100 text-sage-700' : 'bg-stone-100 text-stone-500'
@@ -45,25 +33,46 @@ export default function Webhooks() {
/>
{coreConnected ? 'Connected' : 'Disconnected'}
</span>
<button
type="button"
onClick={() => void refresh()}
className="rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 transition hover:border-stone-300 hover:bg-stone-50">
Refresh
</button>
</div>
{error && <div className="p-3 rounded-lg bg-coral-50 text-coral-700 text-sm">{error}</div>}
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6">
<TunnelList
tunnels={tunnels}
registrations={registrations}
loading={loading}
onCreateTunnel={createTunnel}
onDeleteTunnel={deleteTunnel}
onRefresh={refreshTunnels}
onRegisterEcho={registerEcho}
onUnregisterEcho={unregisterEcho}
/>
<div className="space-y-3">
<h3 className="text-lg font-semibold text-stone-900">Archive</h3>
<p className="text-sm text-stone-600">
Every ComposeIO trigger is appended to a daily JSONL file. Files are labeled by UTC
day.
</p>
<div className="space-y-2 rounded-2xl border border-stone-200 bg-stone-50 p-4">
<div>
<div className="text-xs uppercase tracking-wide text-stone-400">
Archive Directory
</div>
<div className="font-mono text-xs break-all text-stone-700">
{archiveDir ?? 'Not available yet'}
</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-stone-400">
Today&apos;s File
</div>
<div className="font-mono text-xs break-all text-stone-700">
{currentDayFile ?? 'Not available yet'}
</div>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6">
<WebhookActivity activity={activity} />
<ComposeioTriggerHistory entries={entries} />
</div>
</div>
</div>
+30
View File
@@ -0,0 +1,30 @@
import { callCoreRpc } from '../../services/coreRpcClient';
import { type CommandResponse, isTauri } from './common';
export interface ComposioTriggerHistoryEntry {
received_at_ms: number;
toolkit: string;
trigger: string;
metadata_id: string;
metadata_uuid: string;
payload: unknown;
}
export interface ComposioTriggerHistoryResult {
archive_dir: string;
current_day_file: string;
entries: ComposioTriggerHistoryEntry[];
}
export async function openhumanComposioListTriggerHistory(
limit = 100
): Promise<CommandResponse<{ result: ComposioTriggerHistoryResult }>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<{ result: ComposioTriggerHistoryResult }>>({
method: 'openhuman.composio_list_trigger_history',
params: { limit },
});
}
+1
View File
@@ -7,6 +7,7 @@ export * from './window';
export * from './core';
export * from './memory';
export * from './webhooks';
export * from './composio';
export * from './conscious';
export * from './subconscious';
export * from './localAi';
+6 -1
View File
@@ -826,8 +826,13 @@ fn register_domain_subscribers(workspace_dir: std::path::PathBuf) {
crate::openhuman::health::bus::register_health_subscriber();
crate::openhuman::memory::conversations::register_conversation_persistence_subscriber(
workspace_dir,
workspace_dir.clone(),
);
if let Err(error) = crate::openhuman::composio::init_composio_trigger_history(
workspace_dir.clone(),
) {
log::warn!("[composio][history] failed to initialize trigger archive: {error}");
}
crate::openhuman::composio::register_composio_trigger_subscriber();
crate::openhuman::composio::start_periodic_sync();
+2 -1
View File
@@ -576,7 +576,8 @@ const CAPABILITIES: &[Capability] = &[
name: "Debug Webhooks",
domain: "settings",
category: CapabilityCategory::Settings,
description: "Inspect registered webhook tunnels and captured request and response logs.",
description:
"Inspect Composio trigger history and find the daily JSONL archive files stored by the app.",
how_to: "Settings > Developer Options > Webhooks",
status: CapabilityStatus::Beta,
},
+45
View File
@@ -53,6 +53,7 @@ use async_trait::async_trait;
use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle};
use crate::openhuman::agent::triage::{apply_decision, run_triage, TriggerEnvelope};
use crate::openhuman::composio::trigger_history;
use crate::openhuman::config::rpc as config_rpc;
use super::client::ComposioClient;
@@ -165,6 +166,50 @@ impl EventHandler for ComposioTriggerSubscriber {
"[composio:bus] trigger received"
);
if let Some(store) = trigger_history::global() {
let toolkit_owned = toolkit.clone();
let trigger_owned = trigger.clone();
let metadata_id_owned = metadata_id.clone();
let metadata_uuid_owned = metadata_uuid.clone();
let payload_owned = payload.clone();
match tokio::task::spawn_blocking(move || {
store.record_trigger(
&toolkit_owned,
&trigger_owned,
&metadata_id_owned,
&metadata_uuid_owned,
&payload_owned,
)
})
.await
{
Ok(Ok(_)) => {}
Ok(Err(error)) => {
tracing::warn!(
toolkit = %toolkit,
trigger = %trigger,
error = %error,
"[composio][history] failed to archive trigger"
);
}
Err(error) => {
tracing::warn!(
toolkit = %toolkit,
trigger = %trigger,
error = %error,
"[composio][history] failed to join archive task"
);
}
}
} else {
tracing::debug!(
toolkit = %toolkit,
trigger = %trigger,
"[composio][history] archive store not initialized"
);
}
if triage_disabled() {
tracing::debug!(
toolkit = %toolkit,
+6 -1
View File
@@ -42,6 +42,7 @@ pub mod periodic;
pub mod providers;
pub mod schemas;
pub mod tools;
pub mod trigger_history;
pub mod types;
pub use bus::{register_composio_trigger_subscriber, ComposioTriggerSubscriber};
@@ -57,8 +58,12 @@ pub use schemas::{
all_registered_controllers as all_composio_registered_controllers,
};
pub use tools::all_composio_agent_tools;
pub use trigger_history::{
global as global_composio_trigger_history, init_global as init_composio_trigger_history,
};
pub use types::{
ComposioAuthorizeResponse, ComposioConnection, ComposioConnectionsResponse,
ComposioDeleteResponse, ComposioExecuteResponse, ComposioToolFunction, ComposioToolSchema,
ComposioToolkitsResponse, ComposioToolsResponse, ComposioTriggerEvent, ComposioTriggerMetadata,
ComposioToolkitsResponse, ComposioToolsResponse, ComposioTriggerEvent,
ComposioTriggerHistoryEntry, ComposioTriggerHistoryResult, ComposioTriggerMetadata,
};
+36
View File
@@ -28,6 +28,7 @@ use super::providers::{
use super::types::{
ComposioAuthorizeResponse, ComposioConnectionsResponse, ComposioDeleteResponse,
ComposioExecuteResponse, ComposioToolkitsResponse, ComposioToolsResponse,
ComposioTriggerHistoryResult,
};
/// Resolve a [`ComposioClient`] from the root config, or return an
@@ -194,6 +195,41 @@ pub async fn composio_execute(
}
}
// ── Trigger history ────────────────────────────────────────────────
pub async fn composio_list_trigger_history(
config: &Config,
limit: Option<usize>,
) -> OpResult<RpcOutcome<ComposioTriggerHistoryResult>> {
let requested_limit = limit.unwrap_or(100).clamp(1, 500);
let workspace_label = config
.workspace_dir
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("<workspace>");
tracing::debug!(
limit = requested_limit,
workspace = workspace_label,
"[composio] rpc list_trigger_history"
);
let store = super::trigger_history::global().ok_or_else(|| {
"[composio] trigger history unavailable: archive store is not initialized".to_string()
})?;
let history = store
.list_recent(requested_limit)
.map_err(|error| format!("[composio] list_trigger_history failed: {error}"))?;
let count = history.entries.len();
Ok(RpcOutcome::new(
history,
vec![format!(
"composio: {count} trigger history entrie(s) loaded (archive present)"
)],
))
}
// ── Provider-backed ops ─────────────────────────────────────────────
//
// `composio_get_user_profile` and `composio_sync` route through the
+37
View File
@@ -19,6 +19,11 @@ use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
#[derive(Debug, serde::Deserialize)]
struct TriggerHistoryParams {
limit: Option<usize>,
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("list_toolkits"),
@@ -29,6 +34,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("execute"),
schemas("get_user_profile"),
schemas("sync"),
schemas("list_trigger_history"),
]
}
@@ -66,6 +72,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("sync"),
handler: handle_sync,
},
RegisteredController {
schema: schemas("list_trigger_history"),
handler: handle_list_trigger_history,
},
]
}
@@ -230,6 +240,24 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"list_trigger_history" => ControllerSchema {
namespace: "composio",
function: "list_trigger_history",
description:
"List recent ComposeIO trigger events archived by the core and report the daily JSONL archive paths.",
inputs: vec![FieldSchema {
name: "limit",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Maximum number of archived trigger events to return.",
required: false,
}],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Trigger history payload: { archive_dir, current_day_file, entries }.",
required: true,
}],
},
_other => ControllerSchema {
namespace: "composio",
function: "unknown",
@@ -299,6 +327,15 @@ fn handle_execute(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_list_trigger_history(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload: TriggerHistoryParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
to_json(super::ops::composio_list_trigger_history(&config, payload.limit).await?)
})
}
fn handle_get_user_profile(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
+281
View File
@@ -0,0 +1,281 @@
//! Persistent ComposeIO trigger history.
//!
//! Stores every incoming ComposeIO trigger as a JSONL record partitioned by
//! UTC day under `<workspace>/state/triggers/YYYY-MM-DD.jsonl`.
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use chrono::Utc;
use fs2::FileExt;
use super::types::{ComposioTriggerHistoryEntry, ComposioTriggerHistoryResult};
static GLOBAL_TRIGGER_HISTORY: OnceLock<Arc<ComposioTriggerHistoryStore>> = OnceLock::new();
const TRIGGER_ARCHIVE_DIR: &str = "triggers";
pub fn init_global(workspace_dir: PathBuf) -> Result<(), String> {
let expected_archive_dir = workspace_dir.join("state").join(TRIGGER_ARCHIVE_DIR);
if let Some(existing) = GLOBAL_TRIGGER_HISTORY.get() {
if existing.archive_dir == expected_archive_dir {
return Ok(());
}
return Err(format!(
"[composio][history] global store already initialized for {} while attempting {}",
existing.archive_dir.display(),
expected_archive_dir.display()
));
}
let store = Arc::new(ComposioTriggerHistoryStore::new(&workspace_dir)?);
match GLOBAL_TRIGGER_HISTORY.set(store.clone()) {
Ok(()) => Ok(()),
Err(_) => {
if let Some(existing) = GLOBAL_TRIGGER_HISTORY.get() {
if existing.archive_dir == store.archive_dir {
return Ok(());
}
return Err(format!(
"[composio][history] global store already initialized for {} while attempting {}",
existing.archive_dir.display(),
store.archive_dir.display()
));
}
Err(format!(
"[composio][history] failed to initialize global store for {}",
store.archive_dir.display()
))
}
}
}
pub fn global() -> Option<Arc<ComposioTriggerHistoryStore>> {
GLOBAL_TRIGGER_HISTORY.get().cloned()
}
pub struct ComposioTriggerHistoryStore {
archive_dir: PathBuf,
}
impl ComposioTriggerHistoryStore {
pub fn new(workspace_dir: &Path) -> Result<Self, String> {
let archive_dir = workspace_dir.join("state").join(TRIGGER_ARCHIVE_DIR);
fs::create_dir_all(&archive_dir).map_err(|error| {
format!(
"[composio][history] failed to create archive directory {}: {error}",
archive_dir.display()
)
})?;
tracing::debug!(
archive_dir = %archive_dir.display(),
"[composio][history] archive initialized"
);
Ok(Self { archive_dir })
}
pub fn record_trigger(
&self,
toolkit: &str,
trigger: &str,
metadata_id: &str,
metadata_uuid: &str,
payload: &serde_json::Value,
) -> Result<ComposioTriggerHistoryEntry, String> {
let entry = ComposioTriggerHistoryEntry {
received_at_ms: now_ms(),
toolkit: toolkit.to_string(),
trigger: trigger.to_string(),
metadata_id: metadata_id.to_string(),
metadata_uuid: metadata_uuid.to_string(),
payload: payload.clone(),
};
let path = self.current_day_file_path();
let line = serde_json::to_string(&entry)
.map_err(|error| format!("[composio][history] failed to serialize trigger: {error}"))?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| {
format!(
"[composio][history] failed to open archive file {}: {error}",
path.display()
)
})?;
file.lock_exclusive().map_err(|error| {
format!(
"[composio][history] failed to lock archive file {}: {error}",
path.display()
)
})?;
let write_result = writeln!(file, "{line}")
.and_then(|_| file.flush())
.map_err(|error| {
format!(
"[composio][history] failed to append archive file {}: {error}",
path.display()
)
});
let unlock_result = file.unlock().map_err(|error| {
format!(
"[composio][history] failed to unlock archive file {}: {error}",
path.display()
)
});
write_result?;
unlock_result?;
tracing::debug!(
toolkit = %entry.toolkit,
trigger = %entry.trigger,
metadata_id = %entry.metadata_id,
archive_file = %path.display(),
"[composio][history] trigger archived"
);
Ok(entry)
}
pub fn list_recent(&self, limit: usize) -> Result<ComposioTriggerHistoryResult, String> {
let limit = limit.max(1);
let mut day_files = self.list_day_files()?;
day_files.sort_by(|left, right| right.cmp(left));
let mut entries = Vec::new();
for file in day_files {
let mut file_entries = self.read_day_file(&file)?;
file_entries.reverse();
for entry in file_entries {
entries.push(entry);
if entries.len() >= limit {
break;
}
}
if entries.len() >= limit {
break;
}
}
Ok(ComposioTriggerHistoryResult {
archive_dir: self.archive_dir.display().to_string(),
current_day_file: self.current_day_file_path().display().to_string(),
entries,
})
}
fn list_day_files(&self) -> Result<Vec<PathBuf>, String> {
let dir = fs::read_dir(&self.archive_dir).map_err(|error| {
format!(
"[composio][history] failed to read archive directory {}: {error}",
self.archive_dir.display()
)
})?;
Ok(dir
.filter_map(|entry| entry.ok().map(|value| value.path()))
.filter(|path| path.extension().is_some_and(|ext| ext == "jsonl"))
.collect())
}
fn read_day_file(&self, path: &Path) -> Result<Vec<ComposioTriggerHistoryEntry>, String> {
let file = OpenOptions::new().read(true).open(path).map_err(|error| {
format!(
"[composio][history] failed to open archive file {}: {error}",
path.display()
)
})?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(line) if !line.trim().is_empty() => line,
Ok(_) => continue,
Err(error) => {
tracing::warn!(
archive_file = %path.display(),
error = %error,
"[composio][history] failed to read line"
);
continue;
}
};
match serde_json::from_str::<ComposioTriggerHistoryEntry>(&line) {
Ok(entry) => entries.push(entry),
Err(error) => {
tracing::warn!(
archive_file = %path.display(),
error = %error,
"[composio][history] failed to parse archived trigger line"
);
}
}
}
Ok(entries)
}
fn current_day_file_path(&self) -> PathBuf {
self.archive_dir
.join(format!("{}.jsonl", Utc::now().format("%Y-%m-%d")))
}
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archives_triggers_in_daily_jsonl_and_lists_latest_first() {
let temp = tempfile::tempdir().expect("tempdir");
let workspace = temp.path().join("workspace");
fs::create_dir_all(&workspace).expect("workspace dir");
let store = ComposioTriggerHistoryStore::new(&workspace).expect("store");
store
.record_trigger(
"gmail",
"GMAIL_NEW_GMAIL_MESSAGE",
"id-1",
"uuid-1",
&serde_json::json!({ "subject": "hello" }),
)
.expect("record first");
store
.record_trigger(
"notion",
"NOTION_NEW_PAGE",
"id-2",
"uuid-2",
&serde_json::json!({ "title": "roadmap" }),
)
.expect("record second");
let history = store.list_recent(10).expect("list");
assert_eq!(history.entries.len(), 2);
assert_eq!(history.entries[0].metadata_id, "id-2");
assert_eq!(history.entries[1].metadata_id, "id-1");
assert!(PathBuf::from(&history.current_day_file).exists());
}
}
+26
View File
@@ -145,3 +145,29 @@ pub struct ComposioTriggerMetadata {
#[serde(default)]
pub uuid: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposioTriggerHistoryEntry {
/// Unix timestamp in milliseconds when the trigger reached the core.
pub received_at_ms: u64,
/// Toolkit slug, e.g. `"gmail"`.
pub toolkit: String,
/// Trigger slug, e.g. `"GMAIL_NEW_GMAIL_MESSAGE"`.
pub trigger: String,
/// Backend metadata id for this event.
pub metadata_id: String,
/// Backend metadata UUID for this event.
pub metadata_uuid: String,
/// Raw provider payload as forwarded by the backend socket event.
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposioTriggerHistoryResult {
/// Directory containing daily JSONL archives.
pub archive_dir: String,
/// Today's JSONL file path.
pub current_day_file: String,
/// Recent triggers, newest first.
pub entries: Vec<ComposioTriggerHistoryEntry>,
}
+59 -16
View File
@@ -4,6 +4,8 @@ use futures_util::TryStreamExt;
use crate::openhuman::config::Config;
use crate::openhuman::local_ai::model_ids;
use log::debug;
use crate::openhuman::local_ai::paths::{
resolve_stt_model_path, resolve_tts_voice_path, stt_model_target_path, tts_model_target_path,
};
@@ -25,8 +27,59 @@ impl LocalAiService {
let chat_ready = self.has_model(&chat_model).await.unwrap_or(false);
let vision_ready = self.has_model(&vision_model).await.unwrap_or(false);
let embedding_ready = self.has_model(&embedding_model).await.unwrap_or(false);
let stt_path = resolve_stt_model_path(config).ok();
let tts_path = resolve_tts_voice_path(config).ok();
let stt_resolve = resolve_stt_model_path(config);
let tts_resolve = resolve_tts_voice_path(config);
let stt_path = stt_resolve.as_ref().ok().cloned();
let tts_path = tts_resolve.as_ref().ok().cloned();
// STT and TTS are downloaded on-demand (first transcription / first
// synthesis). When the model file is not yet on disk but a download
// URL is configured, report "ondemand" instead of "missing" so the
// UI can treat the capability as non-blocking.
let has_stt_url = config
.local_ai
.stt_download_url
.as_deref()
.is_some_and(|v| !v.trim().is_empty());
let has_tts_url = config
.local_ai
.tts_download_url
.as_deref()
.is_some_and(|v| !v.trim().is_empty());
let stt_state = if stt_path.is_some() {
"ready"
} else if has_stt_url {
"ondemand"
} else {
"missing"
};
let tts_state = if tts_path.is_some() {
"ready"
} else if has_tts_url {
"ondemand"
} else {
"missing"
};
if let Err(ref err) = stt_resolve {
debug!("[local_ai::assets_status] STT resolve failed (state={stt_state}): {err}");
}
if let Err(ref err) = tts_resolve {
debug!("[local_ai::assets_status] TTS resolve failed (state={tts_state}): {err}");
}
let stt_warning = match stt_state {
"ondemand" => {
Some("STT model will download on first transcription request.".to_string())
}
_ => None,
};
let tts_warning = match tts_state {
"ondemand" => Some("TTS voice will download on first synthesis request.".to_string()),
_ => None,
};
let vision_mode = presets::vision_mode_for_config(&config.local_ai);
Ok(LocalAiAssetsStatus {
@@ -67,28 +120,18 @@ impl LocalAiService {
warning: None,
},
stt: LocalAiAssetStatus {
state: if stt_path.is_some() {
"ready"
} else {
"missing"
}
.to_string(),
state: stt_state.to_string(),
id: stt_model,
provider: "whisper.cpp".to_string(),
path: stt_path,
warning: None,
warning: stt_warning,
},
tts: LocalAiAssetStatus {
state: if tts_path.is_some() {
"ready"
} else {
"missing"
}
.to_string(),
state: tts_state.to_string(),
id: tts_voice,
provider: "piper".to_string(),
path: tts_path,
warning: None,
warning: tts_warning,
},
quantization: model_ids::effective_quantization(config),
})
+27 -8
View File
@@ -308,16 +308,35 @@ impl LocalAiService {
}
let started = std::time::Instant::now();
let mut combined_prompt = String::new();
if no_think {
combined_prompt.push_str("Respond with only the final answer. No reasoning.\\n\\n");
}
combined_prompt.push_str(prompt);
let model_id = model_ids::effective_chat_model_id(config);
// When `no_think` is set, append the instruction to the system
// prompt so the model treats it as a directive rather than content
// it might parrot back.
let effective_system = if no_think {
tracing::debug!(
no_think = true,
max_tokens = ?max_tokens,
allow_empty = allow_empty,
model = %model_id,
"[local_ai:infer] selected no_think prompt branch"
);
format!("{system}\n\nRespond with only the final answer. No reasoning, no preamble.")
} else {
tracing::debug!(
no_think = false,
max_tokens = ?max_tokens,
allow_empty = allow_empty,
model = %model_id,
"[local_ai:infer] selected standard prompt branch"
);
system.to_string()
};
let body = OllamaGenerateRequest {
model: model_ids::effective_chat_model_id(config),
prompt: combined_prompt,
system: Some(system.to_string()),
model: model_id,
prompt: prompt.to_string(),
system: Some(effective_system),
images: None,
stream: false,
options: Some(OllamaGenerateOptions {
+8 -1
View File
@@ -33,7 +33,14 @@ pub async fn voice_status(config: &Config) -> Result<RpcOutcome<VoiceStatus>, St
let service = local_ai::global(config);
let whisper_in_process = whisper_engine::is_loaded(&service.whisper);
let stt_available = whisper_in_process || (whisper_bin.is_some() && stt_model.is_some());
// STT is available when ANY transcription backend can work:
// 1. The in-process whisper engine is already loaded, OR
// 2. In-process whisper is enabled in config and the model file exists
// (the engine will load the model on first use), OR
// 3. The whisper-cli binary is installed and the model file exists.
let stt_available = whisper_in_process
|| (config.local_ai.whisper_in_process && stt_model.is_some())
|| (whisper_bin.is_some() && stt_model.is_some());
let tts_available = piper_bin.is_some() && tts_voice.is_some();
debug!(