diff --git a/.gitignore b/.gitignore index 8ea8a72..9ca187a 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,5 @@ desktop.ini # ============================================ .claude/ .playwright-mcp/ +TODO.md +PLAN.md \ No newline at end of file diff --git a/deno.json b/deno.json index fd47be1..d0e2ab0 100644 --- a/deno.json +++ b/deno.json @@ -3,11 +3,12 @@ "./packages/psycheros", "./packages/entity-core", "./packages/entity-loom", - "./packages/launcher" + "./packages/launcher", + "./packages/plugin-api" ], "nodeModulesDir": "auto", "tasks": { - "test": "deno test -A packages/psycheros/tests/ packages/entity-core/tests/", + "test": "deno test -A packages/psycheros/tests/ packages/entity-core/tests/ packages/plugin-api/tests/", "fmt": "deno fmt", "fmt:check": "deno fmt --check" }, diff --git a/packages/entity-core/CLAUDE.md b/packages/entity-core/CLAUDE.md index 9c24140..8e164cb 100644 --- a/packages/entity-core/CLAUDE.md +++ b/packages/entity-core/CLAUDE.md @@ -36,6 +36,15 @@ full schemas are in [`docs/mcp-tools.md`](docs/mcp-tools.md). 2. Register it in `src/tools/mod.ts`. 3. Write the description in first person ("I use this to…"). +## Trusted local plugins + +Entity-core loads optional plugin entrypoints from `PSYCHEROS_PLUGIN_DIR` before +MCP connects. The manager in `src/plugins/` registers additional MCP tools and +additive result decorators. Decorators run after core handlers and cannot +overwrite core fields. My plugin-owned credentials live beside the shared plugin +directory under `.psycheros/plugin-secrets/.env`. I apply them before import +and keep them out of portable entity exports. + ## Storage layout All persistent state lives in `data/`: diff --git a/packages/entity-core/src/plugins/mod.ts b/packages/entity-core/src/plugins/mod.ts new file mode 100644 index 0000000..8ca8ad2 --- /dev/null +++ b/packages/entity-core/src/plugins/mod.ts @@ -0,0 +1,4 @@ +export { + createEntityCorePluginManager, + EntityCorePluginManager, +} from "./plugin-manager.ts"; diff --git a/packages/entity-core/src/plugins/plugin-manager.ts b/packages/entity-core/src/plugins/plugin-manager.ts new file mode 100644 index 0000000..c13fb5f --- /dev/null +++ b/packages/entity-core/src/plugins/plugin-manager.ts @@ -0,0 +1,276 @@ +/** + * Trusted local plugin harness for my canonical core. + */ + +import { join, toFileUrl } from "@std/path"; +import { + type AppliedPluginEnv, + applyPluginEnv, + emptyPluginCapabilityCounts, + type PluginEnv, + type PluginManifest, + type PluginStatus, + validatePluginManifest, + validatePluginRelativePath, +} from "../../../plugin-api/src/mod.ts"; +import type { FileStore } from "../storage/mod.ts"; +import type { GraphStore } from "../graph/mod.ts"; +import type { EmbeddingCache } from "../embeddings/mod.ts"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { z } from "zod"; + +export interface EntityCorePluginServices { + dataDir: string; + statePath: string; + env: PluginEnv; + store: FileStore; + graphStore: GraphStore; + embeddingCache: EmbeddingCache; + log: (...args: unknown[]) => void; +} + +export interface EntityCorePluginTool { + name: string; + description: string; + schema: Record; + handler: ( + args: Record, + services: EntityCorePluginServices, + ) => unknown | Promise; +} + +export interface EntityCoreResultDecorator { + tool: string; + name: string; + priority?: number; + decorate: ( + result: Record, + services: EntityCorePluginServices, + ) => Record | Promise>; +} + +interface EntityCorePluginModule { + tools?: EntityCorePluginTool[]; + resultDecorators?: EntityCoreResultDecorator[]; + start?: (services: EntityCorePluginServices) => void | Promise; + stop?: (services: EntityCorePluginServices) => void | Promise; +} + +interface LoadedPlugin { + directory: string; + manifest: PluginManifest; + module?: EntityCorePluginModule; + appliedEnv?: AppliedPluginEnv; + status: PluginStatus; +} + +function safeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export class EntityCorePluginManager { + private plugins: LoadedPlugin[] = []; + + constructor( + private pluginRoot: string, + private baseServices: Omit, + ) {} + + private services(plugin: LoadedPlugin): EntityCorePluginServices { + return { + ...this.baseServices, + statePath: join(plugin.directory, "state"), + env: plugin.appliedEnv?.env ?? { + get: (name) => Deno.env.get(name), + has: (name) => Deno.env.has(name), + require(name) { + const value = Deno.env.get(name); + if (!value) { + throw new Error(`missing required plugin environment: ${name}`); + } + return value; + }, + }, + }; + } + + async load(): Promise { + await this.stop(); + this.plugins = []; + let entries: Deno.DirEntry[]; + try { + entries = Array.from(Deno.readDirSync(this.pluginRoot)); + } catch (error) { + if (error instanceof Deno.errors.NotFound) return; + throw error; + } + for (const entry of entries.filter((item) => item.isDirectory)) { + const directory = join(this.pluginRoot, entry.name); + let appliedEnv: AppliedPluginEnv | undefined; + try { + const manifest = validatePluginManifest( + JSON.parse(await Deno.readTextFile(join(directory, "plugin.json"))), + entry.name, + ); + const status: PluginStatus = { + id: manifest.id, + name: manifest.name, + version: manifest.version, + enabled: manifest.enabled, + active: false, + degraded: false, + restartRequired: false, + entrypoints: { + psycheros: !!manifest.entrypoints?.psycheros, + entityCore: !!manifest.entrypoints?.entityCore, + }, + capabilities: emptyPluginCapabilityCounts(), + }; + const loaded: LoadedPlugin = { directory, manifest, status }; + this.plugins.push(loaded); + if (!manifest.enabled || !manifest.entrypoints?.entityCore) continue; + appliedEnv = await applyPluginEnv(this.pluginRoot, manifest.id); + loaded.appliedEnv = appliedEnv; + const entrypoint = validatePluginRelativePath( + manifest.entrypoints.entityCore, + ); + const imported = await import( + toFileUrl(join(directory, entrypoint)).href + ); + loaded.module = + (imported.default ?? imported) as EntityCorePluginModule; + status.capabilities.tools = loaded.module.tools?.length ?? 0; + status.capabilities.resultDecorators = + loaded.module.resultDecorators?.length ?? 0; + status.active = true; + await loaded.module.start?.(this.services(loaded)); + } catch (error) { + appliedEnv?.restore(); + console.error(`[Plugins] Failed to load ${entry.name}:`, error); + this.plugins = this.plugins.filter((plugin) => + plugin.manifest.id !== entry.name + ); + this.plugins.push({ + directory, + manifest: { + id: entry.name, + name: entry.name, + version: "unknown", + apiVersion: 1, + enabled: false, + }, + status: { + id: entry.name, + name: entry.name, + version: "unknown", + enabled: false, + active: false, + degraded: true, + restartRequired: false, + entrypoints: { psycheros: false, entityCore: false }, + capabilities: emptyPluginCapabilityCounts(), + lastError: safeError(error), + }, + }); + } + } + this.plugins.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id)); + } + + async stop(): Promise { + for (const plugin of [...this.plugins].reverse()) { + try { + await plugin.module?.stop?.(this.services(plugin)); + } catch (error) { + console.error(`[Plugins] Failed to stop ${plugin.manifest.id}:`, error); + } finally { + plugin.appliedEnv?.restore(); + plugin.appliedEnv = undefined; + } + } + } + + getStatuses(): PluginStatus[] { + return this.plugins.map((plugin) => ({ ...plugin.status })); + } + + getTools(): Array<{ + plugin: LoadedPlugin; + tool: EntityCorePluginTool; + }> { + return this.plugins.flatMap((plugin) => + (plugin.module?.tools ?? []).map((tool) => ({ plugin, tool })) + ); + } + + async decorate( + toolName: string, + input: unknown, + ): Promise> { + const result: Record = + input && typeof input === "object" && !Array.isArray(input) + ? { ...input as Record } + : { result: input }; + const failures: Array<{ plugin: string; decorator: string }> = []; + const decorators = this.plugins.flatMap((plugin) => + (plugin.module?.resultDecorators ?? []) + .filter((decorator) => decorator.tool === toolName) + .map((decorator) => ({ plugin, decorator })) + ).sort((a, b) => + (a.decorator.priority ?? 0) - (b.decorator.priority ?? 0) || + a.plugin.manifest.id.localeCompare(b.plugin.manifest.id) || + a.decorator.name.localeCompare(b.decorator.name) + ); + + for (const { plugin, decorator } of decorators) { + try { + const addition = await decorator.decorate( + { ...result }, + this.services(plugin), + ); + for (const [key, value] of Object.entries(addition)) { + if (key in result) throw new Error(`field collision: ${key}`); + result[key] = value; + } + } catch (error) { + plugin.status.degraded = true; + plugin.status.lastError = safeError(error); + failures.push({ + plugin: plugin.manifest.id, + decorator: decorator.name, + }); + console.error( + `[Plugins] Decorator ${plugin.manifest.id}/${decorator.name} failed:`, + error, + ); + } + } + if (failures.length > 0) result.plugin_failures = failures; + return result; + } + + registerTools(server: McpServer): void { + for (const { plugin, tool } of this.getTools()) { + server.tool(tool.name, tool.description, tool.schema, async (args) => ({ + content: [{ + type: "text" as const, + text: JSON.stringify( + await this.decorate( + tool.name, + await tool.handler(args, this.services(plugin)), + ), + null, + 2, + ), + }], + })); + } + } +} + +export function createEntityCorePluginManager( + pluginRoot: string, + services: Omit, +): EntityCorePluginManager { + return new EntityCorePluginManager(pluginRoot, services); +} diff --git a/packages/entity-core/src/server.ts b/packages/entity-core/src/server.ts index 0985800..a842a07 100644 --- a/packages/entity-core/src/server.ts +++ b/packages/entity-core/src/server.ts @@ -66,6 +66,10 @@ import type { ServerConfig } from "./types.ts"; import { DEFAULT_SERVER_CONFIG } from "./types.ts"; import { cleanupOldSnapshots } from "./snapshot/mod.ts"; import { EmbeddingCache } from "./embeddings/mod.ts"; +import { + createEntityCorePluginManager, + type EntityCorePluginManager, +} from "./plugins/mod.ts"; /** * Create and configure the MCP server. @@ -83,6 +87,7 @@ export function createServer(config: Partial = {}): McpServer { // Initialize embedding cache (shares graph.db for sqlite-vec) const embeddingCache = new EmbeddingCache(fullConfig.dataDir); + const pluginManager = fullConfig.pluginManager; // Create MCP server const server = new McpServer({ @@ -103,7 +108,13 @@ export function createServer(config: Partial = {}): McpServer { content: [ { type: "text" as const, - text: JSON.stringify(result, null, 2), + text: JSON.stringify( + pluginManager + ? await pluginManager.decorate("identity_get_all", result) + : result, + null, + 2, + ), }, ], }; @@ -127,7 +138,13 @@ export function createServer(config: Partial = {}): McpServer { content: [ { type: "text" as const, - text: JSON.stringify(result, null, 2), + text: JSON.stringify( + pluginManager + ? await pluginManager.decorate("identity_write", result) + : result, + null, + 2, + ), }, ], }; @@ -151,7 +168,13 @@ export function createServer(config: Partial = {}): McpServer { content: [ { type: "text" as const, - text: JSON.stringify(result, null, 2), + text: JSON.stringify( + pluginManager + ? await pluginManager.decorate("identity_append", result) + : result, + null, + 2, + ), }, ], }; @@ -441,7 +464,13 @@ export function createServer(config: Partial = {}): McpServer { content: [ { type: "text" as const, - text: JSON.stringify(result, null, 2), + text: JSON.stringify( + pluginManager + ? await pluginManager.decorate("memory_search", result) + : result, + null, + 2, + ), }, ], }; @@ -1330,6 +1359,20 @@ export function createServer(config: Partial = {}): McpServer { }, ); + server.tool( + "plugin_status", + "I use this to inspect the trusted local extensions available to my core.", + {}, + () => ({ + content: [{ + type: "text" as const, + text: JSON.stringify(pluginManager?.getStatuses() ?? [], null, 2), + }], + }), + ); + + pluginManager?.registerTools(server); + return server; } @@ -1339,15 +1382,39 @@ export function createServer(config: Partial = {}): McpServer { export async function startServer( config: Partial = {}, ): Promise { - const server = createServer(config); - const transport = new StdioServerTransport(); - // Auto-rebuild embeddings if schema version changed (e.g., date enrichment // added). This runs before connecting so the entity-core process handles it // on startup rather than silently serving stale vectors. const fullConfig: ServerConfig = { ...DEFAULT_SERVER_CONFIG, ...config }; await autoRebuildEmbeddings(fullConfig.dataDir); + let pluginManager: EntityCorePluginManager | undefined = + fullConfig.pluginManager; + if (!pluginManager) { + const store = fullConfig.store ?? new FileStore(fullConfig.dataDir); + const graphStore = fullConfig.graphStore ?? + new GraphStore(fullConfig.dataDir); + const embeddingCache = new EmbeddingCache(fullConfig.dataDir); + pluginManager = createEntityCorePluginManager( + Deno.env.get("PSYCHEROS_PLUGIN_DIR") ?? + `${fullConfig.dataDir}/../.psycheros/plugins`, + { + dataDir: fullConfig.dataDir, + store, + graphStore, + embeddingCache, + log: (...args) => console.error("[Plugins]", ...args), + }, + ); + await pluginManager.load(); + } + + const server = createServer({ ...config, pluginManager }); + const transport = new StdioServerTransport(); + transport.onclose = () => { + void pluginManager?.stop(); + }; + await server.connect(transport); console.error("Entity Core MCP server started"); diff --git a/packages/entity-core/src/types.ts b/packages/entity-core/src/types.ts index faa624b..942e599 100644 --- a/packages/entity-core/src/types.ts +++ b/packages/entity-core/src/types.ts @@ -221,6 +221,8 @@ export interface ServerConfig { * keep using a stale (closed) connection. */ consolidationRunner?: import("./consolidation/runner.ts").ConsolidationRunner; + /** Trusted local plugins prepared before the MCP transport connects */ + pluginManager?: import("./plugins/mod.ts").EntityCorePluginManager; /** Minimum score threshold for RAG retrieval */ ragMinScore?: number; /** Maximum chunks to retrieve */ diff --git a/packages/launcher-v2/frontend/index.html b/packages/launcher-v2/frontend/index.html index 19db081..d2385f4 100644 --- a/packages/launcher-v2/frontend/index.html +++ b/packages/launcher-v2/frontend/index.html @@ -956,8 +956,8 @@

Manage my data.

Back up Exports a snapshot of my entity data (memories, vault, identity, - settings, generated images) as a zip in your Downloads folder. - Daemon must be running. + settings, generated images, and trusted local plugins) as a zip in + your Downloads folder. Daemon must be running.