/** * The single facade exposed by the algorithm core. * * Adapters call these methods (TypeScript adapters import the implementation * directly; non-TS adapters dispatch via JSON-RPC method names defined in * `jsonrpc.ts`). * * Implementation lives in `core/pipeline/memory-core.ts`. Tests mock this * interface; SDK consumers depend only on this file. */ import type { AgentKind, ApiLogDTO, EpochMs, EpisodeId, EpisodeListItemDTO, FeedbackDTO, PolicyDTO, RetrievalQueryDTO, RetrievalResultDTO, SessionId, SkillDTO, SkillId, SubagentOutcomeDTO, ToolOutcomeDTO, TraceDTO, TurnInputDTO, TurnResultDTO, WorldModelDTO, RuntimeNamespace, ShareScope, } from "./dto.js"; import type { CoreEvent } from "./events.js"; import type { LogRecord } from "./log-record.js"; // ─── Public lifecycle / status ──────────────────────────────────────────────── export interface CoreHealth { ok: boolean; version: string; uptimeMs: number; agent: AgentKind; namespace?: RuntimeNamespace; paths: { home: string; config: string; db: string; skills: string; logs: string; }; /** * Optional host transport status. Hermes fills this at the HTTP * server layer because the core itself does not own the Python ↔ Node * stdio bridge. */ bridge?: BridgeHealth; llm: ModelHealth; embedder: ModelHealth & { dim: number }; /** * Dedicated skill-crystallization model. When the operator leaves * `skillEvolver.model` blank, we surface the main LLM model with * `inherited: true` so the viewer can label it as "inherits from LLM". * The health fields mirror the main LLM client when `inherited=true`. */ skillEvolver: ModelHealth & { inherited: boolean }; } export type BridgeHealthStatus = | "connected" | "reconnecting" | "disconnected" | "unknown"; export interface BridgeHealth { status: BridgeHealthStatus; lastOkAt: number | null; lastErrorAt: number | null; lastError: string | null; } /** * Per-model connectivity summary used by the viewer's Overview page. * * The viewer renders the slot in one of four colours, picked by * comparing the timestamps below — most-recent event wins: * * - **green** (`ok`) : `lastOkAt` is the latest stamp → * primary provider answered directly. * - **yellow** (`fallback`) : `lastFallbackAt` is the latest stamp → * primary provider failed *but* the host LLM bridge rescued the * call. Only ever set on the LLM / skillEvolver slots; the * embedder has no fallback path so this stays `null`. * - **red** (`err`) : `lastError.at` is the latest stamp → * primary provider failed and either no fallback was configured * or the fallback also failed. The accompanying `message` is * surfaced verbatim on the card. * - **idle / off** : every timestamp is `null` → the * facade has not been called yet (idle) or no client is * configured at all (off). * * `lastError` is **sticky** — it is not cleared by a later success. * The viewer's timestamp comparison naturally promotes a fresh * success over a stale failure, while keeping the message available * in case a subsequent failure flips the card back to red. */ export interface ModelHealth { available: boolean; provider: string; model: string; lastOkAt: number | null; /** * Latest time the primary provider failed but the host LLM bridge * answered successfully. Always `null` on the embedder slot. */ lastFallbackAt: number | null; lastError: { at: number; message: string } | null; } // ─── Embedding maintenance ─────────────────────────────────────────────────── export type EmbeddingMaintenanceMode = "repair" | "rebuild"; export interface EmbeddingMaintenanceStats { dimension: number; available: boolean; totalSlots: number; ready: number; missing: number; dimMismatch: number; needsRepair: number; byKind: Record< "trace" | "policy" | "world_model" | "skill", { totalSlots: number; ready: number; missing: number; dimMismatch: number; needsRepair: number; } >; } export interface EmbeddingMaintenanceRunResult { mode: EmbeddingMaintenanceMode; processed: number; updated: number; failed: number; offset: number; nextOffset: number; done: boolean; statsBefore: EmbeddingMaintenanceStats; statsAfter: EmbeddingMaintenanceStats; error?: string; } // ─── Subscriptions ──────────────────────────────────────────────────────────── export type Unsubscribe = () => void; /** Non-serializable execution controls used only by in-process adapters. */ export interface MemorySearchExecutionOptions { /** Abort when the owning host turn/tool is cancelled. */ signal?: AbortSignal; /** Block admission of new background LLM work while this search is active. */ foreground?: boolean; } export interface MemoryCore { // ── lifecycle ── init(): Promise; shutdown(): Promise; health(): Promise; /** Late-bind ARMS telemetry (called after config is available). */ bindTelemetry?(t: unknown): void; /** * Resolve when the background recovery kicked off by `init()` settles. * * `init()` returns as soon as the synchronous orphan / dirty-episode * classification finishes; the actual reflect / reward / L2 recovery * chain runs on a background promise so the host's event loop stays * responsive (see issues #1776 + #1808). This method exposes that * promise for callers that need the historic "await everything" * semantics — primarily tests and one-shot batch tools. * * Implementations MUST never reject from this promise. Failures are * logged on the `init.background_recovery_failed` channel instead. * Adapters that have no startup recovery may omit the method; an * absent implementation is equivalent to `() => Promise.resolve()`. */ waitForStartupRecovery?(): Promise; // ── session / episode ── openSession(input: { agent: AgentKind; sessionId?: SessionId; meta?: Record; namespace?: RuntimeNamespace; }): Promise; closeSession(sessionId: SessionId): Promise; openEpisode(input: { sessionId: SessionId; episodeId?: EpisodeId; /** Optional initial user text (for adapters that know it). */ userMessage?: string; }): Promise; closeEpisode(episodeId: EpisodeId): Promise; // ── pipeline (per turn) ── /** Called *before* the agent acts. Returns the context to inject. */ onTurnStart(turn: TurnInputDTO): Promise; /** * Optional capability: resolve relation/intent routing and open the durable * episode without running retrieval. Hosts with an eventually-consistent * lifecycle can run this after the agent turn, off the prompt-time critical * path. */ prepareTurn?(turn: TurnInputDTO): Promise<{ sessionId: SessionId; episodeId: EpisodeId; }>; /** Called *after* the agent acts. Persists the trace, schedules induction, etc. */ onTurnEnd(result: TurnResultDTO): Promise<{ traceId: string; episodeId: EpisodeId }>; /** Called when the user gives task-level feedback (or implicit signals fire). */ submitFeedback(feedback: Omit & { ts?: number }): Promise; /** * Record a tool outcome. Feeds decision-repair so failure bursts can * trigger targeted re-injection on the *next* turn. Non-blocking: the * call returns before repair runs and never throws on unknown sessions. */ recordToolOutcome(outcome: ToolOutcomeDTO): void; /** * Record a parent-session delegation outcome. Subagent lifecycle hooks * usually carry task/result metadata, not a full child transcript, so this * appends the visible delegation task/result to the parent episode. */ recordSubagentOutcome( outcome: SubagentOutcomeDTO, ): Promise<{ traceId: string; episodeId: EpisodeId }>; // ── memory queries ── searchMemory( query: RetrievalQueryDTO, execution?: MemorySearchExecutionOptions, ): Promise; getTrace(id: string, namespace?: RuntimeNamespace): Promise; /** * Mutate a single trace's user-facing fields (role / summary / * body). Never touches algorithmic signals. Returns the updated * DTO (or null if the id is unknown). */ updateTrace( id: string, patch: { summary?: string | null; userText?: string; agentText?: string; tags?: readonly string[]; }, ): Promise; /** Delete a trace by id (idempotent). Hard delete. */ deleteTrace(id: string): Promise<{ deleted: boolean }>; /** * Bulk delete — takes an id list and returns how many rows were * actually removed. The viewer's "批量删除" uses this. */ deleteTraces(ids: readonly string[]): Promise<{ deleted: number }>; /** * Update the sharing state for a trace. `scope = null` clears the * share. The core never talks to the Hub itself; the adapter / * viewer are responsible for the network call, then call this to * persist the resulting state. */ shareTrace( id: string, share: { scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, ): Promise; getPolicy(id: string, namespace?: RuntimeNamespace, opts?: { includeAllNamespaces?: boolean }): Promise; getWorldModel(id: string, namespace?: RuntimeNamespace, opts?: { includeAllNamespaces?: boolean }): Promise; /** * List L2 policies ("经验") — newest-first. The viewer uses this * for the Experiences panel. */ listPolicies(input?: { status?: PolicyDTO["status"]; limit?: number; offset?: number; q?: string; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean; }): Promise; /** Total policy rows matching the same filter (no limit/offset). */ countPolicies(input?: { status?: PolicyDTO["status"]; q?: string; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean; }): Promise; /** * List L3 world models ("世界环境知识") — newest-first. */ listWorldModels(input?: { limit?: number; offset?: number; q?: string; namespace?: RuntimeNamespace; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean; }): Promise; /** Total world-model rows matching the same filter. */ countWorldModels(input?: { q?: string; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }): Promise; /** Transition a policy through candidate → active → archived. */ setPolicyStatus( id: string, status: PolicyDTO["status"], ): Promise; /** Hard-delete a policy row. */ deletePolicy(id: string): Promise<{ deleted: boolean }>; /** * Append decision guidance (preference / anti-pattern lines) to a * policy's `@repair` block. Used by the viewer's PolicyDrawer for * manual guidance entry — real agents land here via the feedback * pipeline (`core/feedback/feedback.ts::attachRepairToPolicies`). * * Duplicates are de-duped; lines that were already present are a * no-op. Returns the updated DTO or `null` if the policy id is * unknown. */ editPolicyGuidance( id: string, patch: { preference?: string[]; antiPattern?: string[] }, ): Promise; /** Hard-delete a world-model row. */ deleteWorldModel(id: string): Promise<{ deleted: boolean }>; /** * Apply a sharing transition to a policy. Same semantics as * {@link shareTrace}: pass `scope=null` to clear. */ sharePolicy( id: string, share: { scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, ): Promise; /** * Apply a sharing transition to a world model. Same semantics as * {@link shareTrace}. */ shareWorldModel( id: string, share: { scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, ): Promise; /** * User-driven content patch from the viewer's edit modal. Only * surfaces title / trigger / procedure / verification / boundary — * status, support, gain, vec stay owned by the induction pipeline. */ updatePolicy( id: string, patch: { title?: string; trigger?: string; procedure?: string; verification?: string; boundary?: string; }, ): Promise; /** * User-driven world-model patch. Mutates only `title`, `body`, or * `status` (active ↔ archived). */ updateWorldModel( id: string, patch: { title?: string; body?: string; status?: "active" | "archived" }, ): Promise; /** Soft-archive a world model so it can be un-archived later. */ archiveWorldModel(id: string): Promise; /** Reverse of {@link archiveWorldModel}. */ unarchiveWorldModel(id: string): Promise; listEpisodes(input: { sessionId?: SessionId; limit?: number; offset?: number; includeAllNamespaces?: boolean }): Promise; /** * Like `listEpisodes` but returns rich per-row metadata the viewer * needs to render its task list without a second round trip * (session id, status, turn count, first-turn preview). */ listEpisodeRows(input?: { sessionId?: SessionId; limit?: number; offset?: number; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean; }): Promise; /** Total episode rows matching the same filter (no limit/offset). */ countEpisodes(input?: { sessionId?: SessionId; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }): Promise; timeline(input: { episodeId: EpisodeId; namespace?: RuntimeNamespace; includeAllNamespaces?: boolean }): Promise; /** * Reverse-chronological trace listing for the Memories viewer. * * Unlike `searchMemory`, this is a pure timeline query — no embedder, * no ranking, no cold-start guards. The viewer uses it to show the * user's recent memory entries (summary + metadata) even when * retrieval would otherwise return zero results (e.g. brand-new * installs, query mismatch, offline embedder). * * @param input.limit default 50 * @param input.offset default 0 * @param input.sessionId optional filter * @param input.q optional case-insensitive substring filter * applied to `summary ?? userText` */ listTraces(input?: { limit?: number; offset?: number; sessionId?: SessionId; ownerAgentKind?: AgentKind; ownerProfileId?: string; q?: string; /** * Viewer/admin listing mode. Retrieval still respects namespace * visibility; the local viewer needs to browse every profile stored in * the same agent DB so users can switch between Hermes profiles / * OpenClaw agents. */ includeAllNamespaces?: boolean; /** * When true, paginate by distinct `(episodeId, turnId)` groups so * one user turn (query + tool sub-steps + reply) counts as one * memory. Returns all traces belonging to the paginated turns. */ groupByTurn?: boolean; }): Promise; /** Total trace rows matching the same filter (no limit/offset). */ countTraces(input?: { sessionId?: SessionId; ownerAgentKind?: AgentKind; ownerProfileId?: string; q?: string; groupByTurn?: boolean; includeAllNamespaces?: boolean }): Promise; /** * Paged listing of the rich api_logs table ({@link ApiLogDTO}). * Fuels the viewer's Logs page — shows every memos_search and * memory_add call with the full input/output JSON. */ listApiLogs(input?: { toolName?: string; toolNames?: readonly string[]; limit?: number; offset?: number; /** * When true, ignore the (turn-scoped) active namespace and return * rows from every namespace. Viewer callers set this so the Logs * page and metrics aggregations stay stable across profile flips * (#2131); matches the pattern already used by `listTraces` / * `listSkills` / `listPolicies`. * * Note: the write path currently does not stamp per-namespace * owner columns on `api_logs`, so this flag is a no-op today — * every listing is effectively cross-namespace. Keeping the * parameter formalises the contract now so a future per-namespace * write can be added without breaking viewer callers. */ includeAllNamespaces?: boolean; }): Promise<{ logs: ApiLogDTO[]; total: number }>; // ── skills ── listSkills(input?: { status?: SkillDTO["status"]; limit?: number; namespace?: RuntimeNamespace; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }): Promise; /** Total skill rows matching the same filter (no limit). */ countSkills(input?: { status?: SkillDTO["status"]; ownerAgentKind?: AgentKind; ownerProfileId?: string; includeAllNamespaces?: boolean }): Promise; getSkill(id: SkillId, opts?: { recordUse?: boolean; recordTrial?: boolean; sessionId?: SessionId; episodeId?: EpisodeId; traceId?: string; turnId?: EpochMs; toolCallId?: string; namespace?: RuntimeNamespace; includeAllNamespaces?: boolean; }): Promise; archiveSkill(id: SkillId, reason?: string): Promise; /** * Hard-delete a skill row. Distinct from {@link archiveSkill}, which * keeps the row but flips its status. Idempotent. */ deleteSkill(id: SkillId): Promise<{ deleted: boolean }>; /** * Re-activate a previously archived skill. Sets status back to * `'active'` and stamps `updatedAt = now`. */ reactivateSkill(id: SkillId): Promise; /** * User-driven skill patch from the viewer's edit modal. Only the * narrowly user-facing fields are mutable (`name`, `invocationGuide`). */ updateSkill( id: SkillId, patch: { name?: string; invocationGuide?: string }, ): Promise; /** Apply a sharing transition to a skill. */ shareSkill( id: SkillId, share: { scope: ShareScope | null; target?: string | null; sharedAt?: number | null; }, ): Promise; // ── config (for viewer settings tab) ── /** * Return the current resolved `config.yaml`, with sensitive fields * (api keys, tokens) masked as `"••••"`. Safe to hand to a browser. */ getConfig(): Promise>; /** * Apply a partial patch to `config.yaml` (deep merge, preserve * comments). Returns the new masked config. */ patchConfig(patch: Record): Promise>; // ── optional Hub runtime hooks (HTTP viewer uses these when present) ── hubAdminSnapshot?(): Promise; approveHubUser?(userId: string): Promise; rejectHubUser?(userId: string): Promise; removeHubUser?(userId: string): Promise; // ── analytics (viewer dashboard) ── /** * Aggregate counts for the viewer's Analytics tab. `days` controls * the window the `dailyWrites` histogram covers. */ metrics(input?: { days?: number; includeAllNamespaces?: boolean }): Promise<{ total: number; writesToday: number; sessions: number; embeddings: number; dailyWrites: Array<{ date: string; count: number }>; /** Skill counts + "tasks → skills" evolution rate. */ skillStats: { total: number; active: number; candidate: number; archived: number; /** episodes that directly minted a skill / total episodes */ evolutionRate: number; }; /** L2 policy counts + mean gain across active rows. */ policyStats: { total: number; active: number; candidate: number; archived: number; /** Arithmetic mean of `gain` across *active* policies. */ avgGain: number; /** Average quality score; we reuse `policy.gain` as a proxy. */ avgQuality: number; }; worldModelCount: number; decisionRepairCount: number; /** One bucket per day for the last `days`, newest-last. */ dailySkillEvolutions: Array<{ date: string; count: number }>; /** Most recent crystallisations; viewer uses this for the "最近进化事件" table. */ recentEvolutions: Array<{ ts: number; skillId: string; skillName: string; status: "candidate" | "active" | "archived"; sourcePolicyIds: string[]; }>; }>; // ── export / import (bundle) ── exportBundle(): Promise<{ version: 1; exportedAt: number; traces: TraceDTO[]; policies: PolicyDTO[]; worldModels: WorldModelDTO[]; skills: SkillDTO[]; }>; importBundle(bundle: { version?: number; traces?: unknown[]; policies?: unknown[]; worldModels?: unknown[]; skills?: unknown[]; }): Promise<{ imported: number; skipped: number }>; // ── embedding maintenance ── embeddingMaintenanceStats(): Promise; rebuildEmbeddings(input?: { mode?: EmbeddingMaintenanceMode; limit?: number; offset?: number; }): Promise; // ── observability ── /** Subscribe to every CoreEvent the algorithm emits. Returns unsubscribe. */ subscribeEvents(handler: (e: CoreEvent) => void): Unsubscribe; /** * Snapshot of the most-recent aggregated events (ring-buffered). The * SSE route replays these to clients on connect so the Overview * "实时活动" panel isn't empty on page reload. */ getRecentEvents(): readonly CoreEvent[]; /** Subscribe to every (post-redaction) LogRecord. Returns unsubscribe. */ subscribeLogs(handler: (r: LogRecord) => void): Unsubscribe; /** Allow non-TS adapters to forward their own log records into our sinks. */ forwardLog(record: LogRecord): void; }