memos/apps/memos-local-plugin/core/hub/runtime.ts

396 lines
12 KiB
TypeScript

import type {
PolicyDTO,
SkillDTO,
TraceDTO,
WorldModelDTO,
} from "../../agent-contract/dto.js";
import type { ResolvedConfig } from "../config/index.js";
import type { Logger } from "../logger/types.js";
import type { HubSharedMemorySearchHit } from "../storage/repos/hub.js";
import type { Repos } from "../storage/repos/index.js";
import type { EmbeddingVector } from "../types.js";
import { HubClientRuntime, type HubClientStatus } from "./client.js";
import { HubServerRuntime } from "./server.js";
export interface HubAdminPayload {
enabled: boolean;
role?: "hub" | "client";
status?: "disabled" | "starting" | "running" | "pending" | "connected" | "error";
error?: string;
url?: string;
pending?: Array<{
id: string;
name: string;
requestedAt: number;
groupName?: string;
}>;
users?: Array<{
id: string;
name: string;
groupName?: string;
connected: boolean;
role?: string;
status?: string;
memoryCount?: number;
skillCount?: number;
}>;
groups?: Array<{ id: string; name: string; memberCount: number }>;
}
export interface HubRuntime {
start(): Promise<void>;
stop(): Promise<void>;
adminSnapshot(): Promise<HubAdminPayload>;
approveUser(userId: string): Promise<{ ok: boolean; token?: string }>;
rejectUser(userId: string): Promise<{ ok: boolean }>;
removeUser(userId: string): Promise<{ ok: boolean }>;
publishTrace(trace: TraceDTO, embedding?: EmbeddingVector | null): Promise<string | null>;
unpublishTrace(traceId: string): Promise<void>;
publishPolicy(policy: PolicyDTO): Promise<string | null>;
unpublishPolicy(policyId: string): Promise<void>;
publishWorldModel(world: WorldModelDTO): Promise<string | null>;
unpublishWorldModel(worldModelId: string): Promise<void>;
publishSkill(skill: SkillDTO): Promise<string | null>;
unpublishSkill(skillId: string): Promise<void>;
searchMemories(query: string, limit?: number): Promise<HubMemorySearchHit[]>;
}
export type HubMemorySearchHit = HubSharedMemorySearchHit;
export function createHubRuntime(deps: {
repos: Repos;
config: ResolvedConfig;
log: Logger;
agent: string;
version: string;
}): HubRuntime {
return new DefaultHubRuntime(deps);
}
class DefaultHubRuntime implements HubRuntime {
private server: HubServerRuntime | null = null;
private client: HubClientRuntime | null = null;
private status: HubAdminPayload["status"] = "disabled";
private error: string | null = null;
constructor(
private readonly deps: {
repos: Repos;
config: ResolvedConfig;
log: Logger;
agent: string;
version: string;
},
) {}
async start(): Promise<void> {
if (!this.deps.config.hub.enabled) {
this.status = "disabled";
return;
}
this.status = "starting";
this.error = null;
try {
if (this.deps.config.hub.role === "hub") {
this.server = new HubServerRuntime({
repo: this.deps.repos.hub,
config: this.deps.config,
log: this.deps.log.child({ channel: "core.hub.server" }),
version: this.deps.version,
});
await this.server.start();
this.status = "running";
} else {
this.client = new HubClientRuntime({
repo: this.deps.repos.hub,
config: this.deps.config,
log: this.deps.log.child({ channel: "core.hub.client" }),
});
const conn = await this.client.start();
this.status = conn?.userToken ? "connected" : "pending";
}
} catch (err) {
this.status = "error";
this.error = err instanceof Error ? err.message : String(err);
this.deps.log.warn("hub.start.failed", { err: this.error });
}
}
async stop(): Promise<void> {
await this.client?.stop();
await this.server?.stop();
this.client = null;
this.server = null;
}
async adminSnapshot(): Promise<HubAdminPayload> {
const cfg = this.deps.config.hub;
if (!cfg.enabled) return { enabled: false, status: "disabled" };
if (cfg.role === "hub") {
const pending = this.deps.repos.hub.listUsers("pending").map((u) => ({
id: u.id,
name: u.username,
requestedAt: u.rejoinRequestedAt ?? u.createdAt,
}));
const contrib = this.deps.repos.hub.contributionsByUser();
const now = Date.now();
const users = this.deps.repos.hub
.listUsers()
.filter((u) => u.status === "active")
.map((u) => ({
id: u.id,
name: u.username,
connected: u.id === this.server?.ownerUserId || (!!u.lastActiveAt && now - u.lastActiveAt < 2 * 60_000),
role: u.role,
status: u.status,
memoryCount: contrib[u.id]?.memoryCount ?? 0,
skillCount: contrib[u.id]?.skillCount ?? 0,
}));
let url: string | undefined;
try {
url = this.server?.snapshot().url;
} catch {
url = undefined;
}
return {
enabled: true,
role: "hub",
status: this.status,
error: this.error ?? undefined,
url,
pending,
users,
groups: [],
};
}
const clientStatus = this.client
? await this.client.refreshStatus()
: clientStatusFromRepo(this.deps.repos);
const user = clientStatus.user;
const state = user?.status === "pending"
? "pending"
: clientStatus.connected
? "connected"
: "error";
this.status = state;
return {
enabled: true,
role: "client",
status: state,
error: clientStatus.error ?? this.error ?? undefined,
url: clientStatus.hubUrl,
pending: [],
users: user
? [{
id: String(user.id),
name: String(user.username || user.name || ""),
connected: clientStatus.connected,
role: String(user.role || "member"),
status: String(user.status || ""),
}]
: [],
groups: [],
};
}
async approveUser(userId: string): Promise<{ ok: boolean; token?: string }> {
const approved = this.server?.approveUser(userId);
return approved ? { ok: true, token: approved.token } : { ok: false };
}
async rejectUser(userId: string): Promise<{ ok: boolean }> {
return { ok: !!this.server?.rejectUser(userId) };
}
async removeUser(userId: string): Promise<{ ok: boolean }> {
return { ok: !!this.server?.removeUser(userId) };
}
async publishTrace(trace: TraceDTO, embedding?: EmbeddingVector | null): Promise<string | null> {
return this.publishMemory({
sourceTraceId: trace.id,
sourceAgent: String(trace.ownerAgentKind || this.deps.agent),
kind: "trace",
summary: trace.summary || summarize(trace.userText || trace.agentText),
content: joinBlocks([
trace.userText ? `User:\n${trace.userText}` : "",
trace.agentText ? `Agent:\n${trace.agentText}` : "",
]),
embedding,
});
}
async unpublishTrace(traceId: string): Promise<void> {
await this.unpublishMemory(traceId);
}
async publishPolicy(policy: PolicyDTO): Promise<string | null> {
return this.publishMemory({
sourceTraceId: policy.id,
sourceAgent: String(policy.ownerAgentKind || this.deps.agent),
kind: "policy",
summary: policy.title,
content: joinBlocks([
`Title:\n${policy.title}`,
`Trigger:\n${policy.trigger}`,
`Procedure:\n${policy.procedure}`,
`Verification:\n${policy.verification}`,
`Boundary:\n${policy.boundary}`,
]),
});
}
async unpublishPolicy(policyId: string): Promise<void> {
await this.unpublishMemory(policyId);
}
async publishWorldModel(world: WorldModelDTO): Promise<string | null> {
return this.publishMemory({
sourceTraceId: world.id,
sourceAgent: String(world.ownerAgentKind || this.deps.agent),
kind: "world_model",
summary: world.title,
content: joinBlocks([`Title:\n${world.title}`, world.body]),
});
}
async unpublishWorldModel(worldModelId: string): Promise<void> {
await this.unpublishMemory(worldModelId);
}
async publishSkill(skill: SkillDTO): Promise<string | null> {
const payload = {
metadata: {
id: skill.id,
name: skill.name,
invocationGuide: skill.invocationGuide,
version: skill.version,
qualityScore: skill.eta,
},
bundle: {
invocationGuide: skill.invocationGuide,
decisionGuidance: skill.decisionGuidance,
evidenceAnchors: skill.evidenceAnchors,
sourcePolicyIds: skill.sourcePolicyIds,
sourceWorldModelIds: skill.sourceWorldModelIds,
},
};
if (this.server) {
return this.server.publishSkillAsOwner({
sourceSkillId: skill.id,
name: skill.name,
invocationGuide: skill.invocationGuide,
version: skill.version,
qualityScore: skill.eta,
bundle: payload.bundle,
}).id;
}
const result = await this.client?.requestJson<{ skillId?: string }>("/api/v1/hub/skills/publish", {
method: "POST",
body: JSON.stringify(payload),
});
return result?.skillId ?? null;
}
async unpublishSkill(skillId: string): Promise<void> {
if (this.server) {
this.server.unpublishSkillAsOwner(skillId);
return;
}
await this.client?.requestJson("/api/v1/hub/skills/unpublish", {
method: "POST",
body: JSON.stringify({ sourceSkillId: skillId }),
});
}
async searchMemories(
query: string,
limit = 5,
): Promise<HubMemorySearchHit[]> {
if (!this.deps.config.hub.enabled) return [];
if (this.server) {
return this.server.searchMemories(query, limit);
}
const result = await this.client?.requestJson<{ memories?: HubMemorySearchHit[] }>(
"/api/v1/hub/memories/search",
{
method: "POST",
body: JSON.stringify({
query,
limit,
}),
},
);
return result?.memories ?? [];
}
private async publishMemory(input: {
sourceTraceId: string;
sourceAgent: string;
kind: string;
summary: string;
content: string;
embedding?: EmbeddingVector | null;
}): Promise<string | null> {
if (this.server) {
return this.server.publishMemoryAsOwner({
...input,
summary: truncate(input.summary, 500),
content: truncate(input.content, 20_000),
}).id;
}
const result = await this.client?.requestJson<{ memoryId?: string }>("/api/v1/hub/memories/share", {
method: "POST",
body: JSON.stringify({
memory: {
...input,
sourceChunkId: input.sourceTraceId,
summary: truncate(input.summary, 500),
content: truncate(input.content, 20_000),
embedding: input.embedding ? Array.from(input.embedding) : undefined,
},
}),
});
return result?.memoryId ?? null;
}
private async unpublishMemory(sourceTraceId: string): Promise<void> {
if (this.server) {
this.server.unpublishMemoryAsOwner(sourceTraceId);
return;
}
await this.client?.requestJson("/api/v1/hub/memories/unshare", {
method: "POST",
body: JSON.stringify({ sourceTraceId }),
});
}
}
function clientStatusFromRepo(repos: Repos): HubClientStatus {
const conn = repos.hub.getClientConnection();
if (!conn) return { connected: false, user: null };
return {
connected: !!conn.userToken && conn.lastKnownStatus === "active",
hubUrl: conn.hubUrl,
user: {
id: conn.userId,
username: conn.username,
role: conn.role,
status: conn.lastKnownStatus,
},
};
}
function joinBlocks(blocks: string[]): string {
return blocks.filter(Boolean).join("\n\n");
}
function summarize(text: string): string {
return truncate(text.replace(/\s+/g, " ").trim(), 160);
}
function truncate(text: string, max: number): string {
return text.length > max ? `${text.slice(0, max - 1)}...` : text;
}