diff --git a/docs/docs.json b/docs/docs.json index 4ac4af91..3f5c6457 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -259,30 +259,6 @@ "source": "/camera", "destination": "/nodes/camera" }, - { - "source": "/clawd", - "destination": "/start/mayros" - }, - { - "source": "/start/clawd", - "destination": "/start/mayros" - }, - { - "source": "/start/clawd/", - "destination": "/start/mayros" - }, - { - "source": "/clawhub", - "destination": "/tools/skills-hub" - }, - { - "source": "/clawdhub", - "destination": "/tools/skills-hub" - }, - { - "source": "/tools/clawdhub", - "destination": "/tools/skills-hub" - }, { "source": "/configuration", "destination": "/gateway/configuration" diff --git a/extensions/agent-mesh/config.ts b/extensions/agent-mesh/config.ts index 3cbf43b1..29d7fc8c 100644 --- a/extensions/agent-mesh/config.ts +++ b/extensions/agent-mesh/config.ts @@ -46,7 +46,7 @@ export type AgentMeshConfig = { const DEFAULT_NAMESPACE = "mayros"; const DEFAULT_HOST = "127.0.0.1"; -const DEFAULT_PORT = 8080; +const DEFAULT_PORT = 19090; const DEFAULT_MAX_SHARED_NAMESPACES = 50; const DEFAULT_DELEGATION_TIMEOUT = 300; const DEFAULT_AUTO_MERGE = true; @@ -192,8 +192,11 @@ export function parseBackgroundConfig(raw: unknown): BackgroundConfig { export const agentMeshConfigSchema = { parse(value: unknown): AgentMeshConfig { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("agent mesh config required"); + if (value === null || value === undefined) { + value = {}; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("agent mesh config must be an object"); } const cfg = value as Record; assertAllowedKeys( diff --git a/extensions/agent-mesh/index.test.ts b/extensions/agent-mesh/index.test.ts index ea95722e..3746f1bf 100644 --- a/extensions/agent-mesh/index.test.ts +++ b/extensions/agent-mesh/index.test.ts @@ -25,7 +25,7 @@ describe("agent mesh config", () => { const config = agentMeshConfigSchema.parse({}); expect(config.cortex.host).toBe("127.0.0.1"); - expect(config.cortex.port).toBe(8080); + expect(config.cortex.port).toBe(19090); expect(config.cortex.authToken).toBe(undefined); expect(config.agentNamespace).toBe("mayros"); expect(config.mesh.maxSharedNamespaces).toBe(50); @@ -721,7 +721,7 @@ describe("delegation engine", () => { }; const nsMgr = new NamespaceManager(mockClient, "mayros", 50); - const cortexClient = new CortexClient({ host: "127.0.0.1", port: 8080 }); + const cortexClient = new CortexClient({ host: "127.0.0.1", port: 19090 }); const engine = new DelegationEngine(cortexClient, "mayros", nsMgr); const ctx = await engine.prepareContext("Review the TypeScript backend code", "parent-agent"); @@ -761,7 +761,7 @@ describe("delegation engine", () => { }; const nsMgr = new NamespaceManager(mockClient, "mayros", 50); - const cortexClient = new CortexClient({ host: "127.0.0.1", port: 8080 }); + const cortexClient = new CortexClient({ host: "127.0.0.1", port: 19090 }); const engine = new DelegationEngine(cortexClient, "mayros", nsMgr); const ctx = { @@ -803,7 +803,7 @@ describe("delegation engine", () => { }; const nsMgr = new NamespaceManager(mockClient, "mayros", 50); - const cortexClient = new CortexClient({ host: "127.0.0.1", port: 8080 }); + const cortexClient = new CortexClient({ host: "127.0.0.1", port: 19090 }); const engine = new DelegationEngine(cortexClient, "mayros", nsMgr); const result = engine.getInjectedContext("nonexistent"); @@ -898,7 +898,7 @@ describe("knowledge fusion", () => { try { const fusionEngine = new KnowledgeFusion( - new CortexClient({ host: "127.0.0.1", port: 8080 }), + new CortexClient({ host: "127.0.0.1", port: 19090 }), "mayros", ); @@ -924,7 +924,7 @@ describe("knowledge fusion", () => { const { KnowledgeFusion } = await import("./knowledge-fusion.js"); const fusion = new KnowledgeFusion( - new CortexClient({ host: "127.0.0.1", port: 8080 }), + new CortexClient({ host: "127.0.0.1", port: 19090 }), "mayros", ); expect(fusion).toBeTruthy(); @@ -934,7 +934,7 @@ describe("knowledge fusion", () => { const { KnowledgeFusion } = await import("./knowledge-fusion.js"); const fusion = new KnowledgeFusion( - new CortexClient({ host: "127.0.0.1", port: 8080, authToken: "Bearer secret" }), + new CortexClient({ host: "127.0.0.1", port: 19090, authToken: "Bearer secret" }), "mayros", ); expect(fusion).toBeTruthy(); diff --git a/extensions/agent-mesh/knowledge-fusion.test.ts b/extensions/agent-mesh/knowledge-fusion.test.ts index f5bfafc7..c18579c5 100644 --- a/extensions/agent-mesh/knowledge-fusion.test.ts +++ b/extensions/agent-mesh/knowledge-fusion.test.ts @@ -121,7 +121,7 @@ describe("KnowledgeFusion", () => { }); function createFusion(ns = "mayros") { - return new KnowledgeFusion(new CortexClient({ host: "localhost", port: 8080 }), ns); + return new KnowledgeFusion(new CortexClient({ host: "localhost", port: 19090 }), ns); } // ----- additive strategy ----- diff --git a/extensions/agent-mesh/package.json b/extensions/agent-mesh/package.json index 62798964..f6f26805 100644 --- a/extensions/agent-mesh/package.json +++ b/extensions/agent-mesh/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-agent-mesh", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros multi-agent coordination mesh with shared namespaces, delegation, and knowledge fusion", "type": "module", diff --git a/extensions/analytics/mayros.plugin.json b/extensions/analytics/mayros.plugin.json new file mode 100644 index 00000000..0a08728d --- /dev/null +++ b/extensions/analytics/mayros.plugin.json @@ -0,0 +1,50 @@ +{ + "id": "analytics", + "kind": "observability", + "uiHints": { + "enabled": { + "label": "Enable Analytics", + "help": "Opt-in usage analytics collection (default: false)" + }, + "privacyMode": { + "label": "Privacy Mode", + "placeholder": "anonymous", + "help": "\"anonymous\" hashes IDs, \"identified\" keeps raw, \"off\" disables collection" + }, + "endpoint": { + "label": "Endpoint", + "placeholder": "https://analytics.apilium.com/batch", + "help": "HTTP endpoint for batch event delivery (empty = local-only logging)" + }, + "maxBufferSize": { + "label": "Max Buffer Size", + "placeholder": "500", + "advanced": true, + "help": "Maximum events in buffer before flush" + }, + "flushIntervalMs": { + "label": "Flush Interval (ms)", + "placeholder": "30000", + "advanced": true, + "help": "Interval between automatic flushes" + }, + "eventTtlMs": { + "label": "Event TTL (ms)", + "placeholder": "3600000", + "advanced": true, + "help": "Time-to-live for buffered events" + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "privacyMode": { "type": "string", "enum": ["anonymous", "identified", "off"] }, + "endpoint": { "type": "string" }, + "maxBufferSize": { "type": "number", "minimum": 1, "maximum": 10000 }, + "flushIntervalMs": { "type": "number", "minimum": 1000 }, + "eventTtlMs": { "type": "number", "minimum": 60000 } + } + } +} diff --git a/extensions/analytics/package.json b/extensions/analytics/package.json index 282466e4..0615f9c9 100644 --- a/extensions/analytics/package.json +++ b/extensions/analytics/package.json @@ -1,6 +1,6 @@ { - "name": "@apilium/mayros-plugin-analytics", - "version": "0.1.0", + "name": "@apilium/mayros-analytics", + "version": "0.1.6", "private": true, "type": "module", "main": "index.ts", diff --git a/extensions/bash-sandbox/package.json b/extensions/bash-sandbox/package.json index 34e1723f..d49c5207 100644 --- a/extensions/bash-sandbox/package.json +++ b/extensions/bash-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-bash-sandbox", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Bash command sandbox with domain allowlist, command blocklist, and dangerous pattern detection", "type": "module", diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index c03c354f..652851bc 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-bluebubbles", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros BlueBubbles channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/browser-automation/mayros.plugin.json b/extensions/browser-automation/mayros.plugin.json new file mode 100644 index 00000000..0c997a13 --- /dev/null +++ b/extensions/browser-automation/mayros.plugin.json @@ -0,0 +1,25 @@ +{ + "id": "browser-automation", + "kind": "tool", + "uiHints": { + "cdpPort": { + "label": "CDP Port", + "placeholder": "9222", + "help": "Chrome DevTools Protocol remote debugging port" + }, + "cdpHost": { + "label": "CDP Host", + "placeholder": "127.0.0.1", + "advanced": true, + "help": "Hostname where Chrome is listening for CDP connections" + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "cdpPort": { "type": "number", "minimum": 1, "maximum": 65535 }, + "cdpHost": { "type": "string" } + } + } +} diff --git a/extensions/ci-plugin/package.json b/extensions/ci-plugin/package.json index 0d566f1d..aaa9a750 100644 --- a/extensions/ci-plugin/package.json +++ b/extensions/ci-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-ci-plugin", - "version": "0.1.4", + "version": "0.1.6", "description": "CI/CD pipeline integration for Mayros — GitHub Actions and GitLab CI providers", "type": "module", "dependencies": { diff --git a/extensions/ci-plugin/providers/github.test.ts b/extensions/ci-plugin/providers/github.test.ts index c711dd07..31e43bb4 100644 --- a/extensions/ci-plugin/providers/github.test.ts +++ b/extensions/ci-plugin/providers/github.test.ts @@ -133,7 +133,12 @@ describe("GitHubProvider", () => { }); it("triggerRun sends correct payload", async () => { - mf().mockResolvedValue({ ok: true, status: 204 } as Response); + // First call is the dispatch (204 no body), subsequent calls are polling + mf() + .mockResolvedValueOnce({ ok: true, status: 204 } as Response) + .mockResolvedValue( + jsonResponse({ total_count: 1, workflow_runs: [makeRun({ head_branch: "main" })] }), + ); const run = await provider.triggerRun("owner/repo", { branch: "main", @@ -148,7 +153,12 @@ describe("GitHubProvider", () => { }); it("triggerRun defaults to ci.yml workflow", async () => { - mf().mockResolvedValue({ ok: true, status: 204 } as Response); + // First call is the dispatch (204 no body), subsequent calls are polling + mf() + .mockResolvedValueOnce({ ok: true, status: 204 } as Response) + .mockResolvedValue( + jsonResponse({ total_count: 1, workflow_runs: [makeRun({ head_branch: "main" })] }), + ); await provider.triggerRun("owner/repo", { branch: "main" }); diff --git a/extensions/code-indexer/config.ts b/extensions/code-indexer/config.ts index e450d6ab..118d97f4 100644 --- a/extensions/code-indexer/config.ts +++ b/extensions/code-indexer/config.ts @@ -39,8 +39,11 @@ const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs"]; export const codeIndexerConfigSchema = { parse(value: unknown): CodeIndexerConfig { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("code-indexer config required"); + if (value === null || value === undefined) { + value = {}; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("code-indexer config must be an object"); } const cfg = value as Record; assertAllowedKeys( diff --git a/extensions/code-indexer/index.test.ts b/extensions/code-indexer/index.test.ts index 819d4ed8..45f2056f 100644 --- a/extensions/code-indexer/index.test.ts +++ b/extensions/code-indexer/index.test.ts @@ -283,7 +283,7 @@ describe("code-indexer config", () => { expect(config).toBeDefined(); expect(config?.cortex?.host).toBe("127.0.0.1"); - expect(config?.cortex?.port).toBe(8080); + expect(config?.cortex?.port).toBe(19090); expect(config?.agentNamespace).toBe("mayros"); expect(config?.paths).toEqual(["src", "extensions"]); expect(config?.maxFiles).toBe(5000); diff --git a/extensions/code-indexer/package.json b/extensions/code-indexer/package.json index f449fefb..5b072971 100644 --- a/extensions/code-indexer/package.json +++ b/extensions/code-indexer/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-code-indexer", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros code indexer plugin — regex-based codebase scanning with RDF triple storage in Cortex", "type": "module", diff --git a/extensions/code-tools/mayros.plugin.json b/extensions/code-tools/mayros.plugin.json index caeb3798..256f07c9 100644 --- a/extensions/code-tools/mayros.plugin.json +++ b/extensions/code-tools/mayros.plugin.json @@ -3,5 +3,47 @@ "name": "Code Tools", "description": "File read/write/edit, glob, grep, ls, and shell tools for local code interaction", "version": "0.1.4", - "kind": "coding" + "kind": "coding", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "workspaceRoot": { "type": "string" }, + "maxFileSizeBytes": { "type": "number", "minimum": 1024, "maximum": 50000000 }, + "shellTimeout": { "type": "number", "minimum": 1000, "maximum": 600000 }, + "maxGlobResults": { "type": "number", "minimum": 10, "maximum": 5000 }, + "maxGrepResults": { "type": "number", "minimum": 1, "maximum": 500 }, + "shellEnabled": { "type": "boolean" } + } + }, + "uiHints": { + "workspaceRoot": { + "label": "Workspace Root", + "help": "Root directory for file operations. All paths are resolved relative to this." + }, + "maxFileSizeBytes": { + "label": "Max File Size", + "placeholder": "2097152", + "help": "Maximum file size in bytes for read operations (1024-50000000)" + }, + "shellTimeout": { + "label": "Shell Timeout", + "placeholder": "120000", + "help": "Maximum execution time in milliseconds for shell commands (1000-600000)" + }, + "maxGlobResults": { + "label": "Max Glob Results", + "placeholder": "200", + "help": "Maximum number of glob results returned (10-5000)" + }, + "maxGrepResults": { + "label": "Max Grep Results", + "placeholder": "50", + "help": "Maximum number of grep results returned (1-500)" + }, + "shellEnabled": { + "label": "Shell Enabled", + "help": "Whether shell command execution is allowed" + } + } } diff --git a/extensions/code-tools/package.json b/extensions/code-tools/package.json index 3a531333..5b007841 100644 --- a/extensions/code-tools/package.json +++ b/extensions/code-tools/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-code-tools", - "version": "0.1.4", + "version": "0.1.6", "private": true, "type": "module", "dependencies": { diff --git a/extensions/code-tools/tools/code-shell-interactive.ts b/extensions/code-tools/tools/code-shell-interactive.ts index 62701895..a6f4c5c0 100644 --- a/extensions/code-tools/tools/code-shell-interactive.ts +++ b/extensions/code-tools/tools/code-shell-interactive.ts @@ -68,10 +68,13 @@ export function registerCodeShellInteractive(api: MayrosPluginApi, cfg: CodeTool } const startTime = Date.now(); - // Hard cap: cfg.shellTimeout is the max the user can request; use it - // as the outer guard so a hanging PTY never leaks beyond this limit. + // Hard cap: the hard timeout must exceed the soft timeout so the soft + // kill path has a chance to resolve with partial output. Add a 5s + // buffer (capped to cfg.shellTimeout) so a hanging PTY never leaks. const hardTimeout = - typeof p.timeout === "number" ? timeout : Math.min(60000, cfg.shellTimeout); + typeof p.timeout === "number" + ? Math.min(timeout + 5000, cfg.shellTimeout) + : Math.min(60000, cfg.shellTimeout); return new Promise((resolve, reject) => { let output = ""; diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 2711b8e6..ff56fa3f 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-copilot-proxy", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Copilot Proxy provider plugin", "type": "module", diff --git a/extensions/cortex-sync/package.json b/extensions/cortex-sync/package.json index 201a238d..6762fce3 100644 --- a/extensions/cortex-sync/package.json +++ b/extensions/cortex-sync/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-cortex-sync", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Cortex DAG synchronization — peer discovery, delta sync, and cross-device knowledge replication", "type": "module", diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index d19b95ba..df8c8baa 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-diagnostics-otel", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros diagnostics OpenTelemetry exporter", "license": "MIT", "type": "module", diff --git a/extensions/discord/package.json b/extensions/discord/package.json index c092e455..974ff221 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-discord", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros Discord channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 58845321..40076cae 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-feishu", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros Feishu/Lark channel plugin (community maintained by @m1heng)", "license": "MIT", "type": "module", diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json index d467372a..6e270db7 100644 --- a/extensions/google-antigravity-auth/package.json +++ b/extensions/google-antigravity-auth/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-google-antigravity-auth", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Google Antigravity OAuth provider plugin", "type": "module", diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 31507185..c5086dd8 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-google-gemini-cli-auth", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 3fc64388..d98d15a1 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-googlechat", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Google Chat channel plugin", "type": "module", diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 956ddc8b..d9c3eff8 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-imessage", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros iMessage channel plugin", "type": "module", diff --git a/extensions/interactive-permissions/index.test.ts b/extensions/interactive-permissions/index.test.ts index 629becc3..9bd63c72 100644 --- a/extensions/interactive-permissions/index.test.ts +++ b/extensions/interactive-permissions/index.test.ts @@ -26,7 +26,7 @@ describe("interactive-permissions config", () => { const config = interactivePermissionsConfigSchema.parse({}); expect(config.cortex.host).toBe("127.0.0.1"); - expect(config.cortex.port).toBe(8080); + expect(config.cortex.port).toBe(19090); expect(config.agentNamespace).toBe("mayros"); expect(config.autoApproveSafe).toBe(true); expect(config.defaultDeny).toBe(false); diff --git a/extensions/interactive-permissions/package.json b/extensions/interactive-permissions/package.json index 55b2d05f..11c3870f 100644 --- a/extensions/interactive-permissions/package.json +++ b/extensions/interactive-permissions/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-interactive-permissions", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Runtime permission dialogs, bash intent classification, policy persistence, and audit trail", "type": "module", diff --git a/extensions/iot-bridge/config.ts b/extensions/iot-bridge/config.ts index 59ba4633..ded88e38 100644 --- a/extensions/iot-bridge/config.ts +++ b/extensions/iot-bridge/config.ts @@ -29,7 +29,7 @@ export type IoTBridgeConfig = { // --------------------------------------------------------------------------- const DEFAULT_POLL_INTERVAL_MS = 30_000; -const DEFAULT_PORT = 8080; +const DEFAULT_PORT = 19090; const DEFAULT_FLEET_PERSIST_PATH = "~/.mayros/iot-fleet.json"; const DEFAULT_MAX_NODES = 50; diff --git a/extensions/iot-bridge/package.json b/extensions/iot-bridge/package.json index ee5216c9..38fdadfd 100644 --- a/extensions/iot-bridge/package.json +++ b/extensions/iot-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-iot-bridge", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "IoT Bridge — connect MAYROS agents to aingle_minimal IoT nodes via REST", "type": "module", diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 8a054d79..708bce1c 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-irc", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros IRC channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/line/package.json b/extensions/line/package.json index febbebe1..07f432bf 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-line", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros LINE channel plugin", "type": "module", diff --git a/extensions/llm-hooks/package.json b/extensions/llm-hooks/package.json index 86dff5e1..bea112a8 100644 --- a/extensions/llm-hooks/package.json +++ b/extensions/llm-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-llm-hooks", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Markdown-defined hooks evaluated by LLM for policy enforcement", "type": "module", diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index ac1b964c..b3c64d17 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-llm-task", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 93410671..8c2676d8 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-lobster", - "version": "0.1.4", + "version": "0.1.6", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "license": "MIT", "type": "module", diff --git a/extensions/lsp-bridge/package.json b/extensions/lsp-bridge/package.json index 9f549560..e5b2e434 100644 --- a/extensions/lsp-bridge/package.json +++ b/extensions/lsp-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-lsp-bridge", - "version": "0.1.4", + "version": "0.1.6", "description": "Cortex-backed language server bridge for Mayros — hover, diagnostics, go-to-definition", "type": "module", "dependencies": { diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 435b1f85..894dce48 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-matrix", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros Matrix channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 7674005c..b58c634e 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-mattermost", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Mattermost channel plugin", "type": "module", diff --git a/extensions/mcp-client/index.test.ts b/extensions/mcp-client/index.test.ts index b0cb970f..f933fd92 100644 --- a/extensions/mcp-client/index.test.ts +++ b/extensions/mcp-client/index.test.ts @@ -18,7 +18,7 @@ describe("mcp-client config", () => { const config = mcpClientConfigSchema.parse({}); expect(config.cortex.host).toBe("127.0.0.1"); - expect(config.cortex.port).toBe(8080); + expect(config.cortex.port).toBe(19090); expect(config.agentNamespace).toBe("mayros"); expect(config.servers).toEqual([]); expect(config.registerInCortex).toBe(true); diff --git a/extensions/mcp-client/package.json b/extensions/mcp-client/package.json index 49e620f7..14322633 100644 --- a/extensions/mcp-client/package.json +++ b/extensions/mcp-client/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-mcp-client", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "MCP server client with multi-transport support and Cortex tool registry", "type": "module", diff --git a/extensions/mcp-server/config.test.ts b/extensions/mcp-server/config.test.ts index 734feb41..98831656 100644 --- a/extensions/mcp-server/config.test.ts +++ b/extensions/mcp-server/config.test.ts @@ -20,7 +20,7 @@ describe("mcpServerConfigSchema", () => { it("parses full config", () => { const cfg = mcpServerConfigSchema.parse({ transport: "http", - port: 8080, + port: 19090, host: "0.0.0.0", serverName: "my-mayros", serverVersion: "2.0.0", @@ -28,7 +28,7 @@ describe("mcpServerConfigSchema", () => { capabilities: { tools: true, resources: false, prompts: true }, }); expect(cfg.transport).toBe("http"); - expect(cfg.port).toBe(8080); + expect(cfg.port).toBe(19090); expect(cfg.host).toBe("0.0.0.0"); expect(cfg.serverName).toBe("my-mayros"); expect(cfg.auth.token).toBe("secret"); diff --git a/extensions/mcp-server/package.json b/extensions/mcp-server/package.json index 4c9e5964..51284c74 100644 --- a/extensions/mcp-server/package.json +++ b/extensions/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-mcp-server", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "MCP server exposing Mayros tools, Cortex resources, and workflow prompts via Model Context Protocol", "type": "module", diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index 8ee50d89..ccf760fc 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-memory-core", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros core memory search plugin", "type": "module", diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index 064ca66a..4ac5a5f1 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-memory-lancedb", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", diff --git a/extensions/memory-semantic/config.ts b/extensions/memory-semantic/config.ts index 8c0e5281..fd98c1d2 100644 --- a/extensions/memory-semantic/config.ts +++ b/extensions/memory-semantic/config.ts @@ -45,12 +45,15 @@ export type SemanticMemoryConfig = { const DEFAULT_NAMESPACE = "mayros"; const DEFAULT_HOST = "127.0.0.1"; -const DEFAULT_PORT = 8080; +const DEFAULT_PORT = 19090; export const semanticMemoryConfigSchema = { parse(value: unknown): SemanticMemoryConfig { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("semantic memory config required"); + if (value === null || value === undefined) { + value = {}; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("semantic memory config must be an object"); } const cfg = value as Record; assertAllowedKeys( diff --git a/extensions/memory-semantic/cortex-gateway-methods.test.ts b/extensions/memory-semantic/cortex-gateway-methods.test.ts new file mode 100644 index 00000000..a5102ddf --- /dev/null +++ b/extensions/memory-semantic/cortex-gateway-methods.test.ts @@ -0,0 +1,528 @@ +/** + * Tests for cortex.status, cortex.reconnect, cortex.triples, cortex.subjects, + * and cortex.predicates gateway methods. + * + * Uses mocks for CortexClient, CortexSidecar, PendingWriteQueue, and HealthMonitor + * to test the gateway method handlers in isolation. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock respond function +function createRespond() { + return vi.fn() as ReturnType & { + (ok: boolean, payload?: unknown): void; + }; +} + +// Mock dependencies +function createMocks(overrides?: { + healthy?: boolean; + stats?: { + server: { version: string; uptime_seconds: number }; + graph: { triple_count: number; subject_count: number }; + } | null; + sidecarStatus?: "stopped" | "starting" | "running" | "failed"; + startResult?: boolean; + queuedWrites?: number; + triples?: { triples: unknown[]; total: number }; + subjects?: { subjects: string[]; total: number }; + predicates?: { predicates: string[]; total: number }; +}) { + const opts = { + healthy: true, + stats: { + server: { version: "0.3.7", uptime_seconds: 120 }, + graph: { triple_count: 500, subject_count: 100 }, + }, + sidecarStatus: "running" as const, + startResult: true, + queuedWrites: 0, + ...overrides, + }; + + const client = { + isHealthy: vi.fn().mockResolvedValue(opts.healthy), + stats: opts.stats + ? vi.fn().mockResolvedValue(opts.stats) + : vi.fn().mockRejectedValue(new Error("stats unavailable")), + listTriples: vi.fn().mockResolvedValue(opts.triples ?? { triples: [], total: 0 }), + listSubjects: vi.fn().mockResolvedValue(opts.subjects ?? { subjects: [], total: 0 }), + listPredicates: vi.fn().mockResolvedValue(opts.predicates ?? { predicates: [], total: 0 }), + }; + + const sidecar = { + status: opts.sidecarStatus, + start: vi.fn().mockResolvedValue(opts.startResult), + stop: vi.fn().mockResolvedValue(undefined), + }; + + const writeQueue = { + getStats: vi.fn().mockReturnValue({ queued: opts.queuedWrites, maxSize: 200 }), + drain: vi.fn().mockResolvedValue(0), + }; + + const healthMonitor = { + start: vi.fn(), + stop: vi.fn(), + }; + + const cfg = { + cortex: { host: "127.0.0.1", port: 19090, autoStart: true }, + }; + + return { client, sidecar, writeQueue, healthMonitor, cfg }; +} + +// Simulate the cortex.status handler logic inline (mirrors index.ts) +async function handleCortexStatus( + mocks: ReturnType, + respond: ReturnType, +) { + const { client, sidecar, writeQueue, cfg } = mocks; + const healthy = await client.isHealthy(); + let version: string | null = null; + let uptime: number | null = null; + let triples: number | null = null; + let subjects: number | null = null; + if (healthy) { + try { + const s = await client.stats(); + version = s.server?.version ?? null; + uptime = s.server?.uptime_seconds ?? null; + triples = s.graph?.triple_count ?? null; + subjects = s.graph?.subject_count ?? null; + } catch { + /* stats endpoint may not be available */ + } + } + respond(true, { + status: healthy ? "online" : "offline", + sidecar: sidecar.status, + endpoint: `${cfg.cortex.host}:${cfg.cortex.port}`, + autoStart: cfg.cortex.autoStart, + version, + uptime, + triples, + subjects, + pendingWrites: writeQueue.getStats().queued, + }); +} + +// Simulate the cortex.reconnect handler logic inline (mirrors index.ts) +async function handleCortexReconnect( + mocks: ReturnType, + respond: ReturnType, + setCortexAvailable: (v: boolean) => void, +) { + const { sidecar, writeQueue, healthMonitor } = mocks; + if (sidecar.status === "running" || sidecar.status === "starting") { + await sidecar.stop(); + } + const started = await sidecar.start(); + setCortexAvailable(started); + if (started) { + healthMonitor.start(); + void writeQueue.drain(); + } + respond(true, { + success: started, + status: started ? "online" : "failed", + sidecar: sidecar.status, + }); +} + +// ============================================================================ +// cortex.status +// ============================================================================ + +describe("cortex.status gateway method", () => { + it("returns online when healthy", async () => { + const mocks = createMocks({ healthy: true }); + const respond = createRespond(); + await handleCortexStatus(mocks, respond); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + status: "online", + sidecar: "running", + endpoint: "127.0.0.1:19090", + }), + ); + }); + + it("returns offline when unhealthy", async () => { + const mocks = createMocks({ healthy: false, sidecarStatus: "failed" }); + const respond = createRespond(); + await handleCortexStatus(mocks, respond); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + status: "offline", + sidecar: "failed", + }), + ); + }); + + it("includes stats when available", async () => { + const mocks = createMocks({ healthy: true }); + const respond = createRespond(); + await handleCortexStatus(mocks, respond); + + const payload = respond.mock.calls[0][1] as Record; + expect(payload.version).toBe("0.3.7"); + expect(payload.uptime).toBe(120); + expect(payload.triples).toBe(500); + expect(payload.subjects).toBe(100); + }); + + it("returns null stats when stats endpoint fails", async () => { + const mocks = createMocks({ healthy: true, stats: null }); + const respond = createRespond(); + await handleCortexStatus(mocks, respond); + + const payload = respond.mock.calls[0][1] as Record; + expect(payload.status).toBe("online"); + expect(payload.version).toBeNull(); + expect(payload.uptime).toBeNull(); + expect(payload.triples).toBeNull(); + }); + + it("includes pending writes count", async () => { + const mocks = createMocks({ queuedWrites: 5 }); + const respond = createRespond(); + await handleCortexStatus(mocks, respond); + + const payload = respond.mock.calls[0][1] as Record; + expect(payload.pendingWrites).toBe(5); + }); + + it("includes autoStart config value", async () => { + const mocks = createMocks(); + const respond = createRespond(); + await handleCortexStatus(mocks, respond); + + const payload = respond.mock.calls[0][1] as Record; + expect(payload.autoStart).toBe(true); + }); +}); + +// ============================================================================ +// cortex.reconnect +// ============================================================================ + +describe("cortex.reconnect gateway method", () => { + it("starts sidecar and returns success", async () => { + const mocks = createMocks({ sidecarStatus: "stopped", startResult: true }); + const respond = createRespond(); + let available = false; + await handleCortexReconnect(mocks, respond, (v) => (available = v)); + + expect(mocks.sidecar.start).toHaveBeenCalled(); + expect(available).toBe(true); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + success: true, + status: "online", + }), + ); + }); + + it("returns failed when sidecar cannot start", async () => { + const mocks = createMocks({ sidecarStatus: "stopped", startResult: false }); + const respond = createRespond(); + let available = true; + await handleCortexReconnect(mocks, respond, (v) => (available = v)); + + expect(available).toBe(false); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + success: false, + status: "failed", + }), + ); + }); + + it("drains write queue on success", async () => { + const mocks = createMocks({ sidecarStatus: "stopped", startResult: true }); + const respond = createRespond(); + await handleCortexReconnect(mocks, respond, () => {}); + + expect(mocks.writeQueue.drain).toHaveBeenCalled(); + }); + + it("stops running sidecar before restarting", async () => { + const mocks = createMocks({ sidecarStatus: "running", startResult: true }); + const respond = createRespond(); + await handleCortexReconnect(mocks, respond, () => {}); + + expect(mocks.sidecar.stop).toHaveBeenCalled(); + expect(mocks.sidecar.start).toHaveBeenCalled(); + }); + + it("does not stop already-stopped sidecar", async () => { + const mocks = createMocks({ sidecarStatus: "stopped", startResult: true }); + const respond = createRespond(); + await handleCortexReconnect(mocks, respond, () => {}); + + expect(mocks.sidecar.stop).not.toHaveBeenCalled(); + }); + + it("resumes health monitor on success", async () => { + const mocks = createMocks({ sidecarStatus: "stopped", startResult: true }); + const respond = createRespond(); + await handleCortexReconnect(mocks, respond, () => {}); + + expect(mocks.healthMonitor.start).toHaveBeenCalled(); + }); + + it("does not resume health monitor on failure", async () => { + const mocks = createMocks({ sidecarStatus: "stopped", startResult: false }); + const respond = createRespond(); + await handleCortexReconnect(mocks, respond, () => {}); + + expect(mocks.healthMonitor.start).not.toHaveBeenCalled(); + }); + + it("does not drain queue on failure", async () => { + const mocks = createMocks({ sidecarStatus: "stopped", startResult: false }); + const respond = createRespond(); + await handleCortexReconnect(mocks, respond, () => {}); + + expect(mocks.writeQueue.drain).not.toHaveBeenCalled(); + }); +}); + +// ============================================================================ +// cortex.triples / cortex.subjects / cortex.predicates handler simulators +// ============================================================================ + +async function handleCortexTriples( + mocks: ReturnType, + respond: ReturnType, + cortexAvailable: boolean, + params?: Record, +) { + if (!cortexAvailable) { + respond(false, { error: "Cortex is offline" }); + return; + } + try { + const p = params ?? {}; + const result = await mocks.client.listTriples({ + subject: typeof p.subject === "string" ? p.subject : undefined, + predicate: typeof p.predicate === "string" ? p.predicate : undefined, + object: typeof p.object === "string" ? p.object : undefined, + limit: typeof p.limit === "number" ? p.limit : 50, + offset: typeof p.offset === "number" ? p.offset : 0, + }); + respond(true, { triples: result.triples, total: result.total }); + } catch (err) { + respond(false, { error: String(err) }); + } +} + +async function handleCortexSubjects( + mocks: ReturnType, + respond: ReturnType, + cortexAvailable: boolean, + params?: Record, +) { + if (!cortexAvailable) { + respond(false, { error: "Cortex is offline" }); + return; + } + try { + const p = params ?? {}; + const result = await mocks.client.listSubjects({ + limit: typeof p.limit === "number" ? p.limit : 200, + }); + respond(true, { subjects: result.subjects, total: result.total }); + } catch (err) { + respond(false, { error: String(err) }); + } +} + +async function handleCortexPredicates( + mocks: ReturnType, + respond: ReturnType, + cortexAvailable: boolean, + params?: Record, +) { + if (!cortexAvailable) { + respond(false, { error: "Cortex is offline" }); + return; + } + try { + const p = params ?? {}; + const result = await mocks.client.listPredicates({ + limit: typeof p.limit === "number" ? p.limit : 200, + }); + respond(true, { predicates: result.predicates, total: result.total }); + } catch (err) { + respond(false, { error: String(err) }); + } +} + +// ============================================================================ +// cortex.triples +// ============================================================================ + +describe("cortex.triples gateway method", () => { + it("returns error when cortex is offline", async () => { + const mocks = createMocks(); + const respond = createRespond(); + await handleCortexTriples(mocks, respond, false); + + expect(respond).toHaveBeenCalledWith(false, { error: "Cortex is offline" }); + }); + + it("returns triples with default pagination", async () => { + const fakeTriples = [{ id: "1", subject: "ns:test", predicate: "type", object: "demo" }]; + const mocks = createMocks({ triples: { triples: fakeTriples, total: 1 } }); + const respond = createRespond(); + await handleCortexTriples(mocks, respond, true); + + expect(respond).toHaveBeenCalledWith(true, { triples: fakeTriples, total: 1 }); + expect(mocks.client.listTriples).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 50, + offset: 0, + }), + ); + }); + + it("passes subject and predicate filters", async () => { + const mocks = createMocks(); + const respond = createRespond(); + await handleCortexTriples(mocks, respond, true, { + subject: "ns:session:abc", + predicate: "type", + }); + + expect(mocks.client.listTriples).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "ns:session:abc", + predicate: "type", + }), + ); + }); + + it("passes custom limit and offset", async () => { + const mocks = createMocks(); + const respond = createRespond(); + await handleCortexTriples(mocks, respond, true, { limit: 10, offset: 20 }); + + expect(mocks.client.listTriples).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 10, + offset: 20, + }), + ); + }); + + it("returns error on client failure", async () => { + const mocks = createMocks(); + mocks.client.listTriples.mockRejectedValueOnce(new Error("connection reset")); + const respond = createRespond(); + await handleCortexTriples(mocks, respond, true); + + expect(respond).toHaveBeenCalledWith(false, { + error: expect.stringContaining("connection reset"), + }); + }); +}); + +// ============================================================================ +// cortex.subjects +// ============================================================================ + +describe("cortex.subjects gateway method", () => { + it("returns error when cortex is offline", async () => { + const mocks = createMocks(); + const respond = createRespond(); + await handleCortexSubjects(mocks, respond, false); + + expect(respond).toHaveBeenCalledWith(false, { error: "Cortex is offline" }); + }); + + it("returns subjects with default limit", async () => { + const mocks = createMocks({ + subjects: { subjects: ["ns:a", "ns:b"], total: 2 }, + }); + const respond = createRespond(); + await handleCortexSubjects(mocks, respond, true); + + expect(respond).toHaveBeenCalledWith(true, { + subjects: ["ns:a", "ns:b"], + total: 2, + }); + expect(mocks.client.listSubjects).toHaveBeenCalledWith({ limit: 200 }); + }); + + it("passes custom limit", async () => { + const mocks = createMocks(); + const respond = createRespond(); + await handleCortexSubjects(mocks, respond, true, { limit: 50 }); + + expect(mocks.client.listSubjects).toHaveBeenCalledWith({ limit: 50 }); + }); + + it("returns error on client failure", async () => { + const mocks = createMocks(); + mocks.client.listSubjects.mockRejectedValueOnce(new Error("timeout")); + const respond = createRespond(); + await handleCortexSubjects(mocks, respond, true); + + expect(respond).toHaveBeenCalledWith(false, { error: expect.stringContaining("timeout") }); + }); +}); + +// ============================================================================ +// cortex.predicates +// ============================================================================ + +describe("cortex.predicates gateway method", () => { + it("returns error when cortex is offline", async () => { + const mocks = createMocks(); + const respond = createRespond(); + await handleCortexPredicates(mocks, respond, false); + + expect(respond).toHaveBeenCalledWith(false, { error: "Cortex is offline" }); + }); + + it("returns predicates with default limit", async () => { + const mocks = createMocks({ + predicates: { predicates: ["type", "name", "createdAt"], total: 3 }, + }); + const respond = createRespond(); + await handleCortexPredicates(mocks, respond, true); + + expect(respond).toHaveBeenCalledWith(true, { + predicates: ["type", "name", "createdAt"], + total: 3, + }); + expect(mocks.client.listPredicates).toHaveBeenCalledWith({ limit: 200 }); + }); + + it("passes custom limit", async () => { + const mocks = createMocks(); + const respond = createRespond(); + await handleCortexPredicates(mocks, respond, true, { limit: 100 }); + + expect(mocks.client.listPredicates).toHaveBeenCalledWith({ limit: 100 }); + }); + + it("returns error on client failure", async () => { + const mocks = createMocks(); + mocks.client.listPredicates.mockRejectedValueOnce(new Error("not found")); + const respond = createRespond(); + await handleCortexPredicates(mocks, respond, true); + + expect(respond).toHaveBeenCalledWith(false, { error: expect.stringContaining("not found") }); + }); +}); diff --git a/extensions/memory-semantic/cortex-sidecar.test.ts b/extensions/memory-semantic/cortex-sidecar.test.ts index 78272fa7..f019862c 100644 --- a/extensions/memory-semantic/cortex-sidecar.test.ts +++ b/extensions/memory-semantic/cortex-sidecar.test.ts @@ -17,7 +17,10 @@ const mockState = vi.hoisted(() => ({ spawnFn: vi.fn(), existsSyncFn: vi.fn(() => true), locateCortexBinaryFn: vi.fn(async () => "/usr/bin/fake-cortex"), - getCortexBinaryVersionFn: vi.fn(() => "0.2.6"), + getCortexBinaryVersionFn: vi.fn(() => "0.3.7"), + readFileSyncFn: vi.fn(() => '{"jwtSecret":"test-jwt","adminPassword":"test-admin-pass"}'), + writeFileSyncFn: vi.fn(), + mkdirSyncFn: vi.fn(), })); // ---------- Mocks ---------- @@ -28,15 +31,31 @@ vi.mock("node:child_process", () => ({ vi.mock("node:fs", () => ({ existsSync: mockState.existsSyncFn, + readFileSync: mockState.readFileSyncFn, + writeFileSync: mockState.writeFileSyncFn, + mkdirSync: mockState.mkdirSyncFn, })); +vi.mock("node:crypto", () => ({ + randomBytes: vi.fn((n: number) => Buffer.alloc(n, 0x41)), +})); + +vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/tmp/test-home"), +})); + +vi.mock("node:path", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, join: actual.join }; +}); + vi.mock("../shared/cortex-binary-locator.js", () => ({ locateCortexBinary: mockState.locateCortexBinaryFn, getCortexBinaryVersion: mockState.getCortexBinaryVersionFn, })); vi.mock("../shared/cortex-version.js", () => ({ - REQUIRED_CORTEX_VERSION: "0.2.6", + REQUIRED_CORTEX_VERSION: "0.3.7", })); // Mock the CortexClient used internally by the sidecar @@ -81,7 +100,7 @@ mockState.spawnFn.mockImplementation(() => { return proc; }); -import { CortexSidecar } from "./cortex-sidecar.js"; +import { CortexSidecar, ensureCortexSecrets } from "./cortex-sidecar.js"; describe("CortexSidecar", () => { beforeEach(() => { @@ -97,7 +116,7 @@ describe("CortexSidecar", () => { }); mockState.existsSyncFn.mockReturnValue(true); mockState.locateCortexBinaryFn.mockResolvedValue("/usr/bin/fake-cortex"); - mockState.getCortexBinaryVersionFn.mockReturnValue("0.2.6"); + mockState.getCortexBinaryVersionFn.mockReturnValue("0.3.7"); }); afterEach(() => { @@ -247,4 +266,74 @@ describe("CortexSidecar", () => { await sidecar.stop(); // second call expect(sidecar.status).toBe("stopped"); }); + + it("passes AINGLE_JWT_SECRET and AINGLE_ADMIN_PASSWORD to spawned process", async () => { + mockState.healthReturnValues = [false, true]; + + const sidecar = new CortexSidecar({ + host: "127.0.0.1", + port: 9999, + autoStart: true, + binaryPath: "/usr/bin/fake-cortex", + }); + + await sidecar.start(); + + const spawnCall = mockState.spawnFn.mock.calls[0]; + const spawnOpts = spawnCall?.[2] as { env?: Record }; + expect(spawnOpts.env).toBeDefined(); + expect(spawnOpts.env!.AINGLE_JWT_SECRET).toBeTruthy(); + expect(spawnOpts.env!.AINGLE_ADMIN_PASSWORD).toBeTruthy(); + expect(spawnOpts.env!.AINGLE_ADMIN_PASSWORD!.length).toBeGreaterThanOrEqual(12); + + await sidecar.stop(); + }); +}); + +describe("ensureCortexSecrets", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.AINGLE_JWT_SECRET; + delete process.env.AINGLE_ADMIN_PASSWORD; + mockState.existsSyncFn.mockReturnValue(true); + }); + + afterEach(() => { + delete process.env.AINGLE_JWT_SECRET; + delete process.env.AINGLE_ADMIN_PASSWORD; + }); + + it("uses env vars when both are set", () => { + process.env.AINGLE_JWT_SECRET = "env-jwt-secret"; + process.env.AINGLE_ADMIN_PASSWORD = "env-admin-password"; + + const secrets = ensureCortexSecrets(); + + expect(secrets.jwtSecret).toBe("env-jwt-secret"); + expect(secrets.adminPassword).toBe("env-admin-password"); + }); + + it("reads persisted file when env vars are not set", () => { + mockState.readFileSyncFn.mockReturnValue( + '{"jwtSecret":"persisted-jwt","adminPassword":"persisted-admin"}', + ); + + const secrets = ensureCortexSecrets(); + + expect(secrets.jwtSecret).toBe("persisted-jwt"); + expect(secrets.adminPassword).toBe("persisted-admin"); + }); + + it("generates and persists secrets when nothing exists", () => { + mockState.existsSyncFn.mockReturnValue(false); + + const secrets = ensureCortexSecrets(); + + expect(secrets.jwtSecret).toBeTruthy(); + expect(secrets.adminPassword).toBeTruthy(); + expect(secrets.adminPassword.length).toBeGreaterThanOrEqual(12); + expect(mockState.writeFileSyncFn).toHaveBeenCalled(); + const writeCall = mockState.writeFileSyncFn.mock.calls[0]; + expect(writeCall?.[2]).toEqual(expect.objectContaining({ mode: 0o600 })); + }); }); diff --git a/extensions/memory-semantic/cortex-sidecar.ts b/extensions/memory-semantic/cortex-sidecar.ts index 17b42297..4b204397 100644 --- a/extensions/memory-semantic/cortex-sidecar.ts +++ b/extensions/memory-semantic/cortex-sidecar.ts @@ -4,11 +4,15 @@ * - Spawn `aingle-cortex` if autoStart is true and binaryPath is set * - Poll /health with exponential backoff (max 10 s) * - Skip spawn when Cortex is already reachable on the configured port + * - Auto-generate and persist AINGLE_JWT_SECRET / AINGLE_ADMIN_PASSWORD * - Graceful shutdown via SIGTERM */ +import { randomBytes } from "node:crypto"; import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { locateCortexBinary, getCortexBinaryVersion } from "../shared/cortex-binary-locator.js"; import { REQUIRED_CORTEX_VERSION } from "../shared/cortex-version.js"; import type { CortexConfig } from "./config.js"; @@ -21,7 +25,8 @@ export class CortexSidecar { private _status: SidecarStatus = "stopped"; private readonly client: CortexClient; private signalHandlers = new Map void>(); - private restartAttempted = false; + private restartCount = 0; + private static readonly MAX_RESTARTS = 3; constructor(private readonly config: CortexConfig) { this.client = new CortexClient(config); @@ -139,11 +144,17 @@ export class CortexSidecar { this._status = "starting"; const args = ["--host", this.config.host, "--port", String(this.config.port)]; + const secrets = ensureCortexSecrets(); try { this.process = spawn(binaryPath, args, { stdio: ["ignore", "pipe", "pipe"], detached: false, + env: { + ...process.env, + AINGLE_JWT_SECRET: secrets.jwtSecret, + AINGLE_ADMIN_PASSWORD: secrets.adminPassword, + }, }); } catch { this._status = "failed"; @@ -162,17 +173,23 @@ export class CortexSidecar { this.process = null; this.removeSignalHandlers(); - // Auto-restart on unexpected crash (one attempt only) - if (this._status === "failed" && !this.restartAttempted) { - this.restartAttempted = true; - console.warn("[cortex] sidecar crashed unexpectedly, attempting restart..."); - void this.spawn(binaryPath).then((ok) => { - if (ok) { - console.info("[cortex] sidecar restarted successfully"); - } else { - console.error("[cortex] sidecar restart failed"); - } - }); + // Auto-restart on unexpected crash (up to MAX_RESTARTS attempts) + if (this._status === "failed" && this.restartCount < CortexSidecar.MAX_RESTARTS) { + this.restartCount += 1; + const attempt = this.restartCount; + const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 10_000); + console.warn( + `[cortex] sidecar crashed, restart attempt ${attempt}/${CortexSidecar.MAX_RESTARTS} in ${delayMs}ms...`, + ); + setTimeout(() => { + void this.spawn(binaryPath).then((ok) => { + if (ok) { + console.info(`[cortex] sidecar restarted successfully (attempt ${attempt})`); + } else { + console.error(`[cortex] sidecar restart failed (attempt ${attempt})`); + } + }); + }, delayMs); } }); @@ -186,7 +203,7 @@ export class CortexSidecar { const healthy = await this.waitForHealthy(); if (healthy) { this._status = "running"; - this.restartAttempted = false; // reset for future crashes + this.restartCount = 0; // reset for future crashes // Register process signal handlers for graceful sidecar shutdown const cleanup = () => { @@ -223,3 +240,73 @@ export class CortexSidecar { function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +// --------------------------------------------------------------------------- +// Cortex secrets (AINGLE_JWT_SECRET + AINGLE_ADMIN_PASSWORD) +// +// Since Cortex 0.3.7, both env vars are required at startup. +// We auto-generate and persist them in ~/.mayros/cortex-secrets.json +// so they survive restarts and are not regenerated each time. +// If the env vars are already set externally, those values take precedence. +// --------------------------------------------------------------------------- + +type CortexSecrets = { jwtSecret: string; adminPassword: string }; + +const SECRETS_FILENAME = "cortex-secrets.json"; + +function resolveSecretsPath(): string { + const stateDir = join(homedir(), ".mayros"); + return join(stateDir, SECRETS_FILENAME); +} + +export function ensureCortexSecrets(): CortexSecrets { + // Env vars take precedence over persisted file + const envJwt = process.env.AINGLE_JWT_SECRET?.trim(); + const envAdmin = process.env.AINGLE_ADMIN_PASSWORD?.trim(); + + if (envJwt && envAdmin) { + return { jwtSecret: envJwt, adminPassword: envAdmin }; + } + + // Try to load from persisted file + const secretsPath = resolveSecretsPath(); + let persisted: Partial = {}; + + if (existsSync(secretsPath)) { + try { + const raw = readFileSync(secretsPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + if (typeof parsed.jwtSecret === "string") persisted.jwtSecret = parsed.jwtSecret; + if (typeof parsed.adminPassword === "string") persisted.adminPassword = parsed.adminPassword; + } catch { + // corrupted file — will regenerate + } + } + + const jwtSecret = envJwt || persisted.jwtSecret || randomBytes(48).toString("base64"); + const adminPassword = envAdmin || persisted.adminPassword || generatePassword(20); + + // Persist if we generated anything new + if (jwtSecret !== persisted.jwtSecret || adminPassword !== persisted.adminPassword) { + try { + mkdirSync(join(homedir(), ".mayros"), { recursive: true }); + writeFileSync(secretsPath, JSON.stringify({ jwtSecret, adminPassword }, null, 2), { + mode: 0o600, + }); + } catch { + // Non-fatal: secrets work for this session even if persistence fails + } + } + + return { jwtSecret, adminPassword }; +} + +function generatePassword(length: number): string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; + const bytes = randomBytes(length); + let result = ""; + for (let i = 0; i < length; i++) { + result += chars[bytes[i] % chars.length]; + } + return result; +} diff --git a/extensions/memory-semantic/index.test.ts b/extensions/memory-semantic/index.test.ts index 6ea1df0e..7f49b599 100644 --- a/extensions/memory-semantic/index.test.ts +++ b/extensions/memory-semantic/index.test.ts @@ -25,8 +25,8 @@ describe("semantic memory config", () => { expect(config).toBeDefined(); expect(config?.cortex?.host).toBe("127.0.0.1"); - expect(config?.cortex?.port).toBe(8080); - expect(config?.cortex?.autoStart).toBe(false); + expect(config?.cortex?.port).toBe(19090); + expect(config?.cortex?.autoStart).toBe(true); expect(config?.agentNamespace).toBe("mayros"); expect(config?.fallbackToMarkdown).toBe(true); expect(config?.autoConsolidate).toBe(true); @@ -407,7 +407,7 @@ describe("cortex client", () => { // but we can verify construction doesn't throw const client = new CortexClient({ host: "localhost", - port: 8080, + port: 19090, autoStart: false, }); @@ -496,7 +496,7 @@ describe("titans client", () => { const client = new TitansClient({ host: "localhost", - port: 8080, + port: 19090, autoStart: false, }); diff --git a/extensions/memory-semantic/index.ts b/extensions/memory-semantic/index.ts index 772ec340..7058c124 100644 --- a/extensions/memory-semantic/index.ts +++ b/extensions/memory-semantic/index.ts @@ -138,6 +138,16 @@ const semanticMemoryPlugin = { onUnhealthy: () => { cortexAvailable = false; api.logger.warn("memory-semantic: Cortex unreachable — now unhealthy"); + // Auto-restart sidecar if it crashed + if (cfg.cortex.autoStart && (sidecar.status === "failed" || sidecar.status === "stopped")) { + api.logger.info("memory-semantic: attempting Cortex sidecar restart..."); + void sidecar.start().then((ok) => { + if (ok) { + cortexAvailable = true; + api.logger.info("memory-semantic: Cortex sidecar restarted successfully"); + } + }); + } }, }); @@ -153,6 +163,28 @@ const semanticMemoryPlugin = { return cortexAvailable; } + /** Mark Cortex as unavailable on network failures so the health monitor can trigger recovery. */ + function markCortexUnavailable(): void { + if (cortexAvailable) { + cortexAvailable = false; + api.logger.warn("memory-semantic: Cortex call failed — marking unavailable"); + } + } + + /** Wrap a Cortex operation: returns the result on success, null on connection error (after marking unavailable). */ + async function withCortex(fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + markCortexUnavailable(); + const msg = err instanceof Error ? err.message : String(err); + if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|fetch failed|socket hang up/i.test(msg)) { + return null; + } + throw err; + } + } + // ======================================================================== // Fallback helpers — read markdown memory files // ======================================================================== @@ -246,8 +278,26 @@ const semanticMemoryPlugin = { source: "user", }); - for (const t of triples) { - await client.createTriple(t); + const writeResult = await withCortex(async () => { + for (const t of triples) { + await client.createTriple(t); + } + return true; + }); + if (writeResult === null) { + // Queue writes for replay when Cortex recovers + for (const t of triples) { + writeQueue.push({ type: "createTriple", payload: t }); + } + return { + content: [ + { + type: "text", + text: `Memory queued (Cortex temporarily unavailable). Will be saved when Cortex recovers.`, + }, + ], + details: { action: "queued", id: memId, tripleCount: triples.length }, + }; } return { @@ -322,21 +372,57 @@ const semanticMemoryPlugin = { // Pattern query: find all memory nodes owned by this agent const agentNode = agentSubject(ns, agentId); - const result = await client.patternQuery({ - predicate: predicate(ns, "ownedBy"), - object: { node: agentNode }, - limit: limit * 10, // over-fetch to filter locally - }); + const queryResult = await withCortex(() => + client.patternQuery({ + predicate: predicate(ns, "ownedBy"), + object: { node: agentNode }, + limit: limit * 10, // over-fetch to filter locally + }), + ); + + if (queryResult === null) { + // Cortex crashed mid-call — try markdown fallback + if (cfg.fallbackToMarkdown) { + const entries = await readMarkdownMemories(); + const lower = query.toLowerCase(); + const matched = entries + .filter((e) => { + if (category && e.category !== category) return false; + return e.text.toLowerCase().includes(lower); + }) + .slice(0, limit); + const text = matched.map((e, i) => `${i + 1}. [${e.category}] ${e.text}`).join("\n"); + return { + content: [ + { + type: "text", + text: + matched.length > 0 + ? `Found ${matched.length} memories (markdown fallback):\n\n${text}` + : "No relevant memories found (markdown fallback).", + }, + ], + details: { count: matched.length, source: "markdown" }, + }; + } + return { + content: [{ type: "text", text: "Cortex temporarily unavailable." }], + details: { count: 0, reason: "cortex_connection_lost" }, + }; + } // Collect memory subjects - const memSubjects = result.matches.map((t) => t.subject); + const memSubjects = queryResult.matches.map((t) => t.subject); // For each memory, fetch its triples and reconstruct const memories: SemanticMemoryEntry[] = []; const lower = query.toLowerCase(); for (const subj of memSubjects) { - const tripleResult = await client.listTriples({ subject: subj, limit: 20 }); + const tripleResult = await withCortex(() => + client.listTriples({ subject: subj, limit: 20 }), + ); + if (tripleResult === null) break; // connection lost mid-iteration const entry = triplesToMemory(tripleResult.triples); if (!entry) continue; if (category && entry.category !== category) continue; @@ -1681,10 +1767,48 @@ const semanticMemoryPlugin = { } }); - // Session start: contextual awareness notifications - api.on("session_start", async () => { + // Session start: register session node in Cortex + contextual awareness + api.on("session_start", async (event, ctx) => { + // 1. Create session node in Cortex + if (await ensureCortex()) { + const sessionSubject = `${ns}:session:${event.sessionId}`; + const now = new Date().toISOString(); + const sessionTriples = [ + { subject: sessionSubject, predicate: predicate(ns, "type"), object: "session" }, + { + subject: sessionSubject, + predicate: predicate(ns, "sessionId"), + object: event.sessionId, + }, + { subject: sessionSubject, predicate: predicate(ns, "startedAt"), object: now }, + { + subject: sessionSubject, + predicate: predicate(ns, "ownedBy"), + object: { node: agentSubject(ns, ctx.agentId ?? agentId) }, + }, + ...(event.resumedFrom + ? [ + { + subject: sessionSubject, + predicate: predicate(ns, "resumedFrom"), + object: `${ns}:session:${event.resumedFrom}`, + }, + ] + : []), + ]; + for (const t of sessionTriples) { + try { + await client.createTriple(t as Parameters[0]); + } catch { + writeQueue.push({ type: "createTriple", payload: t }); + } + } + api.logger.info(`memory-semantic: session node created (${event.sessionId})`); + } + + // 2. Contextual awareness notifications if (!cfg.contextualAwareness.enabled || !cfg.contextualAwareness.showOnSessionStart) return; - if (!(await ensureCortex())) return; + if (!cortexAvailable) return; try { const notifications = await contextualAwareness.gatherNotifications(agentId); @@ -1698,8 +1822,24 @@ const semanticMemoryPlugin = { } }); - // Session end: create a memory checkpoint for resumability - api.on("session_end", async () => { + // Session end: mark session closed in Cortex + memory checkpoint + api.on("session_end", async (event, ctx) => { + // 1. Mark session node as ended in Cortex + if (await ensureCortex()) { + const sessionSubject = `${ns}:session:${event.sessionId}`; + const endTriple = { + subject: sessionSubject, + predicate: predicate(ns, "endedAt"), + object: new Date().toISOString(), + }; + try { + await client.createTriple(endTriple); + } catch { + writeQueue.push({ type: "createTriple", payload: endTriple }); + } + } + + // 2. Create memory checkpoint for resumability if (!(await ensureTitans())) return; try { const result = await titansClient.createCheckpoint(`session-${Date.now()}`); @@ -1709,6 +1849,112 @@ const semanticMemoryPlugin = { } }); + // ======================================================================== + // Gateway Methods + // ======================================================================== + + api.registerGatewayMethod("cortex.status", async ({ respond }) => { + const healthy = await client.isHealthy(); + let version: string | null = null; + let uptime: number | null = null; + let triples: number | null = null; + let subjects: number | null = null; + if (healthy) { + try { + const s = await client.stats(); + version = s.server?.version ?? null; + uptime = s.server?.uptime_seconds ?? null; + triples = s.graph?.triple_count ?? null; + subjects = s.graph?.subject_count ?? null; + } catch { + /* stats endpoint may not be available */ + } + } + respond(true, { + status: healthy ? "online" : "offline", + sidecar: sidecar.status, + endpoint: `${cfg.cortex.host}:${cfg.cortex.port}`, + autoStart: cfg.cortex.autoStart, + version, + uptime, + triples, + subjects, + pendingWrites: writeQueue.getStats().queued, + }); + }); + + api.registerGatewayMethod("cortex.reconnect", async ({ respond }) => { + if (sidecar.status === "running" || sidecar.status === "starting") { + await sidecar.stop(); + } + const started = await sidecar.start(); + cortexAvailable = started; + if (started) { + healthMonitor.start(); + void writeQueue.drain().then((n) => { + if (n > 0) + api.logger.info(`memory-semantic: replayed ${n} pending writes after reconnect`); + }); + } + respond(true, { + success: started, + status: started ? "online" : "failed", + sidecar: sidecar.status, + }); + }); + + api.registerGatewayMethod("cortex.triples", async ({ params, respond }) => { + if (!cortexAvailable) { + respond(false, { error: "Cortex is offline" }); + return; + } + try { + const p = (params ?? {}) as Record; + const result = await client.listTriples({ + subject: typeof p.subject === "string" ? p.subject : undefined, + predicate: typeof p.predicate === "string" ? p.predicate : undefined, + object: typeof p.object === "string" ? p.object : undefined, + limit: typeof p.limit === "number" ? p.limit : 50, + offset: typeof p.offset === "number" ? p.offset : 0, + }); + respond(true, { triples: result.triples, total: result.total }); + } catch (err) { + respond(false, { error: String(err) }); + } + }); + + api.registerGatewayMethod("cortex.subjects", async ({ params, respond }) => { + if (!cortexAvailable) { + respond(false, { error: "Cortex is offline" }); + return; + } + try { + const p = (params ?? {}) as Record; + const result = await client.listSubjects({ + limit: typeof p.limit === "number" ? p.limit : 200, + }); + respond(true, { subjects: result.subjects, total: result.total }); + } catch (err) { + respond(false, { error: String(err) }); + } + }); + + api.registerGatewayMethod("cortex.predicates", async ({ params, respond }) => { + if (!cortexAvailable) { + respond(false, { error: "Cortex is offline" }); + return; + } + try { + const p = (params ?? {}) as Record; + const result = await client.listPredicates({ + limit: typeof p.limit === "number" ? p.limit : 200, + }); + respond(true, { predicates: result.predicates, total: result.total }); + } catch (err) { + respond(false, { error: String(err) }); + } + }); + // ======================================================================== // Service // ======================================================================== diff --git a/extensions/memory-semantic/package.json b/extensions/memory-semantic/package.json index 087430a7..8d8ce9b9 100644 --- a/extensions/memory-semantic/package.json +++ b/extensions/memory-semantic/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-memory-semantic", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros semantic memory plugin via AIngle Cortex sidecar (RDF triples, identity graph, Titans STM/LTM)", "type": "module", diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index 74b7d262..118fff9a 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-minimax-portal-auth", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 14d2f1c2..2b846ace 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-msteams", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros Microsoft Teams channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index ab27de81..b88a6773 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-nextcloud-talk", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros Nextcloud Talk channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 48bc0c5f..e7d440df 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-nostr", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros Nostr channel plugin for NIP-04 encrypted DMs", "license": "MIT", "type": "module", diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index d64e6acc..3850773a 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-open-prose", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/semantic-observability/config.ts b/extensions/semantic-observability/config.ts index 8c7151eb..e4e2dc7b 100644 --- a/extensions/semantic-observability/config.ts +++ b/extensions/semantic-observability/config.ts @@ -34,7 +34,7 @@ export type ObservabilityConfig = { const DEFAULT_NAMESPACE = "mayros"; const DEFAULT_HOST = "127.0.0.1"; -const DEFAULT_PORT = 8080; +const DEFAULT_PORT = 19090; const DEFAULT_FLUSH_INTERVAL_MS = 5000; const DEFAULT_MAX_CHECKPOINTS = 50; const DEFAULT_MAX_FORKS = 10; @@ -108,8 +108,11 @@ function parseSessionConfig(raw: unknown): SessionConfig { export const observabilityConfigSchema = { parse(value: unknown): ObservabilityConfig { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("observability config required"); + if (value === null || value === undefined) { + value = {}; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("observability config must be an object"); } const cfg = value as Record; assertAllowedKeys( diff --git a/extensions/semantic-observability/index.test.ts b/extensions/semantic-observability/index.test.ts index 27e22149..1a4ae303 100644 --- a/extensions/semantic-observability/index.test.ts +++ b/extensions/semantic-observability/index.test.ts @@ -25,7 +25,7 @@ describe("observability config", () => { expect(config).toBeDefined(); expect(config?.cortex?.host).toBe("127.0.0.1"); - expect(config?.cortex?.port).toBe(8080); + expect(config?.cortex?.port).toBe(19090); expect(config?.agentNamespace).toBe("mayros"); expect(config?.tracing?.enabled).toBe(true); expect(config?.tracing?.captureToolCalls).toBe(true); diff --git a/extensions/semantic-observability/package.json b/extensions/semantic-observability/package.json index 9790e9dd..b1ff2ca0 100644 --- a/extensions/semantic-observability/package.json +++ b/extensions/semantic-observability/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-semantic-observability", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros semantic observability plugin — structured tracing of agent decisions as RDF events", "type": "module", diff --git a/extensions/semantic-skills/config.ts b/extensions/semantic-skills/config.ts index 689d224c..bcaa5980 100644 --- a/extensions/semantic-skills/config.ts +++ b/extensions/semantic-skills/config.ts @@ -36,7 +36,7 @@ export type SemanticSkillsConfig = { const DEFAULT_NAMESPACE = "mayros"; const DEFAULT_HOST = "127.0.0.1"; -const DEFAULT_PORT = 8080; +const DEFAULT_PORT = 19090; function clampInt(raw: unknown, min: number, max: number, defaultVal: number): number { if (typeof raw !== "number") return defaultVal; @@ -105,8 +105,11 @@ function parseVerificationConfig(raw: unknown): VerificationConfig { export const semanticSkillsConfigSchema = { parse(value: unknown): SemanticSkillsConfig { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("semantic skills config required"); + if (value === null || value === undefined) { + value = {}; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("semantic skills config must be an object"); } const cfg = value as Record; assertAllowedKeys( diff --git a/extensions/semantic-skills/index.test.ts b/extensions/semantic-skills/index.test.ts index d04c8d62..aa6f0509 100644 --- a/extensions/semantic-skills/index.test.ts +++ b/extensions/semantic-skills/index.test.ts @@ -21,7 +21,7 @@ describe("semanticSkillsConfigSchema", () => { it("parses minimal config", () => { const cfg = semanticSkillsConfigSchema.parse({}); expect(cfg.cortex.host).toBe("127.0.0.1"); - expect(cfg.cortex.port).toBe(8080); + expect(cfg.cortex.port).toBe(19090); expect(cfg.agentNamespace).toBe("mayros"); expect(cfg.skillSandbox.maxGraphQueries).toBe(50); expect(cfg.skillSandbox.maxAssertions).toBe(20); diff --git a/extensions/semantic-skills/mayros.plugin.json b/extensions/semantic-skills/mayros.plugin.json index 9cb10326..be104cbe 100644 --- a/extensions/semantic-skills/mayros.plugin.json +++ b/extensions/semantic-skills/mayros.plugin.json @@ -8,7 +8,7 @@ "type": "object", "properties": { "host": { "type": "string", "default": "127.0.0.1" }, - "port": { "type": "integer", "default": 8080 }, + "port": { "type": "integer", "default": 19090 }, "authToken": { "type": "string" } } }, diff --git a/extensions/semantic-skills/package.json b/extensions/semantic-skills/package.json index c18f346b..4ae1368e 100644 --- a/extensions/semantic-skills/package.json +++ b/extensions/semantic-skills/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-semantic-skills", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros semantic skills plugin — graph-aware skills with PoL assertions, ZK proofs, and permission gating", "type": "module", diff --git a/extensions/shared/cortex-client.test.ts b/extensions/shared/cortex-client.test.ts index 491bc5ff..b8511d54 100644 --- a/extensions/shared/cortex-client.test.ts +++ b/extensions/shared/cortex-client.test.ts @@ -59,7 +59,7 @@ describe("parseCortexConfig", () => { it("returns defaults for undefined input", () => { const cfg = parseCortexConfig(undefined); expect(cfg.host).toBe("127.0.0.1"); - expect(cfg.port).toBe(8080); + expect(cfg.port).toBe(19090); expect(cfg.authToken).toBeUndefined(); expect(cfg.resilience).toBeUndefined(); expect(cfg.strictVersionCheck).toBe(false); @@ -143,7 +143,7 @@ describe("CortexClient", () => { let client: CortexClient; beforeEach(() => { - client = new CortexClient({ host: "127.0.0.1", port: 8080 }); + client = new CortexClient({ host: "127.0.0.1", port: 19090 }); // Mock global fetch vi.stubGlobal("fetch", vi.fn()); }); @@ -162,7 +162,7 @@ describe("CortexClient", () => { } it("builds correct base URL", () => { - expect(client.baseUrl).toBe("http://127.0.0.1:8080"); + expect(client.baseUrl).toBe("http://127.0.0.1:19090"); }); it("createTriple sends POST to /api/v1/triples", async () => { @@ -173,7 +173,7 @@ describe("CortexClient", () => { expect(result).toEqual(triple); const call = (fetch as ReturnType).mock.calls[0]; - expect(call[0]).toBe("http://127.0.0.1:8080/api/v1/triples"); + expect(call[0]).toBe("http://127.0.0.1:19090/api/v1/triples"); expect(JSON.parse(call[1].body)).toEqual({ subject: "s", predicate: "p", object: "o" }); }); @@ -193,7 +193,7 @@ describe("CortexClient", () => { await client.patternQuery({ predicate: "p", limit: 5 }); const call = (fetch as ReturnType).mock.calls[0]; - expect(call[0]).toBe("http://127.0.0.1:8080/api/v1/query"); + expect(call[0]).toBe("http://127.0.0.1:19090/api/v1/query"); }); it("deleteTriple sends DELETE", async () => { @@ -299,7 +299,7 @@ describe("CortexClient", () => { ]); const call = (fetch as ReturnType).mock.calls[0]; - expect(call[0]).toBe("http://127.0.0.1:8080/api/v1/events"); + expect(call[0]).toBe("http://127.0.0.1:19090/api/v1/events"); }); it("getEvents sends GET with query params", async () => { @@ -368,13 +368,13 @@ describe("CortexClient", () => { describe("type compatibility", () => { it("CortexClient satisfies CortexClientLike", () => { - const client = new CortexClient({ host: "localhost", port: 8080 }); + const client = new CortexClient({ host: "localhost", port: 19090 }); const _like: CortexClientLike = client; expect(_like).toBeDefined(); }); it("CortexClient satisfies CortexLike", () => { - const client = new CortexClient({ host: "localhost", port: 8080 }); + const client = new CortexClient({ host: "localhost", port: 19090 }); const _like: CortexLike = client; expect(_like).toBeDefined(); }); diff --git a/extensions/shared/cortex-config.ts b/extensions/shared/cortex-config.ts index 5e2d865e..e7d0a29c 100644 --- a/extensions/shared/cortex-config.ts +++ b/extensions/shared/cortex-config.ts @@ -26,7 +26,7 @@ export type CortexConfig = { // ============================================================================ const DEFAULT_HOST = "127.0.0.1"; -const DEFAULT_PORT = 8080; +const DEFAULT_PORT = 19090; export function assertAllowedKeys( value: Record, @@ -52,7 +52,7 @@ export function resolveEnvVars(value: string): string { * Parse a raw config object into a validated `CortexConfig`. * * Accepted keys: host, port, binaryPath, autoStart, authToken, resilience. - * Unknown keys throw. Defaults: 127.0.0.1:8080, no auth. + * Unknown keys throw. Defaults: 127.0.0.1:19090, no auth. */ export function parseCortexConfig(raw: unknown): CortexConfig { const cortex = (raw ?? {}) as Record; @@ -80,7 +80,7 @@ export function parseCortexConfig(raw: unknown): CortexConfig { } const binaryPath = typeof cortex.binaryPath === "string" ? cortex.binaryPath : undefined; - const autoStart = cortex.autoStart === true; + const autoStart = cortex.autoStart !== false; const authToken = typeof cortex.authToken === "string" ? resolveEnvVars(cortex.authToken) : undefined; const resilience = parseResilienceConfig(cortex.resilience); diff --git a/extensions/shared/cortex-update-check.ts b/extensions/shared/cortex-update-check.ts index 5de3c3df..37794e66 100644 --- a/extensions/shared/cortex-update-check.ts +++ b/extensions/shared/cortex-update-check.ts @@ -8,8 +8,8 @@ */ import { execFileSync } from "node:child_process"; -import { createWriteStream, existsSync } from "node:fs"; -import { mkdir, chmod, unlink } from "node:fs/promises"; +import { createWriteStream, existsSync, readdirSync } from "node:fs"; +import { mkdir, chmod, unlink, rename } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { Readable } from "node:stream"; @@ -56,7 +56,7 @@ function semverLessThan(a: string, b: string): boolean { // Platform helpers (same logic as scripts/install-cortex.ts) // --------------------------------------------------------------------------- -const REPO_OWNER = "apilium"; +const REPO_OWNER = "ApiliumCode"; const REPO_NAME = "aingle"; function getPlatformKey(): string { @@ -170,7 +170,18 @@ export async function installOrUpdateCortex( const binaryPath = join(installDir, binaryName); if (!existsSync(binaryPath)) { - throw new Error(`Binary not found after extraction: ${binaryPath}`); + // The archive may contain a platform-suffixed binary (e.g. aingle-cortex-macos-aarch64). + // Find it and rename to the expected name. + const baseName = binaryName.replace(/\.exe$/, ""); + const candidates = readdirSync(installDir).filter( + (f) => f.startsWith(baseName + "-") && !f.endsWith(".tar.gz") && !f.endsWith(".zip"), + ); + if (candidates.length === 1) { + await rename(join(installDir, candidates[0]), binaryPath); + log(`Renamed ${candidates[0]} → ${binaryName}`); + } else { + throw new Error(`Binary not found after extraction: ${binaryPath}`); + } } if (process.platform !== "win32") { diff --git a/extensions/shared/cortex-version.ts b/extensions/shared/cortex-version.ts index e77cb971..b1f733bc 100644 --- a/extensions/shared/cortex-version.ts +++ b/extensions/shared/cortex-version.ts @@ -5,4 +5,4 @@ * features or API changes. `mayros update` and the sidecar startup * check will compare the installed binary against this value. */ -export const REQUIRED_CORTEX_VERSION = "0.2.6"; +export const REQUIRED_CORTEX_VERSION = "0.3.7"; diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 539bd80c..509348e3 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-signal", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Signal channel plugin", "type": "module", diff --git a/extensions/skill-hub/config.ts b/extensions/skill-hub/config.ts index 02239dff..467f93c6 100644 --- a/extensions/skill-hub/config.ts +++ b/extensions/skill-hub/config.ts @@ -42,7 +42,7 @@ export type SkillHubConfig = { const DEFAULT_HUB_URL = "https://hub.apilium.com"; const DEFAULT_HOST = "127.0.0.1"; -const DEFAULT_PORT = 8080; +const DEFAULT_PORT = 19090; const DEFAULT_NAMESPACE = "mayros"; const DEFAULT_KEYS_DIR = "~/.mayros/keys"; diff --git a/extensions/skill-hub/index.test.ts b/extensions/skill-hub/index.test.ts index 21912c57..2474dcbc 100644 --- a/extensions/skill-hub/index.test.ts +++ b/extensions/skill-hub/index.test.ts @@ -29,7 +29,7 @@ describe("skillHubConfigSchema", () => { const cfg = skillHubConfigSchema.parse({}); expect(cfg.hubUrl).toBe("https://hub.apilium.com"); expect(cfg.cortex.host).toBe("127.0.0.1"); - expect(cfg.cortex.port).toBe(8080); + expect(cfg.cortex.port).toBe(19090); expect(cfg.agentNamespace).toBe("mayros"); expect(cfg.verification.requireSignature).toBe(true); expect(cfg.verification.polValidation).toBe(true); diff --git a/extensions/skill-hub/mayros.plugin.json b/extensions/skill-hub/mayros.plugin.json index 49bdfd0e..72b09bf8 100644 --- a/extensions/skill-hub/mayros.plugin.json +++ b/extensions/skill-hub/mayros.plugin.json @@ -9,7 +9,7 @@ "type": "object", "properties": { "host": { "type": "string", "default": "127.0.0.1" }, - "port": { "type": "integer", "default": 8080 }, + "port": { "type": "integer", "default": 19090 }, "authToken": { "type": "string" } } }, diff --git a/extensions/skill-hub/package.json b/extensions/skill-hub/package.json index 4c67dee1..1a879499 100644 --- a/extensions/skill-hub/package.json +++ b/extensions/skill-hub/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-skill-hub", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Apilium Hub marketplace — publish, install, sign, and verify semantic skills", "type": "module", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 8553ec46..e3d5e31d 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-slack", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Slack channel plugin", "type": "module", diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index acf94eaa..20e88440 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-telegram", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Telegram channel plugin", "type": "module", diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 2f927a3e..50ce88b9 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-tlon", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Tlon/Urbit channel plugin", "type": "module", diff --git a/extensions/token-economy/package.json b/extensions/token-economy/package.json index bc9fff9d..b954f177 100644 --- a/extensions/token-economy/package.json +++ b/extensions/token-economy/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-token-economy", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros token economy plugin — per-session cost tracking, configurable budgets with soft-stop, and prompt-level memoization", "type": "module", diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 50bb7849..682b7f9c 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-twitch", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros Twitch channel plugin", "type": "module", diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 16798bca..6ec5c104 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-voice-call", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros voice-call plugin", "license": "MIT", "type": "module", diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 9e514a32..01e90999 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-whatsapp", - "version": "0.1.4", + "version": "0.1.6", "private": true, "description": "Mayros WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index da765b46..34962394 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-zalo", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros Zalo channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 61246f40..ed1dba97 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-zalouser", - "version": "0.1.4", + "version": "0.1.6", "description": "Mayros Zalo Personal Account plugin via zca-cli", "license": "MIT", "type": "module", diff --git a/package.json b/package.json index bbb32262..bf85274c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros", - "version": "0.1.5", + "version": "0.1.6", "description": "Multi-channel AI agent framework with knowledge graph, MCP support, and coding CLI", "keywords": [ "agent", diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index bb7f5f3d..13e1ccbc 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -15,7 +15,7 @@ import { join } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -const REPO = "apilium/aingle"; +const REPO = "ApiliumCode/aingle"; const INSTALL_DIR = join(homedir(), ".mayros", "bin"); const IS_WIN = platform() === "win32"; const BINARY_NAME = IS_WIN ? "aingle-cortex.exe" : "aingle-cortex"; diff --git a/src/agents/tools/web-fetch.response-limit.test.ts b/src/agents/tools/web-fetch.response-limit.test.ts index 9b246b8a..25b6917c 100644 --- a/src/agents/tools/web-fetch.response-limit.test.ts +++ b/src/agents/tools/web-fetch.response-limit.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../../infra/net/ssrf.js"; import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; import { createBaseWebFetchToolConfig, @@ -10,6 +11,18 @@ import { createWebFetchTool } from "./web-tools.js"; const baseToolConfig = createBaseWebFetchToolConfig({ maxResponseBytes: 1024 }); installWebFetchSsrfHarness(); +beforeEach(() => { + vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = ["93.184.216.34"]; + return { + hostname: normalized, + addresses, + lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), + }; + }); +}); + describe("web_fetch response size limits", () => { it("caps response bytes and does not hang on endless streams", async () => { const chunk = new TextEncoder().encode("
hi
"); diff --git a/src/agents/tools/web-fetch.test-harness.ts b/src/agents/tools/web-fetch.test-harness.ts index c86a028e..5f4f2c43 100644 --- a/src/agents/tools/web-fetch.test-harness.ts +++ b/src/agents/tools/web-fetch.test-harness.ts @@ -11,6 +11,9 @@ export function installWebFetchSsrfHarness() { vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) => resolvePinnedHostname(hostname, lookupMock), ); + vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation((hostname) => + resolvePinnedHostname(hostname, lookupMock), + ); }); afterEach(() => { diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index e8e2b9f6..10032158 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -1,6 +1,7 @@ import { createServer } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type WebSocket, WebSocketServer } from "ws"; +import * as ssrf from "../infra/net/ssrf.js"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { rawDataToString } from "../infra/ws.js"; import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js"; @@ -71,6 +72,12 @@ describe("cdp", () => { }); it("creates a target via the browser websocket", async () => { + const pinnedSpy = vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockResolvedValue({ + hostname: "example.com", + addresses: ["93.184.216.34"], + lookup: ssrf.createPinnedLookup({ hostname: "example.com", addresses: ["93.184.216.34"] }), + }); + const wsPort = await startWsServerWithMessages((msg, socket) => { if (msg.method !== "Target.createTarget") { return; @@ -93,6 +100,7 @@ describe("cdp", () => { }); expect(created.targetId).toBe("TARGET_123"); + pinnedSpy.mockRestore(); }); it("blocks private navigation targets by default", async () => { @@ -179,6 +187,12 @@ describe("cdp", () => { }); it("fails when /json/version omits webSocketDebuggerUrl", async () => { + const pinnedSpy = vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockResolvedValue({ + hostname: "example.com", + addresses: ["93.184.216.34"], + lookup: ssrf.createPinnedLookup({ hostname: "example.com", addresses: ["93.184.216.34"] }), + }); + const httpPort = await startVersionHttpServer({}); await expect( createTargetViaCdp({ @@ -186,6 +200,8 @@ describe("cdp", () => { url: "https://example.com", }), ).rejects.toThrow("CDP /json/version missing webSocketDebuggerUrl"); + + pinnedSpy.mockRestore(); }); it("captures an aria snapshot via CDP", async () => { diff --git a/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts b/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts index 07c2aa19..46d9e0d4 100644 --- a/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts +++ b/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import * as ssrf from "../infra/net/ssrf.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { getPwToolsCoreSessionMocks, @@ -29,6 +30,11 @@ describe("pw-tools-core.snapshot navigate guard", () => { }); it("navigates valid network URLs with clamped timeout", async () => { + const spy = vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockResolvedValue({ + hostname: "example.com", + ip: "93.184.216.34", + }); + const goto = vi.fn(async () => {}); setPwToolsCoreCurrentPage({ goto, @@ -43,5 +49,6 @@ describe("pw-tools-core.snapshot navigate guard", () => { expect(goto).toHaveBeenCalledWith("https://example.com", { timeout: 1000 }); expect(result.url).toBe("https://example.com"); + spy.mockRestore(); }); }); diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 2a826551..47772652 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -65,48 +65,50 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {} return `${line1}\n${line2}`; } -const MAYROS_ASCII = [ - "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", - "██░▄▄▄░██░▄▄░██░▄▄▄██░▀██░██░▄▄▀██░████░▄▄▀██░███░██", - "██░███░██░▀▀░██░▄▄▄██░█░█░██░█████░████░▀▀░██░█░█░██", - "██░▀▀▀░██░█████░▀▀▀██░██▄░██░▀▀▄██░▀▀░█░██░██▄▀▄▀▄██", - "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", - " ⚡🛡️ MAYROS ⚡🛡️ ", - " ", +// Mayros pixel avatar — gold(G) orange(O) dark(D) face +// Rendered with ANSI colors in formatCliBannerArt() +const MAYROS_AVATAR = [ + " ▄▄██████▄▄ ", + " ██▓▓▓▓▓▓▓▓▓▓██ ", + " ██▓▓▓▓▓▓▓▓▓▓▓▓▓▓██ ", + " ██▓▓██████████████▓▓██ ", + " ▄▄▓▓██ ██▓▓▄▄ ", + " ██░░██ ▓▓ ▓▓ ██░░██ ", + " ██░░██ ██░░██ ", + " ▀▀▓▓██ ╰━━╯ ██▓▓▀▀ ", + " ██▓▓██████████████▓▓██ ", + " ██░░░░░░░░░░░░░░██ ", + " ██░░░░░░░░░░██ ", + " ▀▀██████▀▀ ", ]; export function formatCliBannerArt(options: BannerOptions = {}): string { const rich = options.richTty ?? isRich(); + const label = " ⚡🛡️ MAYROS ⚡🛡️"; if (!rich) { - return MAYROS_ASCII.join("\n"); + return [...MAYROS_AVATAR, label].join("\n"); } - const colorChar = (ch: string) => { - if (ch === "█") { - return theme.accentBright(ch); - } - if (ch === "░") { - return theme.accentDim(ch); - } - if (ch === "▀") { - return theme.accent(ch); - } - return theme.muted(ch); + // Gold = ▓▓ parts (helmet/frame), Orange = ░░ (chin/sides) + // Dark = ██ inside face, █/▀/▄ = outline + const colorChar = (ch: string, _idx: number, line: string, charIdx: number) => { + // Detect context: ▓▓ = gold, ░░ = orange, ██ inside = dark face + if (ch === "▓") return theme.accentBright(ch); + if (ch === "░") return theme.accent(ch); + if (ch === "█" || ch === "▄" || ch === "▀") return theme.muted(ch); + if (ch === "╰" || ch === "━" || ch === "╯") return theme.accentBright(ch); + if (ch === " " && charIdx > 4 && charIdx < line.length - 4) return ch; + return ch; }; - const colored = MAYROS_ASCII.map((line) => { - if (line.includes("MAYROS")) { - return ( - theme.muted(" ") + - theme.accent("⚡🛡️") + - theme.info(" MAYROS ") + - theme.accent("⚡🛡️") - ); - } - return splitGraphemes(line).map(colorChar).join(""); + const colored = MAYROS_AVATAR.map((line) => { + return splitGraphemes(line) + .map((ch, idx) => colorChar(ch, idx, line, idx)) + .join(""); }); - - return colored.join("\n"); + const labelLine = + theme.muted(" ") + theme.accent("⚡🛡️") + theme.info(" MAYROS ") + theme.accent("⚡🛡️"); + return [...colored, labelLine].join("\n"); } export function emitCliBanner(version: string, options: BannerOptions = {}) { diff --git a/src/cli/clawbot-cli.ts b/src/cli/clawbot-cli.ts deleted file mode 100644 index b4c82a55..00000000 --- a/src/cli/clawbot-cli.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Command } from "commander"; -import { registerQrCli } from "./qr-cli.js"; - -export function registerClawbotCli(program: Command) { - const clawbot = program.command("clawbot").description("Legacy clawbot command aliases"); - registerQrCli(clawbot); -} diff --git a/src/cli/code-cli.test.ts b/src/cli/code-cli.test.ts index ce065963..c5f7b15c 100644 --- a/src/cli/code-cli.test.ts +++ b/src/cli/code-cli.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { runTui } = vi.hoisted(() => ({ runTui: vi.fn() })); const readConfigFileSnapshot = vi.hoisted(() => vi.fn()); const onboardCommand = vi.hoisted(() => vi.fn()); +const ensureServicesRunning = vi.hoisted(() => vi.fn()); const runtime = vi.hoisted(() => ({ log: vi.fn(), error: vi.fn(), @@ -25,6 +26,7 @@ vi.mock("node:fs", async (importOriginal) => { }); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot })); vi.mock("../commands/onboard.js", () => ({ onboardCommand })); +vi.mock("../infra/ensure-services.js", () => ({ ensureServicesRunning })); vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); vi.mock("./parse-timeout.js", () => ({ parseTimeoutMs: () => undefined })); vi.mock("../models/model-aliases.js", () => ({ @@ -72,6 +74,10 @@ describe("code cli", () => { vi.clearAllMocks(); runTui.mockResolvedValue(undefined); onboardCommand.mockResolvedValue(undefined); + ensureServicesRunning.mockResolvedValue({ + gateway: { ok: true }, + cortex: { ok: true }, + }); // Default: already onboarded so existing tests pass unchanged readConfigFileSnapshot.mockResolvedValue(onboardedSnapshot()); }); @@ -132,6 +138,10 @@ describe("code-cli zero-config setup redirect", () => { vi.clearAllMocks(); runTui.mockResolvedValue(undefined); onboardCommand.mockResolvedValue(undefined); + ensureServicesRunning.mockResolvedValue({ + gateway: { ok: true }, + cortex: { ok: true }, + }); }); it("skips onboard when already onboarded (wizard.lastRunAt present)", async () => { @@ -157,7 +167,6 @@ describe("code-cli zero-config setup redirect", () => { expect(onboardCommand).toHaveBeenCalledTimes(1); expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Welcome to Mayros!")); - expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Setup complete!")); expect(runTui).toHaveBeenCalledTimes(1); }); @@ -201,6 +210,10 @@ describe("code-cli new flags", () => { vi.clearAllMocks(); runTui.mockResolvedValue(undefined); onboardCommand.mockResolvedValue(undefined); + ensureServicesRunning.mockResolvedValue({ + gateway: { ok: true }, + cortex: { ok: true }, + }); readConfigFileSnapshot.mockResolvedValue(onboardedSnapshot()); }); diff --git a/src/cli/code-cli.ts b/src/cli/code-cli.ts index 35c80786..4f840390 100644 --- a/src/cli/code-cli.ts +++ b/src/cli/code-cli.ts @@ -65,9 +65,10 @@ export function registerCodeCli(program: Command) { ); return; } - defaultRuntime.log( - theme.accent("Setup complete!") + " " + theme.muted("Starting session..."), - ); + // Onboarding may have already launched the TUI (hatch flow). + // If the user passed --clean or no explicit flags, just clear and proceed. + // Clear screen after onboarding so the TUI starts clean. + process.stdout.write("\x1b[2J\x1b[H"); } const stateDir = resolveStateDir(); @@ -115,6 +116,27 @@ export function registerCodeCli(program: Command) { // We store them so TUI can access them if needed. } + // Ensure gateway and Cortex are running before launching the TUI + const { ensureServicesRunning } = await import("../infra/ensure-services.js"); + const freshSnapshot = await readConfigFileSnapshot(); + const ensureConfig = freshSnapshot.valid ? freshSnapshot.config : {}; + const services = await ensureServicesRunning({ + config: ensureConfig, + log: (msg) => defaultRuntime.log(theme.muted(msg)), + }); + + if (!services.gateway.ok) { + defaultRuntime.error( + theme.warn("Gateway could not be started.") + + (services.gateway.detail ? " " + theme.muted(services.gateway.detail) : ""), + ); + return; + } + + if (!services.cortex.ok && services.cortex.detail) { + defaultRuntime.log(theme.muted("Cortex: " + services.cortex.detail)); + } + const historyLimit = Number.parseInt(String(opts.historyLimit ?? "200"), 10); await runTui({ url: opts.url as string | undefined, diff --git a/src/cli/cortex-cli.test.ts b/src/cli/cortex-cli.test.ts new file mode 100644 index 00000000..38b95e49 --- /dev/null +++ b/src/cli/cortex-cli.test.ts @@ -0,0 +1,69 @@ +/** + * Tests for the `mayros cortex` CLI subcommand. + * + * Validates: + * - Registration as a subcli with correct name and subcommands + * - Status and reconnect subcommand definitions + */ + +import { describe, it, expect, vi } from "vitest"; +import { Command } from "commander"; +import { registerCortexCli } from "./cortex-cli.js"; + +describe("cortex CLI registration", () => { + it("registers 'cortex' command with subcommands", () => { + const program = new Command(); + registerCortexCli(program); + + const cortex = program.commands.find((c) => c.name() === "cortex"); + expect(cortex).toBeDefined(); + expect(cortex!.description()).toBe("Cortex sidecar — status, reconnect, and diagnostics"); + }); + + it("has status subcommand", () => { + const program = new Command(); + registerCortexCli(program); + + const cortex = program.commands.find((c) => c.name() === "cortex"); + const status = cortex?.commands.find((c) => c.name() === "status"); + expect(status).toBeDefined(); + expect(status!.description()).toContain("status"); + }); + + it("has reconnect subcommand", () => { + const program = new Command(); + registerCortexCli(program); + + const cortex = program.commands.find((c) => c.name() === "cortex"); + const reconnect = cortex?.commands.find((c) => c.name() === "reconnect"); + expect(reconnect).toBeDefined(); + expect(reconnect!.description()).toContain("restart"); + }); + + it("accepts cortex-host option", () => { + const program = new Command(); + registerCortexCli(program); + + const cortex = program.commands.find((c) => c.name() === "cortex"); + const hostOption = cortex?.options.find((o) => o.long === "--cortex-host"); + expect(hostOption).toBeDefined(); + }); + + it("accepts cortex-port option", () => { + const program = new Command(); + registerCortexCli(program); + + const cortex = program.commands.find((c) => c.name() === "cortex"); + const portOption = cortex?.options.find((o) => o.long === "--cortex-port"); + expect(portOption).toBeDefined(); + }); + + it("registers as subcommand with correct name in subcli entries", async () => { + const { getSubCliEntries } = await import("./program/register.subclis.js"); + const entries = getSubCliEntries(); + const cortexEntry = entries.find((e) => e.name === "cortex"); + expect(cortexEntry).toBeDefined(); + expect(cortexEntry!.hasSubcommands).toBe(true); + expect(cortexEntry!.description).toContain("Cortex"); + }); +}); diff --git a/src/cli/cortex-cli.ts b/src/cli/cortex-cli.ts new file mode 100644 index 00000000..690fba9a --- /dev/null +++ b/src/cli/cortex-cli.ts @@ -0,0 +1,173 @@ +/** + * `mayros cortex` — Cortex sidecar status and management. + * + * Subcommands: + * status — Check Cortex connectivity and stats + * reconnect — Restart the Cortex sidecar (via gateway or direct) + */ + +import type { Command } from "commander"; +import { parseCortexConfig } from "../../extensions/shared/cortex-config.js"; +import { CortexClient } from "../../extensions/shared/cortex-client.js"; +import { loadConfig } from "../config/config.js"; +import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js"; + +// ============================================================================ +// Cortex resolution +// ============================================================================ + +function resolveCortexClient(opts: { + cortexHost?: string; + cortexPort?: string; + cortexToken?: string; +}): CortexClient { + // 1. Try from user config file first (has correct defaults: 127.0.0.1:19090) + if ( + !opts.cortexHost && + !opts.cortexPort && + !process.env.CORTEX_HOST && + !process.env.CORTEX_PORT + ) { + try { + const cfg = loadConfig(); + const pluginCfg = cfg.plugins?.entries?.["memory-semantic"]?.config as + | { cortex?: { host?: string; port?: number; authToken?: string } } + | undefined; + if (pluginCfg?.cortex) { + return new CortexClient(parseCortexConfig(pluginCfg.cortex)); + } + } catch { + // Config not available — fall through to env/cli/defaults + } + } + + // 2. Build from CLI flags → env vars → parseCortexConfig defaults (19090) + const raw: Record = {}; + const host = opts.cortexHost ?? process.env.CORTEX_HOST; + if (host) raw.host = host; + const portStr = opts.cortexPort ?? process.env.CORTEX_PORT; + if (portStr) raw.port = Number.parseInt(portStr, 10); + const authToken = opts.cortexToken ?? process.env.CORTEX_AUTH_TOKEN; + if (authToken) raw.authToken = authToken; + + return new CortexClient(parseCortexConfig(raw)); +} + +// ============================================================================ +// Registration +// ============================================================================ + +export function registerCortexCli(program: Command) { + const cortex = program + .command("cortex") + .description("Cortex sidecar — status, reconnect, and diagnostics") + .option("--cortex-host ", "Cortex host (default: 127.0.0.1 or from config)") + .option("--cortex-port ", "Cortex port (default: 19090 or from config)") + .option("--cortex-token ", "Cortex auth token (or set CORTEX_AUTH_TOKEN)"); + + // ------------------------------------------------------------------ + // mayros cortex status + // ------------------------------------------------------------------ + const statusCmd = cortex + .command("status") + .description("Check Cortex sidecar status and connectivity"); + + addGatewayClientOptions(statusCmd); + + statusCmd.action(async (opts: GatewayRpcOpts) => { + const parent = cortex.opts(); + + // Try via gateway RPC first for richer info (sidecar status, pending writes) + try { + const res = (await callGatewayFromCli("cortex.status", opts)) as { + status: string; + sidecar: string; + endpoint: string; + autoStart: boolean; + version: string | null; + uptime: number | null; + triples: number | null; + subjects: number | null; + pendingWrites: number; + }; + console.log(`Endpoint: ${res.endpoint}`); + console.log(`Status: ${res.status === "online" ? "\u2713 ONLINE" : "\u2717 OFFLINE"}`); + console.log(`Sidecar: ${res.sidecar}`); + console.log(`Auto-start: ${res.autoStart ? "yes" : "no"}`); + if (res.version) console.log(`Version: ${res.version}`); + if (res.uptime != null) console.log(`Uptime: ${res.uptime}s`); + if (res.triples != null) console.log(`Triples: ${res.triples}`); + if (res.subjects != null) console.log(`Subjects: ${res.subjects}`); + if (res.pendingWrites > 0) console.log(`Pending writes: ${res.pendingWrites}`); + return; + } catch { + // Gateway not available — fall back to direct Cortex check + } + + // Direct check + const client = resolveCortexClient(parent); + try { + console.log(`Endpoint: ${client.baseUrl}`); + const healthy = await client.isHealthy(); + console.log(`Status: ${healthy ? "\u2713 ONLINE" : "\u2717 OFFLINE"}`); + if (healthy) { + try { + const stats = await client.stats(); + console.log(`Version: ${stats.server.version}`); + console.log(`Uptime: ${stats.server.uptime_seconds}s`); + console.log(`Triples: ${stats.graph.triple_count}`); + console.log(`Subjects: ${stats.graph.subject_count}`); + } catch { + // Stats endpoint may not be available + } + } + } finally { + client.destroy(); + } + }); + + // ------------------------------------------------------------------ + // mayros cortex reconnect + // ------------------------------------------------------------------ + const reconnectCmd = cortex + .command("reconnect") + .description("Attempt to restart the Cortex sidecar via the gateway"); + + addGatewayClientOptions(reconnectCmd); + + reconnectCmd.action(async (opts: GatewayRpcOpts) => { + const parent = cortex.opts(); + + // Try via gateway RPC first + try { + const res = (await callGatewayFromCli("cortex.reconnect", opts)) as { + success: boolean; + status: string; + sidecar: string; + }; + if (res.success) { + console.log(`Cortex reconnected (sidecar: ${res.sidecar})`); + } else { + console.log(`Reconnect failed (sidecar: ${res.sidecar})`); + process.exitCode = 1; + } + return; + } catch { + // Gateway not available — fall back to direct check + } + + console.log("Gateway unavailable, checking Cortex directly..."); + const client = resolveCortexClient(parent); + try { + const healthy = await client.isHealthy(); + if (healthy) { + console.log("Cortex is reachable directly"); + } else { + console.log("Cortex unreachable — start with: mayros gateway"); + process.exitCode = 1; + } + } finally { + client.destroy(); + } + }); +} diff --git a/src/cli/program/register.subclis.e2e.test.ts b/src/cli/program/register.subclis.e2e.test.ts index ac55f478..75d55234 100644 --- a/src/cli/program/register.subclis.e2e.test.ts +++ b/src/cli/program/register.subclis.e2e.test.ts @@ -68,7 +68,6 @@ describe("registerSubCliCommands", () => { const names = program.commands.map((cmd) => cmd.name()); expect(names).toContain("acp"); expect(names).toContain("gateway"); - expect(names).toContain("clawbot"); expect(registerAcpCli).not.toHaveBeenCalled(); }); diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 2756b62f..b3213e19 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -208,15 +208,6 @@ const entries: SubCliEntry[] = [ mod.registerQrCli(program); }, }, - { - name: "clawbot", - description: "Legacy clawbot command aliases", - hasSubcommands: true, - register: async (program) => { - const mod = await import("../clawbot-cli.js"); - mod.registerClawbotCli(program); - }, - }, { name: "pairing", description: "Secure DM pairing (approve inbound requests)", @@ -377,6 +368,15 @@ const entries: SubCliEntry[] = [ mod.registerTasksCli(program); }, }, + { + name: "cortex", + description: "Cortex sidecar — status, reconnect, and diagnostics", + hasSubcommands: true, + register: async (program) => { + const mod = await import("../cortex-cli.js"); + mod.registerCortexCli(program); + }, + }, { name: "diagnose", description: "Diagnostic checks — runtime, Cortex, plugins, security, config", diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index d85064bf..2d2edfd7 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -143,8 +143,8 @@ export async function runCli(argv: string[] = process.argv) { const { buildProgram } = await import("./program.js"); const program = buildProgram(normalizedArgv); - const { registerCodeCli } = await import("./code-cli.js"); - registerCodeCli(program); + const { registerSubCliByName } = await import("./program/register.subclis.js"); + await registerSubCliByName(program, "code"); await program.parseAsync([ ...normalizedArgv.slice(0, 2), "code", @@ -193,8 +193,8 @@ export async function runCli(argv: string[] = process.argv) { return; } - const { registerCodeCli } = await import("./code-cli.js"); - registerCodeCli(program); + const { registerSubCliByName } = await import("./program/register.subclis.js"); + await registerSubCliByName(program, "code"); await program.parseAsync([...parseArgv.slice(0, 2), "code", ...parseArgv.slice(2)]); return; } diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index eabca9d7..0455ca48 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -720,11 +720,7 @@ async function maybeMigrateLegacyConfig(): Promise { // missing config } - const legacyCandidates = [ - path.join(home, ".clawdbot", "clawdbot.json"), - path.join(home, ".moldbot", "moldbot.json"), - path.join(home, ".moltbot", "moltbot.json"), - ]; + const legacyCandidates: string[] = []; let legacyPath: string | null = null; for (const candidate of legacyCandidates) { diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 7157e20c..2b321b28 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -99,13 +99,19 @@ export function validateGatewayPasswordInput(value: unknown): string | undefined export function printWizardHeader(runtime: RuntimeEnv) { const header = [ - "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", - "██░▄▄▄░██░▄▄░██░▄▄▄██░▀██░██░▄▄▀██░████░▄▄▀██░███░██", - "██░███░██░▀▀░██░▄▄▄██░█░█░██░█████░████░▀▀░██░█░█░██", - "██░▀▀▀░██░█████░▀▀▀██░██▄░██░▀▀▄██░▀▀░█░██░██▄▀▄▀▄██", - "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", - " ⚡🛡️ MAYROS ⚡🛡️ ", - " ", + " ▄▄██████▄▄ ", + " ██▓▓▓▓▓▓▓▓▓▓██ ", + " ██▓▓▓▓▓▓▓▓▓▓▓▓▓▓██ ", + " ██▓▓██████████████▓▓██ ", + " ▄▄▓▓██ ██▓▓▄▄ ", + " ██░░██ ▓▓ ▓▓ ██░░██ ", + " ██░░██ ██░░██ ", + " ▀▀▓▓██ ╰━━╯ ██▓▓▀▀ ", + " ██▓▓██████████████▓▓██ ", + " ██░░░░░░░░░░░░░░██ ", + " ██░░░░░░░░░░██ ", + " ▀▀██████▀▀ ", + " ⚡🛡️ MAYROS ⚡🛡️ ", ].join("\n"); runtime.log(header); } diff --git a/src/compat/legacy-names.ts b/src/compat/legacy-names.ts index 85f1e467..d91f3b3a 100644 --- a/src/compat/legacy-names.ts +++ b/src/compat/legacy-names.ts @@ -1,15 +1,15 @@ export const PROJECT_NAME = "mayros" as const; -export const LEGACY_PROJECT_NAMES = ["openclaw", "clawdbot", "moltbot"] as const; +export const LEGACY_PROJECT_NAMES = [] as const; export const MANIFEST_KEY = PROJECT_NAME; export const LEGACY_MANIFEST_KEYS = LEGACY_PROJECT_NAMES; -export const LEGACY_PLUGIN_MANIFEST_FILENAMES = ["openclaw.plugin.json"] as const; +export const LEGACY_PLUGIN_MANIFEST_FILENAMES = [] as const; export const LEGACY_CANVAS_HANDLER_NAMES = [] as const; export const MACOS_APP_SOURCES_DIR = "apps/macos/Sources/Mayros" as const; -export const LEGACY_MACOS_APP_SOURCES_DIRS = ["apps/macos/Sources/OpenClaw"] as const; +export const LEGACY_MACOS_APP_SOURCES_DIRS = [] as const; diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index 5addc5a9..c80e74af 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -76,24 +76,7 @@ describe("state + config path candidates", () => { const home = "/home/test"; const resolvedHome = path.resolve(home); const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home); - const expected = [ - path.join(resolvedHome, ".mayros", "mayros.json"), - path.join(resolvedHome, ".mayros", "clawdbot.json"), - path.join(resolvedHome, ".mayros", "moldbot.json"), - path.join(resolvedHome, ".mayros", "moltbot.json"), - path.join(resolvedHome, ".clawdbot", "mayros.json"), - path.join(resolvedHome, ".clawdbot", "clawdbot.json"), - path.join(resolvedHome, ".clawdbot", "moldbot.json"), - path.join(resolvedHome, ".clawdbot", "moltbot.json"), - path.join(resolvedHome, ".moldbot", "mayros.json"), - path.join(resolvedHome, ".moldbot", "clawdbot.json"), - path.join(resolvedHome, ".moldbot", "moldbot.json"), - path.join(resolvedHome, ".moldbot", "moltbot.json"), - path.join(resolvedHome, ".moltbot", "mayros.json"), - path.join(resolvedHome, ".moltbot", "clawdbot.json"), - path.join(resolvedHome, ".moltbot", "moldbot.json"), - path.join(resolvedHome, ".moltbot", "moltbot.json"), - ]; + const expected = [path.join(resolvedHome, ".mayros", "mayros.json")]; expect(candidates).toEqual(expected); }); diff --git a/src/config/paths.ts b/src/config/paths.ts index e2412055..3e25357a 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -17,11 +17,10 @@ export function resolveIsNixMode(env: NodeJS.ProcessEnv = process.env): boolean export const isNixMode = resolveIsNixMode(); -// Support historical (and occasionally misspelled) legacy state dirs. -const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moldbot", ".moltbot"] as const; const NEW_STATE_DIRNAME = ".mayros"; const CONFIG_FILENAME = "mayros.json"; -const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moldbot.json", "moltbot.json"] as const; +const LEGACY_STATE_DIRNAMES = [] as const; +const LEGACY_CONFIG_FILENAMES = [] as const; function resolveDefaultHomeDir(): string { return resolveRequiredHomeDir(process.env, os.homedir); diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index d4323c45..d903c0e0 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -45,13 +45,13 @@ describe("gateway auth", () => { }); }); - it("does not resolve legacy CLAWDBOT gateway env vars", () => { + it("ignores unrelated env vars", () => { expect( resolveGatewayAuth({ authConfig: {}, env: { - CLAWDBOT_GATEWAY_TOKEN: "legacy-token", - CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", + UNRELATED_TOKEN: "not-a-gateway-token", + UNRELATED_PASSWORD: "not-a-gateway-password", } as NodeJS.ProcessEnv, }), ).toMatchObject({ diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 28927a99..cb040350 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -843,6 +843,12 @@ export const chatHandlers: GatewayRequestHandlers = { // See: https://github.com/ApiliumCode/mayros/issues/3658 const stampedMessage = injectTimestamp(parsedMessage, timestampOptsFromConfig(cfg)); + // Local/TUI sessions are always treated as the device owner. + const isLocalOwner = + !client?.connect?.scopes || + client.connect.scopes.includes("local") || + client.connect.scopes.includes("admin"); + const ctx: MsgContext = { Body: parsedMessage, BodyForAgent: stampedMessage, @@ -860,6 +866,7 @@ export const chatHandlers: GatewayRequestHandlers = { SenderName: clientInfo?.displayName, SenderUsername: clientInfo?.displayName, GatewayClientScopes: client?.connect?.scopes, + ...(isLocalOwner ? { OwnerAllowFrom: ["*"] } : {}), }; const agentId = resolveSessionAgentId({ diff --git a/src/infra/ensure-services.test.ts b/src/infra/ensure-services.test.ts new file mode 100644 index 00000000..128cb40c --- /dev/null +++ b/src/infra/ensure-services.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const probeGatewayReachable = vi.hoisted(() => vi.fn()); +const waitForGatewayReachable = vi.hoisted(() => vi.fn()); +const resolveGatewayService = vi.hoisted(() => vi.fn()); +const parseCortexConfig = vi.hoisted(() => vi.fn()); + +let cortexHealthy = true; +let sidecarStartResult = true; + +const CortexClient = vi.hoisted(() => + vi.fn().mockImplementation(function () { + return { isHealthy: vi.fn().mockImplementation(() => Promise.resolve(cortexHealthy)) }; + }), +); +const CortexSidecar = vi.hoisted(() => + vi.fn().mockImplementation(function () { + return { start: vi.fn().mockImplementation(() => Promise.resolve(sidecarStartResult)) }; + }), +); + +vi.mock("../commands/onboard-helpers.js", () => ({ + probeGatewayReachable, + waitForGatewayReachable, +})); +vi.mock("../daemon/service.js", () => ({ resolveGatewayService })); +vi.mock("../../extensions/shared/cortex-config.js", () => ({ parseCortexConfig })); +vi.mock("../../extensions/shared/cortex-client.js", () => ({ CortexClient })); +vi.mock("../../extensions/memory-semantic/cortex-sidecar.js", () => ({ CortexSidecar })); +vi.mock("../config/config.js", () => ({ + resolveGatewayPort: (cfg: Record) => + (cfg?.gateway as Record)?.port ?? 18789, +})); + +import { ensureServicesRunning } from "./ensure-services.js"; + +function makeConfig(overrides: Record = {}) { + return { gateway: { port: 18789 }, ...overrides } as Parameters< + typeof ensureServicesRunning + >[0]["config"]; +} + +describe("ensureServicesRunning", () => { + const log = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + cortexHealthy = true; + sidecarStartResult = true; + parseCortexConfig.mockReturnValue({ host: "127.0.0.1", port: 19090, autoStart: true }); + }); + + describe("gateway", () => { + it("returns ok when gateway is already reachable", async () => { + probeGatewayReachable.mockResolvedValue({ ok: true }); + + const result = await ensureServicesRunning({ config: makeConfig(), log }); + + expect(result.gateway.ok).toBe(true); + expect(resolveGatewayService).not.toHaveBeenCalled(); + }); + + it("restarts service when gateway is not reachable", async () => { + probeGatewayReachable.mockResolvedValue({ ok: false, detail: "ECONNREFUSED" }); + const restart = vi.fn(); + const isLoaded = vi.fn().mockResolvedValue(true); + resolveGatewayService.mockReturnValue({ restart, isLoaded }); + waitForGatewayReachable.mockResolvedValue({ ok: true }); + + const result = await ensureServicesRunning({ config: makeConfig(), log }); + + expect(result.gateway.ok).toBe(true); + expect(restart).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("starting service")); + }); + + it("fails when service is not installed", async () => { + probeGatewayReachable.mockResolvedValue({ ok: false }); + const isLoaded = vi.fn().mockResolvedValue(false); + resolveGatewayService.mockReturnValue({ isLoaded, restart: vi.fn() }); + + const result = await ensureServicesRunning({ config: makeConfig(), log }); + + expect(result.gateway.ok).toBe(false); + expect(result.gateway.detail).toContain("not installed"); + }); + + it("fails when restart throws", async () => { + probeGatewayReachable.mockResolvedValue({ ok: false }); + const isLoaded = vi.fn().mockResolvedValue(true); + const restart = vi.fn().mockRejectedValue(new Error("permission denied")); + resolveGatewayService.mockReturnValue({ isLoaded, restart }); + + const result = await ensureServicesRunning({ config: makeConfig(), log }); + + expect(result.gateway.ok).toBe(false); + expect(result.gateway.detail).toContain("permission denied"); + }); + + it("fails when gateway does not become healthy after restart", async () => { + probeGatewayReachable.mockResolvedValue({ ok: false }); + const isLoaded = vi.fn().mockResolvedValue(true); + resolveGatewayService.mockReturnValue({ isLoaded, restart: vi.fn() }); + waitForGatewayReachable.mockResolvedValue({ ok: false, detail: "timeout" }); + + const result = await ensureServicesRunning({ config: makeConfig(), log }); + + expect(result.gateway.ok).toBe(false); + expect(result.gateway.detail).toBe("timeout"); + }); + + it("uses configured auth token for probe", async () => { + probeGatewayReachable.mockResolvedValue({ ok: true }); + + await ensureServicesRunning({ + config: makeConfig({ + gateway: { port: 18789, auth: { token: "my-token" } }, + }), + log, + }); + + expect(probeGatewayReachable).toHaveBeenCalledWith( + expect.objectContaining({ token: "my-token" }), + ); + }); + }); + + describe("cortex", () => { + beforeEach(() => { + // Gateway always reachable for cortex tests + probeGatewayReachable.mockResolvedValue({ ok: true }); + }); + + it("returns ok when cortex is already healthy", async () => { + cortexHealthy = true; + + const result = await ensureServicesRunning({ config: makeConfig(), log }); + + expect(result.cortex.ok).toBe(true); + }); + + it("starts sidecar when cortex is not healthy", async () => { + cortexHealthy = false; + sidecarStartResult = true; + + const result = await ensureServicesRunning({ config: makeConfig(), log }); + + expect(result.cortex.ok).toBe(true); + expect(log).toHaveBeenCalledWith(expect.stringContaining("starting sidecar")); + }); + + it("fails when sidecar fails to start", async () => { + cortexHealthy = false; + sidecarStartResult = false; + + const result = await ensureServicesRunning({ config: makeConfig(), log }); + + expect(result.cortex.ok).toBe(false); + expect(result.cortex.detail).toContain("failed to start"); + }); + + it("returns not ok when autoStart is disabled", async () => { + cortexHealthy = false; + parseCortexConfig.mockReturnValue({ host: "127.0.0.1", port: 19090, autoStart: false }); + + const result = await ensureServicesRunning({ config: makeConfig(), log }); + + expect(result.cortex.ok).toBe(false); + expect(result.cortex.detail).toContain("autoStart is disabled"); + }); + }); +}); diff --git a/src/infra/ensure-services.ts b/src/infra/ensure-services.ts new file mode 100644 index 00000000..4833610f --- /dev/null +++ b/src/infra/ensure-services.ts @@ -0,0 +1,138 @@ +/** + * Ensures the Gateway and Cortex sidecar are running before starting the TUI. + * + * 1. Probe gateway health — if reachable, done. + * 2. If not, try to (re)start the daemon service. + * 3. Wait for gateway to become healthy. + * 4. Probe Cortex health — if reachable, done. + * 5. If not, spawn the Cortex sidecar. + */ + +import type { MayrosConfig } from "../config/config.js"; +import { resolveGatewayPort } from "../config/config.js"; +import { probeGatewayReachable, waitForGatewayReachable } from "../commands/onboard-helpers.js"; +import { resolveGatewayService } from "../daemon/service.js"; +import { parseCortexConfig } from "../../extensions/shared/cortex-config.js"; +import { CortexClient } from "../../extensions/shared/cortex-client.js"; +import { CortexSidecar } from "../../extensions/memory-semantic/cortex-sidecar.js"; + +export type EnsureServicesResult = { + gateway: { ok: boolean; detail?: string }; + cortex: { ok: boolean; detail?: string }; +}; + +export async function ensureServicesRunning(params: { + config: MayrosConfig; + log: (msg: string) => void; +}): Promise { + const { config, log } = params; + + const gatewayResult = await ensureGatewayRunning({ config, log }); + const cortexResult = await ensureCortexRunning({ config, log }); + + return { gateway: gatewayResult, cortex: cortexResult }; +} + +// --------------------------------------------------------------------------- +// Gateway +// --------------------------------------------------------------------------- + +async function ensureGatewayRunning(params: { + config: MayrosConfig; + log: (msg: string) => void; +}): Promise<{ ok: boolean; detail?: string }> { + const { config, log } = params; + const port = resolveGatewayPort(config); + const wsUrl = `ws://127.0.0.1:${port}`; + + // 1. Quick probe — maybe it's already running + const probe = await probeGatewayReachable({ + url: wsUrl, + token: config.gateway?.auth?.token ?? process.env.MAYROS_GATEWAY_TOKEN, + password: config.gateway?.auth?.password ?? process.env.MAYROS_GATEWAY_PASSWORD, + timeoutMs: 2000, + }); + + if (probe.ok) { + return { ok: true }; + } + + // 2. Try to start/restart the daemon service + log("Gateway not running — starting service..."); + + try { + const service = resolveGatewayService(); + const loaded = await service.isLoaded({ env: process.env }).catch(() => false); + + if (!loaded) { + return { + ok: false, + detail: "Gateway service not installed. Run `mayros onboard` to set it up.", + }; + } + + await service.restart({ env: process.env, stdout: process.stdout }); + } catch (err) { + return { + ok: false, + detail: `Failed to start gateway: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + // 3. Wait for it to become healthy + const result = await waitForGatewayReachable({ + url: wsUrl, + token: config.gateway?.auth?.token ?? process.env.MAYROS_GATEWAY_TOKEN, + password: config.gateway?.auth?.password ?? process.env.MAYROS_GATEWAY_PASSWORD, + deadlineMs: 15_000, + pollMs: 400, + probeTimeoutMs: 2000, + }); + + if (result.ok) { + log("Gateway started."); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Cortex +// --------------------------------------------------------------------------- + +async function ensureCortexRunning(params: { + config: MayrosConfig; + log: (msg: string) => void; +}): Promise<{ ok: boolean; detail?: string }> { + const { config, log } = params; + + const cortexRaw = ( + config.plugins?.entries?.["memory-semantic"]?.config as Record + )?.cortex; + const cortexConfig = parseCortexConfig(cortexRaw ?? {}); + + // 1. Quick health check + const client = new CortexClient(cortexConfig); + if (await client.isHealthy()) { + return { ok: true }; + } + + if (!cortexConfig.autoStart) { + return { ok: false, detail: "Cortex not running and autoStart is disabled." }; + } + + // 2. Spawn the sidecar + log("Cortex not running — starting sidecar..."); + const sidecar = new CortexSidecar(cortexConfig); + const healthy = await sidecar.start(); + + if (healthy) { + log("Cortex started."); + return { ok: true }; + } + + return { + ok: false, + detail: "Cortex sidecar failed to start. Run `mayros doctor` for diagnostics.", + }; +} diff --git a/src/infra/state-migrations.state-dir.test.ts b/src/infra/state-migrations.state-dir.test.ts index 4317d6ad..974e2532 100644 --- a/src/infra/state-migrations.state-dir.test.ts +++ b/src/infra/state-migrations.state-dir.test.ts @@ -24,29 +24,30 @@ afterEach(async () => { tempRoot = null; }); -describe("legacy state dir auto-migration", () => { - it("follows legacy symlink when it points at another legacy dir (clawdbot -> moltbot)", async () => { +describe("state dir auto-migration", () => { + it("reports no migration when no legacy dirs exist", async () => { const root = await makeTempRoot(); - const legacySymlink = path.join(root, ".clawdbot"); - const legacyDir = path.join(root, ".moltbot"); - fs.mkdirSync(legacyDir, { recursive: true }); - fs.writeFileSync(path.join(legacyDir, "marker.txt"), "ok", "utf-8"); + const result = await autoMigrateLegacyStateDir({ + env: {} as NodeJS.ProcessEnv, + homedir: () => root, + }); - const dirLinkType = process.platform === "win32" ? "junction" : "dir"; - fs.symlinkSync(legacyDir, legacySymlink, dirLinkType); + expect(result.migrated).toBe(false); + }); + + it("skips migration when .mayros already exists", async () => { + const root = await makeTempRoot(); + const mayrosDir = path.join(root, ".mayros"); + fs.mkdirSync(mayrosDir, { recursive: true }); + fs.writeFileSync(path.join(mayrosDir, "marker.txt"), "ok", "utf-8"); const result = await autoMigrateLegacyStateDir({ env: {} as NodeJS.ProcessEnv, homedir: () => root, }); - expect(result.migrated).toBe(true); - expect(result.warnings).toEqual([]); - - const targetMarker = path.join(root, ".mayros", "marker.txt"); - expect(fs.readFileSync(targetMarker, "utf-8")).toBe("ok"); - expect(fs.readFileSync(path.join(root, ".moltbot", "marker.txt"), "utf-8")).toBe("ok"); - expect(fs.readFileSync(path.join(root, ".clawdbot", "marker.txt"), "utf-8")).toBe("ok"); + expect(result.migrated).toBe(false); + expect(fs.readFileSync(path.join(mayrosDir, "marker.txt"), "utf-8")).toBe("ok"); }); }); diff --git a/src/infra/unhandled-rejections.fatal-detection.test.ts b/src/infra/unhandled-rejections.fatal-detection.test.ts index 6eb246ce..65a9fb82 100644 --- a/src/infra/unhandled-rejections.fatal-detection.test.ts +++ b/src/infra/unhandled-rejections.fatal-detection.test.ts @@ -4,8 +4,9 @@ import { installUnhandledRejectionHandler } from "./unhandled-rejections.js"; describe("installUnhandledRejectionHandler - fatal detection", () => { let exitCalls: Array = []; - let consoleErrorSpy: ReturnType; + let stderrMessages: string[] = []; let consoleWarnSpy: ReturnType; + let stderrWriteSpy: ReturnType; let originalExit: typeof process.exit; beforeAll(() => { @@ -15,6 +16,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { beforeEach(() => { exitCalls = []; + stderrMessages = []; vi.spyOn(process, "exit").mockImplementation((code?: string | number | null): never => { if (code !== undefined && code !== null) { @@ -23,13 +25,31 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { return undefined as never; }); - consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + // Mock stderr.write to capture messages and invoke the callback synchronously + // so that process.exit is called within the same tick as process.emit. + stderrWriteSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((...args: unknown[]): boolean => { + const data = args[0]; + if (typeof data === "string") { + stderrMessages.push(data); + } + // Find the callback argument (2nd or 3rd arg) and invoke it synchronously + for (let i = 1; i < args.length; i++) { + if (typeof args[i] === "function") { + (args[i] as () => void)(); + break; + } + } + return true; + }); + consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { vi.clearAllMocks(); - consoleErrorSpy.mockRestore(); + stderrWriteSpy.mockRestore(); consoleWarnSpy.mockRestore(); }); @@ -52,10 +72,11 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { expect(exitCalls).toEqual([1]); } - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[mayros] FATAL unhandled rejection:", - expect.stringContaining("Out of memory"), + const hasFatalMsg = stderrMessages.some( + (msg) => + msg.includes("[mayros] FATAL unhandled rejection:") && msg.includes("Out of memory"), ); + expect(hasFatalMsg).toBe(true); }); }); @@ -73,10 +94,12 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { expect(exitCalls).toEqual([1]); } - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[mayros] CONFIGURATION ERROR - requires fix:", - expect.stringContaining("Invalid config"), + const hasConfigMsg = stderrMessages.some( + (msg) => + msg.includes("[mayros] CONFIGURATION ERROR - requires fix:") && + msg.includes("Invalid config"), ); + expect(hasConfigMsg).toBe(true); }); }); @@ -109,10 +132,12 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { process.emit("unhandledRejection", genericErr, Promise.resolve()); expect(exitCalls).toEqual([1]); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[mayros] Unhandled promise rejection:", - expect.stringContaining("Something went wrong"), + const hasGenericMsg = stderrMessages.some( + (msg) => + msg.includes("[mayros] Unhandled promise rejection:") && + msg.includes("Something went wrong"), ); + expect(hasGenericMsg).toBe(true); }); }); }); diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index ad77d44f..2041bc4f 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -4,7 +4,7 @@ import { normalizePluginsConfig } from "./config-state.js"; describe("normalizePluginsConfig", () => { it("uses default memory slot when not specified", () => { const result = normalizePluginsConfig({}); - expect(result.slots.memory).toBe("memory-core"); + expect(result.slots.memory).toBe("memory-semantic"); }); it("respects explicit memory slot value", () => { @@ -38,13 +38,13 @@ describe("normalizePluginsConfig", () => { const result = normalizePluginsConfig({ slots: { memory: "" }, }); - expect(result.slots.memory).toBe("memory-core"); + expect(result.slots.memory).toBe("memory-semantic"); }); it("uses default when memory slot is whitespace only", () => { const result = normalizePluginsConfig({ slots: { memory: " " }, }); - expect(result.slots.memory).toBe("memory-core"); + expect(result.slots.memory).toBe("memory-semantic"); }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index a5f8c685..82858b27 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -14,9 +14,22 @@ export type NormalizedPluginsConfig = { }; export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ + // Core infrastructure "device-pair", "phone-control", "talk-voice", + "code-tools", + "interactive-permissions", + "bash-sandbox", + // Semantic / Cortex ecosystem + "memory-semantic", + "semantic-observability", + "semantic-skills", + "code-indexer", + "agent-mesh", + "token-economy", + // Analytics + "analytics", ]); const normalizeList = (value: unknown): string[] => { @@ -82,7 +95,11 @@ const hasExplicitMemorySlot = (plugins?: MayrosConfig["plugins"]) => Boolean(plugins?.slots && Object.prototype.hasOwnProperty.call(plugins.slots, "memory")); const hasExplicitMemoryEntry = (plugins?: MayrosConfig["plugins"]) => - Boolean(plugins?.entries && Object.prototype.hasOwnProperty.call(plugins.entries, "memory-core")); + Boolean( + plugins?.entries && + (Object.prototype.hasOwnProperty.call(plugins.entries, "memory-semantic") || + Object.prototype.hasOwnProperty.call(plugins.entries, "memory-core")), + ); const hasExplicitPluginConfig = (plugins?: MayrosConfig["plugins"]) => { if (!plugins) { diff --git a/src/plugins/slots.test.ts b/src/plugins/slots.test.ts index 960c5f01..c7172105 100644 --- a/src/plugins/slots.test.ts +++ b/src/plugins/slots.test.ts @@ -75,7 +75,7 @@ describe("applyExclusiveSlotSelection", () => { expect(result.changed).toBe(true); expect(result.warnings).toContain( - 'Exclusive slot "memory" switched from "memory-core" to "memory".', + 'Exclusive slot "memory" switched from "memory-semantic" to "memory".', ); }); diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts index db0227ba..bc062b19 100644 --- a/src/plugins/slots.ts +++ b/src/plugins/slots.ts @@ -14,7 +14,7 @@ const SLOT_BY_KIND: Record = { }; const DEFAULT_SLOT_BY_KEY: Record = { - memory: "memory-core", + memory: "memory-semantic", }; export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null { diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 83716533..2ad63d0f 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -41,6 +41,7 @@ function createContext() { function setRegistry(entries: MockRegistryToolEntry[]) { const registry = { tools: entries, + plugins: entries.map((e) => ({ id: e.pluginId, status: "loaded" as const, source: e.source })), diagnostics: [] as Array<{ level: string; pluginId: string; diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index dd6a2164..a94e299a 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -62,6 +62,10 @@ export function resolvePluginTools(params: { }); const tools: AnyAgentTool[] = []; + const errorPlugins = registry.plugins.filter((p) => p.status === "error"); + if (errorPlugins.length > 0) { + log.warn(`plugin errors: ${errorPlugins.map((p) => `${p.id}(${p.error})`).join(", ")}`); + } const existing = params.existingToolNames ?? new Set(); const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool))); const allowlist = normalizeAllowlist(params.toolAllowlist); diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 21f9285a..85fe2e9b 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -174,7 +174,7 @@ describe("removePluginFromConfig", () => { const { config: result, actions } = removePluginFromConfig(config, "memory-plugin"); - expect(result.plugins?.slots?.memory).toBe("memory-core"); + expect(result.plugins?.slots?.memory).toBe("memory-semantic"); expect(actions.memorySlot).toBe(true); }); diff --git a/src/sdk/index.test.ts b/src/sdk/index.test.ts index 60ad64f5..cc6b0a23 100644 --- a/src/sdk/index.test.ts +++ b/src/sdk/index.test.ts @@ -9,6 +9,8 @@ const mockClient = { waitForReady: vi.fn().mockResolvedValue(undefined), sendChat: vi.fn().mockResolvedValue({ runId: "test-run" }), listSessions: vi.fn().mockResolvedValue({ sessions: [] }), + abortChat: vi.fn().mockRejectedValue(new Error("no active run")), + patchSession: vi.fn().mockResolvedValue(undefined), onEvent: null as ((event: { event: string; payload?: unknown }) => void) | null, onDisconnected: null as ((reason?: string) => void) | null, }; @@ -347,6 +349,24 @@ describe("MayrosClient", () => { describe("abort", () => { it("disconnects on abort", async () => { await client.connect(); + + // Simulate an active run by starting sendMessage (which sets activeRunId). + // The mock sendChat completes but no chat.final is emitted, so the run + // stays "active" from the client's perspective. + mockClient.sendChat.mockImplementation(async () => { + // Don't emit any events — leave the run in progress + return { runId: "run-abort-test" }; + }); + // abortChat rejects, causing abort() to fall back to disconnect() + mockClient.abortChat.mockRejectedValue(new Error("run already finished")); + + // Start the generator but don't consume it — the run is now active. + const gen = client.sendMessage("test"); + // Kick off the generator so sendChat is invoked and activeRunId is set. + const iterPromise = gen.next(); + // Give the event loop a tick for sendChat to complete and set activeRunId. + await new Promise((r) => setTimeout(r, 10)); + await client.abort(); expect(mockClient.stop).toHaveBeenCalled(); }); diff --git a/src/tui/commands.ts b/src/tui/commands.ts index f9b4fc56..a6244d52 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -191,7 +191,9 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman { name: "settings", description: "Open settings" }, // Mayros ecosystem { name: "plan", description: "Start or show semantic plan" }, - { name: "kg", description: "Search the knowledge graph" }, + { name: "kg", description: "Search or browse the knowledge graph" }, + { name: "mouse", description: "Toggle mouse reporting (off enables text selection)" }, + { name: "tools", description: "List tools available to the model" }, { name: "trace", description: "Show agent trace events" }, { name: "team", description: "Show team dashboard" }, { name: "tasks", description: "Show background tasks" }, diff --git a/src/tui/components/welcome-screen.ts b/src/tui/components/welcome-screen.ts index bceb47a9..84bf4511 100644 --- a/src/tui/components/welcome-screen.ts +++ b/src/tui/components/welcome-screen.ts @@ -167,7 +167,7 @@ export class WelcomeScreen implements Component { // Compose lines const lines: string[] = []; - // Top border: ╭─ Mayros v0.1.4 ─...─╮ + // Top border: ╭─ Mayros v{version} ─...─╮ const title = ` Mayros v${this.version} `; const titleLen = title.length; const remainingTop = innerWidth - 1 - titleLen; // -1 for initial ─ diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index a3d2d457..a2cd7523 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it, vi } from "vitest"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { getSlashCommands, helpText } from "./commands.js"; +vi.mock("../commands/onboard.js", () => ({ + onboardCommand: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../runtime.js", () => ({ + defaultRuntime: {}, +})); + +// Prevent process.exit from killing the test runner +const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never); + describe("tui command handlers", () => { it("forwards unknown slash commands to the gateway", async () => { const sendChat = vi.fn().mockResolvedValue({ runId: "r1" }); @@ -316,9 +326,9 @@ describe("tui command handlers", () => { const setActivityStatus = vi.fn(); const { handleCommand } = createCommandHandlers({ - client: { sendChat } as never, + client: { sendChat, stop: vi.fn() } as never, chatLog: { addUser, addSystem } as never, - tui: { requestRender } as never, + tui: { requestRender, stop: vi.fn() } as never, opts: {}, state: { currentSessionKey: "agent:main:main", @@ -348,42 +358,52 @@ describe("tui command handlers", () => { expect(addUser).toHaveBeenCalledWith("/plan start"); }); - it("/kg shows usage when no query provided", async () => { - const { handleCommand, addSystem } = setupEcosystem(); + it("/kg shows summary when no query provided", async () => { + const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/kg"); - expect(addSystem).toHaveBeenCalledWith("usage: /kg "); + expect(addUser).toHaveBeenCalledWith( + expect.stringContaining("Show a knowledge graph summary."), + ); }); it("/kg sends search message when query provided", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/kg auth flow"); - expect(addUser).toHaveBeenCalledWith("Search the knowledge graph for: auth flow"); + expect(addUser).toHaveBeenCalledWith( + expect.stringContaining("Search the knowledge graph for: auth flow"), + ); }); it("/trace sends trace message", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/trace stats"); - expect(addUser).toHaveBeenCalledWith("Show trace stats summary for the current session"); + expect(addUser).toHaveBeenCalledWith( + "Use the trace_stats tool with no arguments to show aggregated observability statistics for the current agent.", + ); }); it("/team sends dashboard message", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/team"); expect(addUser).toHaveBeenCalledWith( - "Show the team dashboard with current agent status and activity", + "Use the mesh_team_dashboard tool with no arguments to show the team dashboard with current agent status and activity.", ); }); it("/tasks sends tasks message", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/tasks"); - expect(addUser).toHaveBeenCalledWith("Show background tasks status and summary"); + expect(addUser).toHaveBeenCalledWith( + "Use the agent_list_background_tasks tool with no arguments to list all background agent tasks and their current status.", + ); }); it("/workflow without args lists workflows", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/workflow"); - expect(addUser).toHaveBeenCalledWith("List available workflows and their status"); + expect(addUser).toHaveBeenCalledWith( + 'Use the mesh_run_workflow tool with action "list" to list available workflows and their status.', + ); }); it("/workflow with args forwards them", async () => { @@ -395,21 +415,23 @@ describe("tui command handlers", () => { it("/rules sends rules message", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/rules"); - expect(addUser).toHaveBeenCalledWith("Show active rules"); + expect(addUser).toHaveBeenCalledWith( + 'Use the semantic_memory_recall tool with subject pattern "rule:*" to list all active rules.', + ); }); it("/mailbox without args checks inbox", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/mailbox"); - expect(addUser).toHaveBeenCalledWith("Check my inbox for new messages and show unread count"); + expect(addUser).toHaveBeenCalledWith( + "Use the agent_check_inbox tool with no arguments to check the inbox for new messages and show unread count.", + ); }); - it("/onboard shows terminal hint", async () => { + it("/onboard launches wizard", async () => { const { handleCommand, addSystem } = setupEcosystem(); await handleCommand("/onboard"); - expect(addSystem).toHaveBeenCalledWith( - "Run 'mayros onboard' from the terminal to start the setup wizard", - ); + expect(addSystem).toHaveBeenCalledWith("Launching onboarding wizard — stopping TUI..."); }); }); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 6425f748..3e738e5d 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -64,6 +64,7 @@ type CommandHandlerContext = { applySessionInfoFromPatch: (result: SessionsPatchResult) => void; noteLocalRunId: (runId: string) => void; forgetLocalRunId?: (runId: string) => void; + mouseHandler?: { enable(): void; disable(): void; isEnabled(): boolean }; }; export function createCommandHandlers(context: CommandHandlerContext) { @@ -979,12 +980,41 @@ export function createCommandHandlers(context: CommandHandlerContext) { await sendMessage(`/plan ${action}`); break; } + case "mouse": { + if (context.mouseHandler) { + if (context.mouseHandler.isEnabled()) { + context.mouseHandler.disable(); + chatLog.addSystem("Mouse reporting disabled — text selection enabled."); + } else { + context.mouseHandler.enable(); + chatLog.addSystem("Mouse reporting enabled."); + } + } else { + chatLog.addSystem("Mouse handler not available."); + } + break; + } + case "tools": { + await sendMessage( + "List every tool name you have access to. Output ONLY a numbered list of tool names, nothing else. " + + "Do NOT describe them. Just the names, one per line.", + ); + break; + } case "kg": { + // Check if semantic memory tools are likely available by asking the gateway + const kgHint = + "You MUST use one of these tools (in order of preference): " + + "memory_stats, semantic_memory_query, semantic_memory_recall. " + + "If none of these tools are available to you, respond EXACTLY with: " + + '"[kg] Plugin memory-semantic is not loaded. Run `mayros plugins list` to check."'; if (!args) { - chatLog.addSystem("usage: /kg "); - break; + await sendMessage( + `Show a knowledge graph summary. ${kgHint} Show categories, triple counts, and recent entries.`, + ); + } else { + await sendMessage(`Search the knowledge graph for: ${args}. ${kgHint}`); } - await sendMessage(`Use the semantic_memory_query tool to search for: ${args}`); break; } case "trace": { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 59f2f822..36e7b4e3 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -9,6 +9,7 @@ import { TUI, } from "@mariozechner/pi-tui"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { VERSION } from "../version.js"; import { loadConfig } from "../config/config.js"; import { isLoopbackHost } from "../gateway/net.js"; import { @@ -549,7 +550,8 @@ export async function runTui(opts: TuiOptions) { } tui.requestRender(); }); - mouseHandler.enable(); + // Mouse reporting off by default — enables native text selection. + // Users can toggle with /mouse if they want scroll-with-mouse. const root = new Container(); root.addChild(header); @@ -816,7 +818,7 @@ export async function runTui(opts: TuiOptions) { return parsed ? normalizeAgentId(parsed.agentId) : null; })(); - const createWelcomeScreen = () => new WelcomeScreen({ version: "0.1.4", getState: () => state }); + const createWelcomeScreen = () => new WelcomeScreen({ version: VERSION, getState: () => state }); const sessionActions = createSessionActions({ client, @@ -876,6 +878,7 @@ export async function runTui(opts: TuiOptions) { formatSessionKey, noteLocalRunId, forgetLocalRunId, + mouseHandler, }); const { runLocalShellLine } = createLocalShellRunner({ diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 054945d7..711943a9 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -124,7 +124,7 @@ describe("web media loading", () => { }); beforeAll(() => { - vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => { + const mockPinnedHostname = async (hostname: string) => { const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); const addresses = ["93.184.216.34"]; return { @@ -132,7 +132,18 @@ describe("web media loading", () => { addresses, lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), }; - }); + }; + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(mockPinnedHostname); + const realResolvePinnedHostnameWithPolicy = ssrf.resolvePinnedHostnameWithPolicy; + vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation( + async (hostname, params) => { + // Let SSRF-blocked hostnames go through real validation so guard tests still work. + if (ssrf.isBlockedHostnameOrIp(hostname) || /^(10|127|192\.168)\.\d/.test(hostname)) { + return realResolvePinnedHostnameWithPolicy(hostname, params); + } + return mockPinnedHostname(hostname); + }, + ); }); it("strips MEDIA: prefix before reading local file (including whitespace variants)", async () => { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 82b3a7c5..fb96149b 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -35,6 +35,7 @@ export const en: TranslationMap = { config: "Config", debug: "Debug", logs: "Logs", + cortex: "Cortex", }, subtitles: { agents: "Manage agent workspaces, tools, and identities.", @@ -50,6 +51,7 @@ export const en: TranslationMap = { config: "Edit ~/.mayros/mayros.json safely.", debug: "Gateway snapshots, events, and manual RPC calls.", logs: "Live tail of the gateway file logs.", + cortex: "Browse the knowledge graph: triples, subjects, and predicates.", }, overview: { access: { diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 8b66fc89..17559078 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -35,6 +35,7 @@ export const pt_BR: TranslationMap = { config: "Config", debug: "Debug", logs: "Logs", + cortex: "Cortex", }, subtitles: { agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.", @@ -50,6 +51,7 @@ export const pt_BR: TranslationMap = { config: "Editar ~/.mayros/mayros.json com segurança.", debug: "Snapshots do gateway, eventos e chamadas RPC manuais.", logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.", + cortex: "Explore o grafo de conhecimento: triplas, sujeitos e predicados.", }, overview: { access: { diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 80869258..43ebc617 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -35,6 +35,7 @@ export const zh_CN: TranslationMap = { config: "配置", debug: "调试", logs: "日志", + cortex: "Cortex", }, subtitles: { agents: "管理代理工作区、工具和身份。", @@ -50,6 +51,7 @@ export const zh_CN: TranslationMap = { config: "安全地编辑 ~/.mayros/mayros.json。", debug: "网关快照、事件和手动 RPC 调用。", logs: "网关文件日志的实时追踪。", + cortex: "浏览知识图谱:三元组、主体和谓词。", }, overview: { access: { diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index a02c92da..e370ba8c 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -35,6 +35,7 @@ export const zh_TW: TranslationMap = { config: "配置", debug: "調試", logs: "日誌", + cortex: "Cortex", }, subtitles: { agents: "管理代理工作區、工具和身份。", @@ -50,6 +51,7 @@ export const zh_TW: TranslationMap = { config: "安全地編輯 ~/.mayros/mayros.json。", debug: "網關快照、事件和手動 RPC 調用。", logs: "網關文件日志的實時追蹤。", + cortex: "瀏覽知識圖譜:三元組、主體和謂詞。", }, overview: { access: { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index b939c27c..ef039fc4 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -530,6 +530,10 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } +.grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .stat-grid { display: grid; gap: 14px; @@ -596,7 +600,8 @@ } .grid-cols-2, - .grid-cols-3 { + .grid-cols-3, + .grid-cols-4 { grid-template-columns: 1fr; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a4c07f63..7d54eb2a 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -43,6 +43,13 @@ import { } from "./controllers/exec-approvals.ts"; import { loadLogs } from "./controllers/logs.ts"; import { loadNodes } from "./controllers/nodes.ts"; +import { + loadCortexStatus, + loadCortexTriples, + loadCortexSubjects, + loadCortexPredicates, + reconnectCortex, +} from "./controllers/cortex.ts"; import { loadPresence } from "./controllers/presence.ts"; import { deleteSessionAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts"; import { @@ -65,6 +72,7 @@ import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.t import { renderInstances } from "./views/instances.ts"; import { renderLogs } from "./views/logs.ts"; import { renderNodes } from "./views/nodes.ts"; +import { renderCortex } from "./views/cortex.ts"; import { renderOverview } from "./views/overview.ts"; import { renderSessions } from "./views/sessions.ts"; import { renderSkills } from "./views/skills.ts"; @@ -225,6 +233,8 @@ export function renderApp(state: AppViewState) { cronEnabled: state.cronStatus?.enabled ?? null, cronNext, lastChannelsRefresh: state.channelsLastSuccess, + cortexStatus: state.cortexStatus, + cortexLoading: state.cortexLoading, onSettingsChange: (next) => state.applySettings(next), onPasswordChange: (next) => (state.password = next), onSessionKeyChange: (next) => { @@ -240,6 +250,7 @@ export function renderApp(state: AppViewState) { }, onConnect: () => state.connect(), onRefresh: () => state.loadOverview(), + onCortexReconnect: () => void reconnectCortex(state), }) : nothing } @@ -959,6 +970,38 @@ export function renderApp(state: AppViewState) { }) : nothing } + + ${ + state.tab === "cortex" + ? renderCortex({ + loading: state.cortexLoading, + error: state.cortexError, + status: state.cortexStatus, + triples: state.cortexTriples, + subjects: state.cortexSubjects, + predicates: state.cortexPredicates, + filter: state.cortexBrowseFilter, + browseLoading: state.cortexBrowseLoading, + browseError: state.cortexBrowseError, + onRefresh: () => { + void loadCortexStatus(state); + void loadCortexTriples(state); + void loadCortexSubjects(state); + void loadCortexPredicates(state); + }, + onFilterChange: (filter) => void loadCortexTriples(state, { ...filter }), + onSubjectClick: (subject) => { + const current = state.cortexBrowseFilter.subject; + void loadCortexTriples(state, { + subject: current === subject ? undefined : subject, + offset: 0, + }); + }, + onPageChange: (offset) => void loadCortexTriples(state, { offset }), + onReconnect: () => void reconnectCortex(state), + }) + : nothing + } ${renderExecApprovalPrompt(state)} ${renderGatewayUrlConfirmation(state)} diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index af3f5abc..01e49897 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -11,6 +11,12 @@ import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-iden import { loadAgentSkills } from "./controllers/agent-skills.ts"; import { loadAgents } from "./controllers/agents.ts"; import { loadChannels } from "./controllers/channels.ts"; +import { + loadCortexStatus, + loadCortexTriples, + loadCortexSubjects, + loadCortexPredicates, +} from "./controllers/cortex.ts"; import { loadConfig, loadConfigSchema } from "./controllers/config.ts"; import { loadCronJobs, loadCronStatus } from "./controllers/cron.ts"; import { loadDebug } from "./controllers/debug.ts"; @@ -240,6 +246,12 @@ export async function refreshActiveTab(host: SettingsHost) { await loadDebug(host as unknown as MayrosApp); host.eventLog = host.eventLogBuffer; } + if (host.tab === "cortex") { + await loadCortexStatus(host as unknown as MayrosApp); + void loadCortexTriples(host as unknown as MayrosApp); + void loadCortexSubjects(host as unknown as MayrosApp); + void loadCortexPredicates(host as unknown as MayrosApp); + } if (host.tab === "logs") { host.logsAtBottom = true; await loadLogs(host as unknown as MayrosApp, { reset: true }); @@ -409,6 +421,7 @@ export async function loadOverview(host: SettingsHost) { loadSessions(host as unknown as MayrosApp), loadCronStatus(host as unknown as MayrosApp), loadDebug(host as unknown as MayrosApp), + loadCortexStatus(host as unknown as MayrosApp), ]); } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index e7c7735c..8fcd14d5 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -9,6 +9,11 @@ import type { Tab } from "./navigation.ts"; import type { UiSettings } from "./storage.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts"; import type { ThemeMode } from "./theme.ts"; +import type { + CortexStatusResponse, + CortexBrowseFilter, + TripleEntry, +} from "./controllers/cortex.ts"; import type { AgentsListResult, AgentsFilesListResult, @@ -124,6 +129,15 @@ export type AppViewState = { presenceEntries: PresenceEntry[]; presenceError: string | null; presenceStatus: string | null; + cortexLoading: boolean; + cortexStatus: CortexStatusResponse | null; + cortexError: string | null; + cortexTriples: { triples: TripleEntry[]; total: number } | null; + cortexSubjects: { subjects: string[]; total: number } | null; + cortexPredicates: { predicates: string[]; total: number } | null; + cortexBrowseLoading: boolean; + cortexBrowseError: string | null; + cortexBrowseFilter: CortexBrowseFilter; agentsLoading: boolean; agentsList: AgentsListResult | null; agentsError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7624f704..b5799a30 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -53,6 +53,11 @@ import { import type { AppViewState } from "./app-view-state.ts"; import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; +import type { + CortexStatusResponse, + CortexBrowseFilter, + TripleEntry, +} from "./controllers/cortex.ts"; import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; @@ -209,6 +214,16 @@ export class MayrosApp extends LitElement { @state() presenceError: string | null = null; @state() presenceStatus: string | null = null; + @state() cortexLoading = false; + @state() cortexStatus: CortexStatusResponse | null = null; + @state() cortexError: string | null = null; + @state() cortexTriples: { triples: TripleEntry[]; total: number } | null = null; + @state() cortexSubjects: { subjects: string[]; total: number } | null = null; + @state() cortexPredicates: { predicates: string[]; total: number } | null = null; + @state() cortexBrowseLoading = false; + @state() cortexBrowseError: string | null = null; + @state() cortexBrowseFilter: CortexBrowseFilter = { limit: 50, offset: 0 }; + @state() agentsLoading = false; @state() agentsList: AgentsListResult | null = null; @state() agentsError: string | null = null; diff --git a/ui/src/ui/controllers/cortex.ts b/ui/src/ui/controllers/cortex.ts new file mode 100644 index 00000000..5e6d97c8 --- /dev/null +++ b/ui/src/ui/controllers/cortex.ts @@ -0,0 +1,125 @@ +import type { GatewayBrowserClient } from "../gateway.ts"; + +export type CortexStatusResponse = { + status: "online" | "offline"; + sidecar: string; + endpoint: string; + autoStart: boolean; + version: string | null; + uptime: number | null; + triples: number | null; + subjects: number | null; + pendingWrites: number; +}; + +export type TripleEntry = { + id?: string; + subject: string; + predicate: string; + object: unknown; + created_at?: string; +}; + +export type CortexBrowseFilter = { + subject?: string; + predicate?: string; + limit: number; + offset: number; +}; + +export type CortexState = { + client: GatewayBrowserClient | null; + connected: boolean; + cortexLoading: boolean; + cortexStatus: CortexStatusResponse | null; + cortexError: string | null; + cortexTriples: { triples: TripleEntry[]; total: number } | null; + cortexSubjects: { subjects: string[]; total: number } | null; + cortexPredicates: { predicates: string[]; total: number } | null; + cortexBrowseLoading: boolean; + cortexBrowseError: string | null; + cortexBrowseFilter: CortexBrowseFilter; +}; + +export async function loadCortexStatus(state: CortexState) { + if (!state.client || !state.connected) { + return; + } + if (state.cortexLoading) { + return; + } + state.cortexLoading = true; + state.cortexError = null; + try { + state.cortexStatus = await state.client.request("cortex.status", {}); + } catch (err) { + state.cortexError = String(err); + } finally { + state.cortexLoading = false; + } +} + +export async function reconnectCortex(state: CortexState) { + if (!state.client || !state.connected) { + return; + } + state.cortexLoading = true; + state.cortexError = null; + try { + await state.client.request("cortex.reconnect", {}); + // Refresh status after reconnect + state.cortexStatus = await state.client.request("cortex.status", {}); + } catch (err) { + state.cortexError = String(err); + } finally { + state.cortexLoading = false; + } +} + +export async function loadCortexTriples(state: CortexState, filter?: Partial) { + if (!state.client || !state.connected) { + return; + } + if (state.cortexBrowseLoading) { + return; + } + if (filter) { + state.cortexBrowseFilter = { ...state.cortexBrowseFilter, ...filter }; + } + state.cortexBrowseLoading = true; + state.cortexBrowseError = null; + try { + state.cortexTriples = await state.client.request("cortex.triples", { + subject: state.cortexBrowseFilter.subject, + predicate: state.cortexBrowseFilter.predicate, + limit: state.cortexBrowseFilter.limit, + offset: state.cortexBrowseFilter.offset, + }); + } catch (err) { + state.cortexBrowseError = String(err); + } finally { + state.cortexBrowseLoading = false; + } +} + +export async function loadCortexSubjects(state: CortexState) { + if (!state.client || !state.connected) { + return; + } + try { + state.cortexSubjects = await state.client.request("cortex.subjects", { limit: 200 }); + } catch { + // non-critical — subjects dropdown just won't populate + } +} + +export async function loadCortexPredicates(state: CortexState) { + if (!state.client || !state.connected) { + return; + } + try { + state.cortexPredicates = await state.client.request("cortex.predicates", { limit: 200 }); + } catch { + // non-critical — predicates dropdown just won't populate + } +} diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 8e1bc2c9..db6ba80f 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -5,7 +5,7 @@ export const TAB_GROUPS = [ { label: "chat", tabs: ["chat"] }, { label: "control", - tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"], + tabs: ["overview", "channels", "instances", "sessions", "usage", "cron", "cortex"], }, { label: "agent", tabs: ["agents", "skills", "nodes"] }, { label: "settings", tabs: ["config", "debug", "logs"] }, @@ -19,6 +19,7 @@ export type Tab = | "sessions" | "usage" | "cron" + | "cortex" | "skills" | "nodes" | "chat" @@ -34,6 +35,7 @@ const TAB_PATHS: Record = { sessions: "/sessions", usage: "/usage", cron: "/cron", + cortex: "/cortex", skills: "/skills", nodes: "/nodes", chat: "/chat", @@ -141,6 +143,8 @@ export function iconForTab(tab: Tab): IconName { return "barChart"; case "cron": return "loader"; + case "cortex": + return "brain"; case "skills": return "zap"; case "nodes": diff --git a/ui/src/ui/views/cortex.ts b/ui/src/ui/views/cortex.ts new file mode 100644 index 00000000..35baed2d --- /dev/null +++ b/ui/src/ui/views/cortex.ts @@ -0,0 +1,199 @@ +import { html, nothing } from "lit"; +import type { + CortexStatusResponse, + CortexBrowseFilter, + TripleEntry, +} from "../controllers/cortex.ts"; + +export type CortexBrowserProps = { + loading: boolean; + error: string | null; + status: CortexStatusResponse | null; + triples: { triples: TripleEntry[]; total: number } | null; + subjects: { subjects: string[]; total: number } | null; + predicates: { predicates: string[]; total: number } | null; + filter: CortexBrowseFilter; + browseLoading: boolean; + browseError: string | null; + onRefresh: () => void; + onFilterChange: (filter: Partial) => void; + onSubjectClick: (subject: string) => void; + onPageChange: (offset: number) => void; + onReconnect: () => void; +}; + +function formatObject(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function groupBySubject(triples: TripleEntry[]): Map { + const groups = new Map(); + for (const triple of triples) { + const existing = groups.get(triple.subject); + if (existing) { + existing.push(triple); + } else { + groups.set(triple.subject, [triple]); + } + } + return groups; +} + +export function renderCortex(props: CortexBrowserProps) { + const isOffline = !props.status || props.status.status !== "online"; + const triples = props.triples?.triples ?? []; + const total = props.triples?.total ?? 0; + const subjectList = props.subjects?.subjects ?? []; + const predicateList = props.predicates?.predicates ?? []; + const grouped = groupBySubject(triples); + const limit = props.filter.limit; + const offset = props.filter.offset; + const pageStart = total > 0 ? offset + 1 : 0; + const pageEnd = Math.min(offset + limit, total); + const hasPrev = offset > 0; + const hasNext = offset + limit < total; + + if (isOffline) { + return html` +
+
Cortex Offline
+
+ ${props.status?.endpoint ? `Endpoint: ${props.status.endpoint}` : "Not connected"} +
+ +
+ `; + } + + return html` +
+
+
+
+ ${props.status?.triples != null ? `${props.status.triples} triples` : ""} + ${props.status?.subjects != null ? ` · ${props.status.subjects} subjects` : ""} + ${props.status?.version ? ` · v${props.status.version}` : ""} +
+
+ +
+ + ${props.browseError ? html`
${props.browseError}
` : nothing} + +
+ + +
+ + ${ + triples.length === 0 && !props.browseLoading + ? html` +
+
No triples found.
+
+ ` + : nothing + } + + ${[...grouped.entries()].map( + ([subject, entries]) => html` +
+
props.onSubjectClick(subject)} + title="Filter by this subject" + > + ${subject} +
+ + + ${entries.map( + (entry) => html` + + + + + `, + )} + +
+ ${entry.predicate} + + ${formatObject(entry.object)} +
+
+ `, + )} + + ${ + total > 0 + ? html` +
+ Showing ${pageStart}–${pageEnd} of ${total} +
+ + +
+
+ ` + : nothing + } +
+ `; +} diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index daf8c939..22ddde4b 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -4,6 +4,7 @@ import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import type { GatewayHelloOk } from "../gateway.ts"; import { formatNextRun } from "../presenter.ts"; import type { UiSettings } from "../storage.ts"; +import type { CortexStatusResponse } from "../controllers/cortex.ts"; export type OverviewProps = { connected: boolean; @@ -16,11 +17,14 @@ export type OverviewProps = { cronEnabled: boolean | null; cronNext: number | null; lastChannelsRefresh: number | null; + cortexStatus: CortexStatusResponse | null; + cortexLoading: boolean; onSettingsChange: (next: UiSettings) => void; onPasswordChange: (next: string) => void; onSessionKeyChange: (next: string) => void; onConnect: () => void; onRefresh: () => void; + onCortexReconnect: () => void; }; export function renderOverview(props: OverviewProps) { @@ -253,7 +257,7 @@ export function renderOverview(props: OverviewProps) { -
+
${t("overview.stats.instances")}
${props.presenceCount}
@@ -271,6 +275,35 @@ export function renderOverview(props: OverviewProps) {
${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}
+
+
Cortex
+
+ ${ + props.cortexLoading + ? "..." + : props.cortexStatus + ? props.cortexStatus.status === "online" + ? "Online" + : "Offline" + : t("common.na") + } +
+
+ ${props.cortexStatus?.version ? `v${props.cortexStatus.version}` : ""} + ${props.cortexStatus?.triples != null ? ` \u00b7 ${props.cortexStatus.triples} triples` : ""} + ${props.cortexStatus?.pendingWrites ? ` \u00b7 ${props.cortexStatus.pendingWrites} queued` : ""} +
+ ${ + props.cortexStatus && props.cortexStatus.status !== "online" + ? html`` + : "" + } +