From 5587a4c289d3d9198f47b39e2f0469ceb4dd9c49 Mon Sep 17 00:00:00 2001 From: It Apilium Date: Mon, 9 Mar 2026 15:13:33 +0100 Subject: [PATCH 1/7] feat: persistent Cortex storage and graceful update flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass --db flag to Cortex sidecar for Sled-backed persistent storage (~/.mayros/cortex-data/graph.sled). Add flush-before-update to prevent data loss during binary replacement, and dataDir config option. - Add dataDir to CortexConfig type and parser - Pass --db /graph.sled in sidecar spawn args - Create data directory on first start (mkdir -p) - Add flushCortexBeforeUpdate() — POST /api/v1/flush before replace - Integrate flush into installOrUpdateCortex() - Add cortex.dataDir uiHint in memory-semantic config - Bump REQUIRED_CORTEX_VERSION to 0.4.1 - Bump package version 0.1.9 → 0.1.10 - Sync 49 extension versions via plugins:sync --- extensions/agent-mesh/package.json | 2 +- extensions/analytics/package.json | 2 +- extensions/bash-sandbox/package.json | 2 +- extensions/bluebubbles/package.json | 2 +- extensions/ci-plugin/package.json | 2 +- extensions/code-indexer/package.json | 2 +- extensions/code-tools/package.json | 2 +- extensions/copilot-proxy/package.json | 2 +- extensions/cortex-sync/package.json | 2 +- extensions/diagnostics-otel/package.json | 2 +- extensions/discord/package.json | 2 +- extensions/feishu/package.json | 2 +- .../google-antigravity-auth/package.json | 2 +- .../google-gemini-cli-auth/package.json | 2 +- extensions/googlechat/package.json | 2 +- extensions/imessage/package.json | 2 +- .../interactive-permissions/package.json | 2 +- extensions/iot-bridge/package.json | 2 +- extensions/irc/package.json | 2 +- extensions/line/package.json | 2 +- extensions/llm-hooks/package.json | 2 +- extensions/llm-task/package.json | 2 +- extensions/lobster/package.json | 2 +- extensions/lsp-bridge/package.json | 2 +- extensions/matrix/CHANGELOG.md | 6 +++ extensions/matrix/package.json | 2 +- extensions/mattermost/package.json | 2 +- extensions/mcp-client/package.json | 2 +- extensions/mcp-server/package.json | 2 +- extensions/memory-core/package.json | 2 +- extensions/memory-lancedb/package.json | 2 +- extensions/memory-semantic/config.ts | 6 +++ extensions/memory-semantic/cortex-sidecar.ts | 12 +++++- extensions/memory-semantic/package.json | 2 +- extensions/minimax-portal-auth/package.json | 2 +- extensions/msteams/CHANGELOG.md | 6 +++ extensions/msteams/package.json | 2 +- extensions/nextcloud-talk/package.json | 2 +- extensions/nostr/CHANGELOG.md | 6 +++ extensions/nostr/package.json | 2 +- extensions/open-prose/package.json | 2 +- .../semantic-observability/package.json | 2 +- extensions/semantic-skills/package.json | 2 +- extensions/shared/cortex-config.ts | 5 +++ extensions/shared/cortex-update-check.ts | 43 +++++++++++++++++++ extensions/shared/cortex-version.ts | 2 +- extensions/signal/package.json | 2 +- extensions/skill-hub/package.json | 2 +- extensions/slack/package.json | 2 +- extensions/telegram/package.json | 2 +- extensions/tlon/package.json | 2 +- extensions/token-economy/package.json | 2 +- extensions/twitch/CHANGELOG.md | 6 +++ extensions/twitch/package.json | 2 +- extensions/voice-call/CHANGELOG.md | 6 +++ extensions/voice-call/package.json | 2 +- extensions/whatsapp/package.json | 2 +- extensions/zalo/CHANGELOG.md | 6 +++ extensions/zalo/package.json | 2 +- extensions/zalouser/CHANGELOG.md | 6 +++ extensions/zalouser/package.json | 2 +- package.json | 2 +- 62 files changed, 158 insertions(+), 52 deletions(-) diff --git a/extensions/agent-mesh/package.json b/extensions/agent-mesh/package.json index 51c41682..1c319e84 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.9", + "version": "0.1.10", "private": true, "description": "Mayros multi-agent coordination mesh with shared namespaces, delegation, and knowledge fusion", "type": "module", diff --git a/extensions/analytics/package.json b/extensions/analytics/package.json index 2a9d076f..34e3941a 100644 --- a/extensions/analytics/package.json +++ b/extensions/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-analytics", - "version": "0.1.9", + "version": "0.1.10", "private": true, "type": "module", "main": "index.ts", diff --git a/extensions/bash-sandbox/package.json b/extensions/bash-sandbox/package.json index e89e0149..8b3da022 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.9", + "version": "0.1.10", "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 1e46ed17..2ea0bdac 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-bluebubbles", - "version": "0.1.9", + "version": "0.1.10", "description": "Mayros BlueBubbles channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/ci-plugin/package.json b/extensions/ci-plugin/package.json index cae7cff9..dcfc9340 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.9", + "version": "0.1.10", "description": "CI/CD pipeline integration for Mayros — GitHub Actions and GitLab CI providers", "type": "module", "dependencies": { diff --git a/extensions/code-indexer/package.json b/extensions/code-indexer/package.json index aeffa01a..0c5c771a 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.9", + "version": "0.1.10", "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/package.json b/extensions/code-tools/package.json index b2aba150..74374de8 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.9", + "version": "0.1.10", "private": true, "type": "module", "dependencies": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index aafbea4a..3db99f8c 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.9", + "version": "0.1.10", "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 3b293bda..3e2ee156 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.9", + "version": "0.1.10", "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 5238f1bc..c115ebf3 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.9", + "version": "0.1.10", "description": "Mayros diagnostics OpenTelemetry exporter", "license": "MIT", "type": "module", diff --git a/extensions/discord/package.json b/extensions/discord/package.json index af477ddd..ce4783bc 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-discord", - "version": "0.1.9", + "version": "0.1.10", "description": "Mayros Discord channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index ed2e6fa6..5c3fd660 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-feishu", - "version": "0.1.9", + "version": "0.1.10", "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 62b1a1ad..c806dd46 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.9", + "version": "0.1.10", "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 e22d7ee5..944a8ba9 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.9", + "version": "0.1.10", "private": true, "description": "Mayros Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 7f60ef9b..c3ff66c5 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-googlechat", - "version": "0.1.9", + "version": "0.1.10", "private": true, "description": "Mayros Google Chat channel plugin", "type": "module", diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index d60851d5..bdea09f3 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-imessage", - "version": "0.1.9", + "version": "0.1.10", "private": true, "description": "Mayros iMessage channel plugin", "type": "module", diff --git a/extensions/interactive-permissions/package.json b/extensions/interactive-permissions/package.json index 62517ddd..cc82ffcc 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.9", + "version": "0.1.10", "private": true, "description": "Runtime permission dialogs, bash intent classification, policy persistence, and audit trail", "type": "module", diff --git a/extensions/iot-bridge/package.json b/extensions/iot-bridge/package.json index 0d5e2fc7..83ee77f1 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.9", + "version": "0.1.10", "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 ae25c5cc..6463b416 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-irc", - "version": "0.1.9", + "version": "0.1.10", "description": "Mayros IRC channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/line/package.json b/extensions/line/package.json index 6aef2724..18ddd8e4 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-line", - "version": "0.1.9", + "version": "0.1.10", "private": true, "description": "Mayros LINE channel plugin", "type": "module", diff --git a/extensions/llm-hooks/package.json b/extensions/llm-hooks/package.json index a3701dc1..089ef312 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.9", + "version": "0.1.10", "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 5f644b18..41210058 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.9", + "version": "0.1.10", "private": true, "description": "Mayros JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 54a60531..aa7c62ab 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-lobster", - "version": "0.1.9", + "version": "0.1.10", "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 f3d2b2f6..ca4a08fc 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.9", + "version": "0.1.10", "description": "Cortex-backed language server bridge for Mayros — hover, diagnostics, go-to-definition", "type": "module", "dependencies": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 0b672c05..f7c03959 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.10 + +### Changes + +- Version alignment with core Mayros release numbers. + ## 0.1.9 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index eea372c8..209d73a0 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-matrix", - "version": "0.1.9", + "version": "0.1.10", "description": "Mayros Matrix channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index bea4b274..9d895f6e 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-mattermost", - "version": "0.1.9", + "version": "0.1.10", "private": true, "description": "Mayros Mattermost channel plugin", "type": "module", diff --git a/extensions/mcp-client/package.json b/extensions/mcp-client/package.json index 5ba6bfcf..b914226c 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.9", + "version": "0.1.10", "private": true, "description": "MCP server client with multi-transport support and Cortex tool registry", "type": "module", diff --git a/extensions/mcp-server/package.json b/extensions/mcp-server/package.json index b54bdf15..05f7ad77 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.9", + "version": "0.1.10", "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 ddaf870c..9c363068 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.9", + "version": "0.1.10", "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 f091206b..73f94321 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.9", + "version": "0.1.10", "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 fd98c1d2..b1f6c22f 100644 --- a/extensions/memory-semantic/config.ts +++ b/extensions/memory-semantic/config.ts @@ -172,6 +172,12 @@ export const semanticMemoryConfigSchema = { label: "Auto-Start Cortex", help: "Automatically start the Cortex sidecar process", }, + "cortex.dataDir": { + label: "Cortex Data Directory", + placeholder: "~/.mayros/cortex-data", + advanced: true, + help: "Directory for persistent Cortex data (graph database, Ineru snapshots)", + }, "cortex.authToken": { label: "Cortex Auth Token", sensitive: true, diff --git a/extensions/memory-semantic/cortex-sidecar.ts b/extensions/memory-semantic/cortex-sidecar.ts index 353faffc..63f76a97 100644 --- a/extensions/memory-semantic/cortex-sidecar.ts +++ b/extensions/memory-semantic/cortex-sidecar.ts @@ -140,10 +140,20 @@ export class CortexSidecar { // ---------- internals ---------- + /** Resolves the data directory, creating it if necessary. */ + private resolveDataDir(): string { + const dir = this.config.dataDir ?? join(homedir(), ".mayros", "cortex-data"); + mkdirSync(dir, { recursive: true }); + return dir; + } + private async spawn(binaryPath: string): Promise { this._status = "starting"; - const args = ["--host", this.config.host, "--port", String(this.config.port)]; + const dataDir = this.resolveDataDir(); + const dbPath = join(dataDir, "graph.sled"); + + const args = ["--host", this.config.host, "--port", String(this.config.port), "--db", dbPath]; // P2P flag forwarding (B1): map CortexConfig.p2p to CLI flags if (this.config.p2p?.enabled) { diff --git a/extensions/memory-semantic/package.json b/extensions/memory-semantic/package.json index 7ba8766a..38b35506 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.9", + "version": "0.1.10", "private": true, "description": "Mayros semantic memory plugin via AIngle Cortex sidecar (RDF triples, identity graph, Ineru STM/LTM)", "type": "module", diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index f94a73fc..4beebac7 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.9", + "version": "0.1.10", "private": true, "description": "Mayros MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index f365c8a1..9c47c1de 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.10 + +### Changes + +- Version alignment with core Mayros release numbers. + ## 0.1.9 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 3f36bd93..d24b2281 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-msteams", - "version": "0.1.9", + "version": "0.1.10", "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 968a6214..1baf439d 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.9", + "version": "0.1.10", "description": "Mayros Nextcloud Talk channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index dd9c4691..81b8c5c8 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.10 + +### Changes + +- Version alignment with core Mayros release numbers. + ## 0.1.9 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 0a1af2d0..89dbd61d 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-nostr", - "version": "0.1.9", + "version": "0.1.10", "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 096f8f77..1ef58cf3 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.9", + "version": "0.1.10", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/semantic-observability/package.json b/extensions/semantic-observability/package.json index 9da6e562..960c5405 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.9", + "version": "0.1.10", "private": true, "description": "Mayros semantic observability plugin — structured tracing of agent decisions as RDF events", "type": "module", diff --git a/extensions/semantic-skills/package.json b/extensions/semantic-skills/package.json index eea43606..3024cd6f 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.9", + "version": "0.1.10", "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-config.ts b/extensions/shared/cortex-config.ts index 6593f09e..aab193ec 100644 --- a/extensions/shared/cortex-config.ts +++ b/extensions/shared/cortex-config.ts @@ -28,6 +28,8 @@ export type CortexConfig = { requireAuth?: boolean; strictVersionCheck?: boolean; p2p?: P2pConfig; + /** Directory for Cortex persistent data (graph.sled, ineru.snapshot). */ + dataDir?: string; }; // ============================================================================ @@ -78,6 +80,7 @@ export function parseCortexConfig(raw: unknown): CortexConfig { "requireAuth", "strictVersionCheck", "p2p", + "dataDir", ], "cortex config", ); @@ -97,6 +100,7 @@ export function parseCortexConfig(raw: unknown): CortexConfig { const requireAuth = cortex.requireAuth === true; const strictVersionCheck = cortex.strictVersionCheck === true; const p2p = parseP2pConfig(cortex.p2p); + const dataDir = typeof cortex.dataDir === "string" ? cortex.dataDir : undefined; return { host, @@ -108,6 +112,7 @@ export function parseCortexConfig(raw: unknown): CortexConfig { requireAuth, strictVersionCheck, p2p, + dataDir, }; } diff --git a/extensions/shared/cortex-update-check.ts b/extensions/shared/cortex-update-check.ts index 37794e66..83b00c1b 100644 --- a/extensions/shared/cortex-update-check.ts +++ b/extensions/shared/cortex-update-check.ts @@ -22,6 +22,45 @@ import { } from "./cortex-binary-locator.js"; import { REQUIRED_CORTEX_VERSION } from "./cortex-version.js"; +// --------------------------------------------------------------------------- +// Graceful flush before binary replacement +// --------------------------------------------------------------------------- + +/** + * Flushes Cortex data to disk before replacing the binary. + * + * Sends POST /api/v1/flush to the running Cortex instance, waits for + * confirmation, then returns. If Cortex is unreachable or the flush + * times out, continues silently (best-effort). + */ +export async function flushCortexBeforeUpdate( + host = "127.0.0.1", + port = 19090, + log: (msg: string) => void = () => {}, +): Promise { + const url = `http://${host}:${port}/api/v1/flush`; + log("Flushing Cortex data before update..."); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(url, { + method: "POST", + signal: controller.signal, + }); + clearTimeout(timeout); + + if (res.ok) { + log("Cortex data flushed successfully"); + } else { + log(`Cortex flush returned ${res.status} — continuing`); + } + } catch { + log("Cortex not reachable for flush — continuing"); + } +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -134,7 +173,11 @@ export async function checkCortexVersion(binaryPath?: string): Promise void = () => {}, + opts?: { cortexHost?: string; cortexPort?: number }, ): Promise { + // Flush running Cortex data before replacing the binary + await flushCortexBeforeUpdate(opts?.cortexHost ?? "127.0.0.1", opts?.cortexPort ?? 19090, log); + const installDir = getDefaultInstallDir(); const binaryName = getBinaryName(); const assetName = getAssetPattern(); diff --git a/extensions/shared/cortex-version.ts b/extensions/shared/cortex-version.ts index ef055cb2..91d6ce09 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.4.0"; +export const REQUIRED_CORTEX_VERSION = "0.4.1"; diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 52791d43..069d8822 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-signal", - "version": "0.1.9", + "version": "0.1.10", "private": true, "description": "Mayros Signal channel plugin", "type": "module", diff --git a/extensions/skill-hub/package.json b/extensions/skill-hub/package.json index 76e68332..7328609b 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.9", + "version": "0.1.10", "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 95a55910..c5591a4c 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-slack", - "version": "0.1.9", + "version": "0.1.10", "private": true, "description": "Mayros Slack channel plugin", "type": "module", diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 5ebd659e..53633ff8 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-telegram", - "version": "0.1.9", + "version": "0.1.10", "private": true, "description": "Mayros Telegram channel plugin", "type": "module", diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 653eca36..2912e087 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-tlon", - "version": "0.1.9", + "version": "0.1.10", "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 f6385a36..73f972d1 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.9", + "version": "0.1.10", "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/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 107ef2e7..bf4400a5 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.10 + +### Changes + +- Version alignment with core Mayros release numbers. + ## 0.1.9 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 2e210f37..d1f22591 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-twitch", - "version": "0.1.9", + "version": "0.1.10", "private": true, "description": "Mayros Twitch channel plugin", "type": "module", diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 1fe41245..5db28e4a 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.10 + +### Changes + +- Version alignment with core Mayros release numbers. + ## 0.1.9 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 7962cc9d..3b9282da 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.9", + "version": "0.1.10", "description": "Mayros voice-call plugin", "license": "MIT", "type": "module", diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 625f2873..a425f808 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-whatsapp", - "version": "0.1.9", + "version": "0.1.10", "private": true, "description": "Mayros WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index c5d94fb3..ad190d05 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.10 + +### Changes + +- Version alignment with core Mayros release numbers. + ## 0.1.9 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index e9178f3e..dcfbbafe 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-zalo", - "version": "0.1.9", + "version": "0.1.10", "description": "Mayros Zalo channel plugin", "license": "MIT", "type": "module", diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index 61bbff0d..5605caac 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.10 + +### Changes + +- Version alignment with core Mayros release numbers. + ## 0.1.9 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 869a1b56..cbb550d4 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros-zalouser", - "version": "0.1.9", + "version": "0.1.10", "description": "Mayros Zalo Personal Account plugin via zca-cli", "license": "MIT", "type": "module", diff --git a/package.json b/package.json index cec14014..c3e77e27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apilium/mayros", - "version": "0.1.9", + "version": "0.1.10", "description": "Multi-channel AI agent framework with knowledge graph, MCP support, and coding CLI", "keywords": [ "agent", From 5989ff660f08011853f2005639fbe59e0e400b60 Mon Sep 17 00:00:00 2001 From: It Apilium Date: Mon, 9 Mar 2026 15:46:20 +0100 Subject: [PATCH 2/7] fix: graceful sidecar restart and flush before binary update Add flushBeforeStop() to CortexSidecar to POST /api/v1/flush before SIGTERM, increase SIGKILL timeout to 10s, and add restartForUpdate() method. Add onBeforeReplace/onAfterReplace callbacks to installOrUpdateCortex so callers can wire sidecar stop/start around binary replacement. --- extensions/memory-semantic/cortex-sidecar.ts | 38 ++++++++++++++++++-- extensions/shared/cortex-update-check.ts | 22 +++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/extensions/memory-semantic/cortex-sidecar.ts b/extensions/memory-semantic/cortex-sidecar.ts index 63f76a97..efbe7a61 100644 --- a/extensions/memory-semantic/cortex-sidecar.ts +++ b/extensions/memory-semantic/cortex-sidecar.ts @@ -101,6 +101,22 @@ export class CortexSidecar { return this.spawn(binaryPath); } + /** + * Flush Cortex data via REST before stopping the process. + * Best-effort — if Cortex is unreachable, continues silently. + */ + private async flushBeforeStop(): Promise { + try { + const url = `http://${this.config.host}:${this.config.port}/api/v1/flush`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + await fetch(url, { method: "POST", signal: controller.signal }); + clearTimeout(timeout); + } catch { + // best-effort + } + } + async stop(): Promise { // Remove process signal handlers to prevent double-stop this.removeSignalHandlers(); @@ -110,17 +126,20 @@ export class CortexSidecar { return; } + // Flush data via REST before sending SIGTERM + await this.flushBeforeStop(); + const proc = this.process; this.process = null; - // Give it a chance to shut down gracefully + // Give it a chance to shut down gracefully (SIGTERM triggers its own flush + snapshot) proc.kill("SIGTERM"); await new Promise((resolve) => { const timeout = setTimeout(() => { proc.kill("SIGKILL"); resolve(); - }, 5000); + }, 10_000); proc.once("exit", () => { clearTimeout(timeout); @@ -131,6 +150,21 @@ export class CortexSidecar { this._status = "stopped"; } + /** + * Gracefully restart the sidecar after a binary update. + * + * 1. Flush data via REST + * 2. Stop the process (SIGTERM → Cortex flushes + saves snapshot) + * 3. Wait for exit + * 4. Start again with the new binary + */ + async restartForUpdate(): Promise { + console.info("[cortex] restarting sidecar for binary update..."); + this.restartCount = 0; // reset so auto-restart doesn't interfere + await this.stop(); + return this.start(); + } + private removeSignalHandlers(): void { for (const [signal, handler] of this.signalHandlers) { process.removeListener(signal, handler); diff --git a/extensions/shared/cortex-update-check.ts b/extensions/shared/cortex-update-check.ts index 83b00c1b..2b7038f9 100644 --- a/extensions/shared/cortex-update-check.ts +++ b/extensions/shared/cortex-update-check.ts @@ -173,7 +173,14 @@ export async function checkCortexVersion(binaryPath?: string): Promise void = () => {}, - opts?: { cortexHost?: string; cortexPort?: number }, + opts?: { + cortexHost?: string; + cortexPort?: number; + /** Called after download but before replacing the binary — use to stop the sidecar. */ + onBeforeReplace?: () => Promise; + /** Called after the binary is replaced — use to restart the sidecar. */ + onAfterReplace?: () => Promise; + }, ): Promise { // Flush running Cortex data before replacing the binary await flushCortexBeforeUpdate(opts?.cortexHost ?? "127.0.0.1", opts?.cortexPort ?? 19090, log); @@ -204,6 +211,12 @@ export async function installOrUpdateCortex( await downloadFile(asset.browser_download_url, archivePath); log("Download complete."); + // Stop the sidecar before replacing the binary on disk + if (opts?.onBeforeReplace) { + log("Stopping sidecar before binary replacement..."); + await opts.onBeforeReplace(); + } + log("Extracting..."); if (asset.name.endsWith(".zip")) { await extractZip(archivePath, installDir); @@ -239,5 +252,12 @@ export async function installOrUpdateCortex( const version = getCortexBinaryVersion(binaryPath); log(`Installed: ${binaryPath} (v${version ?? "unknown"})`); + + // Restart the sidecar with the new binary + if (opts?.onAfterReplace) { + log("Restarting sidecar with new binary..."); + await opts.onAfterReplace(); + } + return true; } From bc9b1f240d9e49e8457fef0478f08f78f1937bf7 Mon Sep 17 00:00:00 2001 From: It Apilium Date: Mon, 9 Mar 2026 15:46:36 +0100 Subject: [PATCH 3/7] chore: remove unused homedir import from cortex-update-check --- extensions/shared/cortex-update-check.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/shared/cortex-update-check.ts b/extensions/shared/cortex-update-check.ts index 2b7038f9..10870179 100644 --- a/extensions/shared/cortex-update-check.ts +++ b/extensions/shared/cortex-update-check.ts @@ -10,7 +10,6 @@ import { execFileSync } from "node:child_process"; 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"; import { pipeline } from "node:stream/promises"; From defceb13be3c48a5e1cd8ce86a99b9937a401c62 Mon Sep 17 00:00:00 2001 From: It Apilium Date: Mon, 9 Mar 2026 15:46:42 +0100 Subject: [PATCH 4/7] fix: hide internal AI instructions from slash command display Add displayText parameter to sendMessage so TUI shows a clean command label (e.g. /kg, /trace stats) instead of exposing raw AI instructions to the user. Affected commands: /tools, /kg, /trace, /team, /tasks, /workflow, /rules, /mailbox, /sync. --- src/tui/tui-command-handlers.test.ts | 48 ++++++++++------------------ src/tui/tui-command-handlers.ts | 18 +++++++++-- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index a2cd7523..de00ed0f 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -358,52 +358,40 @@ describe("tui command handlers", () => { expect(addUser).toHaveBeenCalledWith("/plan start"); }); - it("/kg shows summary when no query provided", async () => { + it("/kg shows clean display text when no query provided", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/kg"); - expect(addUser).toHaveBeenCalledWith( - expect.stringContaining("Show a knowledge graph summary."), - ); + expect(addUser).toHaveBeenCalledWith("/kg"); }); - it("/kg sends search message when query provided", async () => { + it("/kg shows clean display text when query provided", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/kg auth flow"); - expect(addUser).toHaveBeenCalledWith( - expect.stringContaining("Search the knowledge graph for: auth flow"), - ); + expect(addUser).toHaveBeenCalledWith("/kg auth flow"); }); - it("/trace sends trace message", async () => { + it("/trace shows clean display text", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/trace stats"); - expect(addUser).toHaveBeenCalledWith( - "Use the trace_stats tool with no arguments to show aggregated observability statistics for the current agent.", - ); + expect(addUser).toHaveBeenCalledWith("/trace stats"); }); - it("/team sends dashboard message", async () => { + it("/team shows clean display text", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/team"); - expect(addUser).toHaveBeenCalledWith( - "Use the mesh_team_dashboard tool with no arguments to show the team dashboard with current agent status and activity.", - ); + expect(addUser).toHaveBeenCalledWith("/team"); }); - it("/tasks sends tasks message", async () => { + it("/tasks shows clean display text", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/tasks"); - expect(addUser).toHaveBeenCalledWith( - "Use the agent_list_background_tasks tool with no arguments to list all background agent tasks and their current status.", - ); + expect(addUser).toHaveBeenCalledWith("/tasks"); }); - it("/workflow without args lists workflows", async () => { + it("/workflow without args shows clean display text", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/workflow"); - expect(addUser).toHaveBeenCalledWith( - 'Use the mesh_run_workflow tool with action "list" to list available workflows and their status.', - ); + expect(addUser).toHaveBeenCalledWith("/workflow"); }); it("/workflow with args forwards them", async () => { @@ -412,20 +400,16 @@ describe("tui command handlers", () => { expect(addUser).toHaveBeenCalledWith("/workflow run code-review"); }); - it("/rules sends rules message", async () => { + it("/rules shows clean display text", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/rules"); - expect(addUser).toHaveBeenCalledWith( - 'Use the semantic_memory_recall tool with subject pattern "rule:*" to list all active rules.', - ); + expect(addUser).toHaveBeenCalledWith("/rules"); }); - it("/mailbox without args checks inbox", async () => { + it("/mailbox without args shows clean display text", async () => { const { handleCommand, addUser } = setupEcosystem(); await handleCommand("/mailbox"); - expect(addUser).toHaveBeenCalledWith( - "Use the agent_check_inbox tool with no arguments to check the inbox for new messages and show unread count.", - ); + expect(addUser).toHaveBeenCalledWith("/mailbox"); }); it("/onboard launches wizard", async () => { diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 3e738e5d..2b504668 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -998,6 +998,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { 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.", + "/tools", ); break; } @@ -1011,9 +1012,10 @@ export function createCommandHandlers(context: CommandHandlerContext) { if (!args) { await sendMessage( `Show a knowledge graph summary. ${kgHint} Show categories, triple counts, and recent entries.`, + "/kg", ); } else { - await sendMessage(`Search the knowledge graph for: ${args}. ${kgHint}`); + await sendMessage(`Search the knowledge graph for: ${args}. ${kgHint}`, `/kg ${args}`); } break; } @@ -1022,15 +1024,18 @@ export function createCommandHandlers(context: CommandHandlerContext) { if (subCmd === "stats") { await sendMessage( "Use the trace_stats tool with no arguments to show aggregated observability statistics for the current agent.", + "/trace stats", ); } else if (subCmd === "explain" && args.includes(" ")) { const eventId = args.slice("explain".length).trim(); await sendMessage( `Use the trace_explain tool with eventId "${eventId}" to trace the causal chain for that event.`, + `/trace explain ${eventId}`, ); } else { await sendMessage( "Use the trace_query tool with no arguments to list recent trace events for the current agent.", + "/trace events", ); } break; @@ -1038,12 +1043,14 @@ export function createCommandHandlers(context: CommandHandlerContext) { case "team": { await sendMessage( "Use the mesh_team_dashboard tool with no arguments to show the team dashboard with current agent status and activity.", + "/team", ); break; } case "tasks": { await sendMessage( "Use the agent_list_background_tasks tool with no arguments to list all background agent tasks and their current status.", + "/tasks", ); break; } @@ -1051,6 +1058,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { if (!args) { await sendMessage( 'Use the mesh_run_workflow tool with action "list" to list available workflows and their status.', + "/workflow", ); } else { await sendMessage(`/workflow ${args}`); @@ -1061,10 +1069,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { if (args) { await sendMessage( `Use the semantic_memory_recall tool to search for rules matching: ${args}`, + `/rules ${args}`, ); } else { await sendMessage( 'Use the semantic_memory_recall tool with subject pattern "rule:*" to list all active rules.', + "/rules", ); } break; @@ -1073,6 +1083,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { if (!args) { await sendMessage( "Use the agent_check_inbox tool with no arguments to check the inbox for new messages and show unread count.", + "/mailbox", ); } else { await sendMessage(`/mailbox ${args}`); @@ -1286,6 +1297,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { case "sync": { await sendMessage( "Use the cortex_sync_status tool with no arguments to show Cortex peer sync status and statistics.", + "/sync", ); break; } @@ -1319,9 +1331,9 @@ export function createCommandHandlers(context: CommandHandlerContext) { tui.requestRender(); }; - const sendMessage = async (text: string) => { + const sendMessage = async (text: string, displayText?: string) => { try { - chatLog.addUser(text); + chatLog.addUser(displayText ?? text); tui.requestRender(); const style = (state.outputStyle ?? "standard") as OutputStyle; const styledText = applyOutputStyle(text, style); From 15054c0188801bf0679227266bd60045a9834d53 Mon Sep 17 00:00:00 2001 From: It Apilium Date: Mon, 9 Mar 2026 16:20:52 +0100 Subject: [PATCH 5/7] fix: complete sidecar lifecycle hardening (10 gaps) Addresses all identified gaps in Cortex sidecar management: - Capture stderr in ring buffer for crash diagnostics (getLastLogs) - Add stopping flag to prevent auto-restart during deliberate stop - Lock file in dataDir to prevent concurrent sidecar instances - Port availability check before spawn (detect port conflicts) - Auto-install binary on first start when not explicitly configured - Skip health monitor restart while sidecar is still starting - Drain pending write queue before stopping sidecar - Secrets follow dataDir with migration from ~/.mayros/ - Wire lifecycle callbacks from plugin to update-runner via registry - Pass actual host/port from config to flush-before-update --- .../memory-semantic/cortex-sidecar.test.ts | 18 +- extensions/memory-semantic/cortex-sidecar.ts | 208 ++++++++++++++++-- extensions/memory-semantic/index.ts | 45 +++- .../shared/cortex-lifecycle-registry.ts | 49 +++++ src/infra/update-runner.ts | 30 ++- 5 files changed, 326 insertions(+), 24 deletions(-) create mode 100644 extensions/shared/cortex-lifecycle-registry.ts diff --git a/extensions/memory-semantic/cortex-sidecar.test.ts b/extensions/memory-semantic/cortex-sidecar.test.ts index e3488133..09f18aca 100644 --- a/extensions/memory-semantic/cortex-sidecar.test.ts +++ b/extensions/memory-semantic/cortex-sidecar.test.ts @@ -34,6 +34,19 @@ vi.mock("node:fs", () => ({ readFileSync: mockState.readFileSyncFn, writeFileSync: mockState.writeFileSyncFn, mkdirSync: mockState.mkdirSyncFn, + unlinkSync: vi.fn(), +})); + +// Mock node:net to prevent real TCP connections during port checks +vi.mock("node:net", () => ({ + createConnection: vi.fn(() => { + // Simulate ECONNREFUSED (port is free) + const emitter = new (require("node:events").EventEmitter)(); + emitter.setTimeout = vi.fn(); + emitter.destroy = vi.fn(); + process.nextTick(() => emitter.emit("error", new Error("ECONNREFUSED"))); + return emitter; + }), })); vi.mock("node:crypto", () => ({ @@ -176,7 +189,7 @@ describe("CortexSidecar", () => { removeListenerSpy.mockRestore(); }); - it("drains stdout and stderr on spawn", async () => { + it("drains stdout and captures stderr on spawn", async () => { mockState.healthReturnValues = [false, true]; const sidecar = new CortexSidecar({ @@ -191,7 +204,8 @@ describe("CortexSidecar", () => { const fakeProc = mockState.fakeProc as FakeChildProcess; expect(fakeProc).not.toBeNull(); expect(fakeProc.stdout.resume).toHaveBeenCalled(); - expect(fakeProc.stderr.resume).toHaveBeenCalled(); + // stderr is now captured via .on('data') ring buffer + expect(fakeProc.stderr.listenerCount("data")).toBeGreaterThan(0); await sidecar.stop(); }); diff --git a/extensions/memory-semantic/cortex-sidecar.ts b/extensions/memory-semantic/cortex-sidecar.ts index efbe7a61..d9b3b134 100644 --- a/extensions/memory-semantic/cortex-sidecar.ts +++ b/extensions/memory-semantic/cortex-sidecar.ts @@ -10,7 +10,8 @@ import { randomBytes } from "node:crypto"; import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { createConnection } from "node:net"; import { homedir } from "node:os"; import { join } from "node:path"; import { locateCortexBinary, getCortexBinaryVersion } from "../shared/cortex-binary-locator.js"; @@ -26,7 +27,11 @@ export class CortexSidecar { private readonly client: CortexClient; private signalHandlers = new Map void>(); private restartCount = 0; + private stopping = false; + private lockPath: string | null = null; + private stderrBuffer: string[] = []; private static readonly MAX_RESTARTS = 3; + private static readonly STDERR_BUFFER_SIZE = 50; constructor(private readonly config: CortexConfig) { this.client = new CortexClient(config); @@ -36,11 +41,18 @@ export class CortexSidecar { return this._status; } + /** Returns the last lines of sidecar stderr output for diagnostics. */ + getLastLogs(): string[] { + return [...this.stderrBuffer]; + } + /** * Ensure Cortex is reachable, spawning the process if needed. * Returns `true` when healthy, `false` on failure. */ async start(): Promise { + this.stopping = false; + // Already running externally? if (await this.client.isHealthy()) { this._status = "running"; @@ -59,8 +71,24 @@ export class CortexSidecar { } if (!binaryPath || !existsSync(binaryPath)) { - this._status = "failed"; - return false; + // Only auto-install when the binary was auto-detected (not explicitly configured) + if (!this.config.binaryPath) { + console.info("[cortex] binary not found — attempting auto-install..."); + try { + const { installOrUpdateCortex } = await import("../shared/cortex-update-check.js"); + await installOrUpdateCortex((msg) => console.info(`[cortex] ${msg}`)); + binaryPath = await locateCortexBinary(); + } catch (err) { + console.warn(`[cortex] auto-install failed: ${err instanceof Error ? err.message : err}`); + } + } + if (!binaryPath || !existsSync(binaryPath)) { + console.error( + "[cortex] no binary available. Run: mayros update (or download from https://github.com/ApiliumCode/aingle/releases)", + ); + this._status = "failed"; + return false; + } } // Warn (but don't block) if the installed binary is older than required @@ -118,11 +146,15 @@ export class CortexSidecar { } async stop(): Promise { + this.stopping = true; + // Remove process signal handlers to prevent double-stop this.removeSignalHandlers(); if (!this.process) { this._status = "stopped"; + this.releaseLock(); + this.stopping = false; return; } @@ -148,6 +180,8 @@ export class CortexSidecar { }); this._status = "stopped"; + this.releaseLock(); + this.stopping = false; } /** @@ -160,9 +194,9 @@ export class CortexSidecar { */ async restartForUpdate(): Promise { console.info("[cortex] restarting sidecar for binary update..."); - this.restartCount = 0; // reset so auto-restart doesn't interfere + this.restartCount = 0; await this.stop(); - return this.start(); + return this.start(); // start() resets stopping = false } private removeSignalHandlers(): void { @@ -183,10 +217,27 @@ export class CortexSidecar { private async spawn(binaryPath: string): Promise { this._status = "starting"; + this.stderrBuffer = []; const dataDir = this.resolveDataDir(); const dbPath = join(dataDir, "graph.sled"); + // Acquire a lock file to prevent multiple sidecars on the same dataDir + if (!this.acquireLock(dataDir)) { + console.error( + `[cortex] another sidecar is using ${dataDir}. Stop it first or use a different dataDir.`, + ); + this._status = "failed"; + return false; + } + + // Check if the port is already in use by something other than Cortex + if (!(await this.ensurePortAvailable())) { + this.releaseLock(); + this._status = "failed"; + return false; + } + const args = ["--host", this.config.host, "--port", String(this.config.port), "--db", dbPath]; // P2P flag forwarding (B1): map CortexConfig.p2p to CLI flags @@ -200,7 +251,7 @@ export class CortexSidecar { } } - const secrets = ensureCortexSecrets(); + const secrets = ensureCortexSecrets(this.config.dataDir); try { this.process = spawn(binaryPath, args, { @@ -214,14 +265,25 @@ export class CortexSidecar { }); } catch { this._status = "failed"; + this.releaseLock(); return false; } - // Drain stdout/stderr to prevent child process blocking on full pipe buffers + // Drain stdout to prevent child process blocking on full pipe buffers this.process.stdout?.resume(); - this.process.stderr?.resume(); - // Handle unexpected exit with one auto-restart attempt + // Capture stderr for diagnostics (ring buffer of last N lines) + this.process.stderr?.on("data", (chunk: Buffer) => { + const lines = chunk.toString().split("\n").filter(Boolean); + for (const line of lines) { + this.stderrBuffer.push(line); + if (this.stderrBuffer.length > CortexSidecar.STDERR_BUFFER_SIZE) { + this.stderrBuffer.shift(); + } + } + }); + + // Handle unexpected exit — auto-restart only if not deliberately stopping this.process.once("exit", (code) => { if (this._status === "running" || this._status === "starting") { this._status = code === 0 ? "stopped" : "failed"; @@ -229,6 +291,9 @@ export class CortexSidecar { this.process = null; this.removeSignalHandlers(); + // Skip auto-restart if this was a deliberate stop/update + if (this.stopping) return; + // Auto-restart on unexpected crash (up to MAX_RESTARTS attempts) if (this._status === "failed" && this.restartCount < CortexSidecar.MAX_RESTARTS) { this.restartCount += 1; @@ -237,6 +302,9 @@ export class CortexSidecar { console.warn( `[cortex] sidecar crashed, restart attempt ${attempt}/${CortexSidecar.MAX_RESTARTS} in ${delayMs}ms...`, ); + if (this.stderrBuffer.length > 0) { + console.warn(`[cortex] last stderr: ${this.stderrBuffer.slice(-3).join(" | ")}`); + } setTimeout(() => { void this.spawn(binaryPath).then((ok) => { if (ok) { @@ -276,6 +344,85 @@ export class CortexSidecar { return healthy; } + /** + * Check if the configured port is available. If something is already listening + * and it's Cortex (healthy), treat as external instance. Otherwise fail. + */ + private async ensurePortAvailable(): Promise { + const portInUse = await new Promise((resolve) => { + const socket = createConnection({ host: this.config.host, port: this.config.port }); + socket.setTimeout(1000); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("timeout", () => { + socket.destroy(); + resolve(false); + }); + socket.once("error", () => { + socket.destroy(); + resolve(false); + }); + }); + + if (!portInUse) return true; // port is free + + // Something is listening — check if it's Cortex + if (await this.client.isHealthy()) { + this._status = "running"; + console.info(`[cortex] external Cortex already running on port ${this.config.port}`); + return false; // don't spawn, but not a failure — caller checks status + } + + console.error( + `[cortex] port ${this.config.port} is in use by another process. Change cortex.port in config.`, + ); + return false; + } + + /** Acquire a lock file in the data directory. Returns true on success. */ + private acquireLock(dataDir: string): boolean { + const lockFile = join(dataDir, ".cortex.lock"); + try { + // Exclusive create — fails if file exists + writeFileSync(lockFile, String(process.pid), { flag: "wx" }); + this.lockPath = lockFile; + return true; + } catch { + // File exists — check if the PID is still alive + try { + const existingPid = Number(readFileSync(lockFile, "utf-8").trim()); + if (existingPid && !isNaN(existingPid)) { + try { + process.kill(existingPid, 0); // probe — throws if dead + return false; // process is alive, lock is valid + } catch { + // Process is dead — stale lock, reclaim it + } + } + unlinkSync(lockFile); + writeFileSync(lockFile, String(process.pid), { flag: "wx" }); + this.lockPath = lockFile; + return true; + } catch { + return false; + } + } + } + + /** Release the lock file. */ + private releaseLock(): void { + if (this.lockPath) { + try { + unlinkSync(this.lockPath); + } catch { + // best-effort + } + this.lockPath = null; + } + } + private async waitForHealthy(): Promise { const maxWaitMs = 10_000; const start = Date.now(); @@ -310,12 +457,14 @@ 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); +const DEFAULT_SECRETS_DIR = join(homedir(), ".mayros"); + +function resolveSecretsPath(dataDir?: string): string { + const dir = dataDir ?? DEFAULT_SECRETS_DIR; + return join(dir, SECRETS_FILENAME); } -export function ensureCortexSecrets(): CortexSecrets { +export function ensureCortexSecrets(dataDir?: string): CortexSecrets { // Env vars take precedence over persisted file const envJwt = process.env.AINGLE_JWT_SECRET?.trim(); const envAdmin = process.env.AINGLE_ADMIN_PASSWORD?.trim(); @@ -324,8 +473,8 @@ export function ensureCortexSecrets(): CortexSecrets { return { jwtSecret: envJwt, adminPassword: envAdmin }; } - // Try to load from persisted file - const secretsPath = resolveSecretsPath(); + // Try to load from the target directory + const secretsPath = resolveSecretsPath(dataDir); let persisted: Partial = {}; if (existsSync(secretsPath)) { @@ -339,16 +488,37 @@ export function ensureCortexSecrets(): CortexSecrets { } } + // Migrate from default location if dataDir is set and secrets only exist in ~/.mayros + if (dataDir && !persisted.jwtSecret) { + const defaultPath = resolveSecretsPath(); + if (existsSync(defaultPath)) { + try { + const raw = readFileSync(defaultPath, "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 — 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, - }); + const dir = dataDir ?? DEFAULT_SECRETS_DIR; + mkdirSync(dir, { recursive: true }); + writeFileSync( + resolveSecretsPath(dataDir), + JSON.stringify({ jwtSecret, adminPassword }, null, 2), + { + mode: 0o600, + }, + ); } catch { // Non-fatal: secrets work for this session even if persistence fails } diff --git a/extensions/memory-semantic/index.ts b/extensions/memory-semantic/index.ts index f554e7fd..d484c34d 100644 --- a/extensions/memory-semantic/index.ts +++ b/extensions/memory-semantic/index.ts @@ -138,8 +138,12 @@ 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")) { + // Auto-restart sidecar if it crashed — skip if still starting (avoids double-spawn race) + if ( + cfg.cortex.autoStart && + sidecar.status !== "starting" && + (sidecar.status === "failed" || sidecar.status === "stopped") + ) { api.logger.info("memory-semantic: attempting Cortex sidecar restart..."); void sidecar.start().then((ok) => { if (ok) { @@ -1989,9 +1993,46 @@ const semanticMemoryPlugin = { } }); healthMonitor.start(); + + // Register lifecycle callbacks so update-runner can coordinate sidecar restart + const { registerCortexLifecycleCallbacks } = + await import("../shared/cortex-lifecycle-registry.js"); + registerCortexLifecycleCallbacks({ + host: cfg.cortex.host, + port: cfg.cortex.port, + onBeforeReplace: async () => { + healthMonitor.stop(); + try { + await writeQueue.drain(); + } catch { + /* best-effort */ + } + await sidecar.stop(); + }, + onAfterReplace: async () => { + const ok = await sidecar.start(); + cortexAvailable = ok; + if (ok) healthMonitor.start(); + }, + }); }, async stop() { + // Clear lifecycle callbacks so update-runner doesn't call stale references + try { + const { clearCortexLifecycleCallbacks } = + await import("../shared/cortex-lifecycle-registry.js"); + clearCortexLifecycleCallbacks(); + } catch { + /* best-effort */ + } healthMonitor.stop(); + // Drain pending writes before stopping the queue and sidecar + try { + const drained = await writeQueue.drain(); + if (drained > 0) api.logger.info(`memory-semantic: drained ${drained} pending writes`); + } catch { + // best-effort — don't block shutdown + } await writeQueue.stop(); client.destroy(); await sidecar.stop(); diff --git a/extensions/shared/cortex-lifecycle-registry.ts b/extensions/shared/cortex-lifecycle-registry.ts new file mode 100644 index 00000000..5c38945f --- /dev/null +++ b/extensions/shared/cortex-lifecycle-registry.ts @@ -0,0 +1,49 @@ +/** + * Cortex Lifecycle Callback Registry + * + * Bridges the plugin layer (memory-semantic owns the sidecar) with the + * infra layer (update-runner needs to stop/start the sidecar during + * binary replacement). The plugin registers callbacks at load time; + * update-runner reads them when performing a Cortex binary update. + */ + +const REGISTRY_KEY = Symbol.for("mayros.cortexLifecycleCallbacks"); + +export type CortexLifecycleCallbacks = { + /** Called after download but before replacing the binary on disk. */ + onBeforeReplace: () => Promise; + /** Called after the new binary is in place. */ + onAfterReplace: () => Promise; + /** Host where Cortex is listening (for flush). */ + host: string; + /** Port where Cortex is listening (for flush). */ + port: number; +}; + +type GlobalWithCallbacks = typeof globalThis & { + [REGISTRY_KEY]?: CortexLifecycleCallbacks | null; +}; + +/** + * Register lifecycle callbacks. Called by the memory-semantic plugin + * during service start so update-runner can coordinate sidecar restarts. + */ +export function registerCortexLifecycleCallbacks(callbacks: CortexLifecycleCallbacks): void { + (globalThis as GlobalWithCallbacks)[REGISTRY_KEY] = callbacks; +} + +/** + * Retrieve the registered lifecycle callbacks, or null if no plugin + * has registered (e.g. memory-semantic is not loaded). + */ +export function getCortexLifecycleCallbacks(): CortexLifecycleCallbacks | null { + return (globalThis as GlobalWithCallbacks)[REGISTRY_KEY] ?? null; +} + +/** + * Clear the registered callbacks. Called by the memory-semantic plugin + * during service stop. + */ +export function clearCortexLifecycleCallbacks(): void { + (globalThis as GlobalWithCallbacks)[REGISTRY_KEY] = null; +} diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 05952c69..ef144069 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -947,11 +947,39 @@ async function maybeCortexUpdate( }; progress?.onStepStart?.(stepInfo); + // Read lifecycle callbacks and host/port from the running plugin (if loaded) + let lifecycleCallbacks: { + onBeforeReplace?: () => Promise; + onAfterReplace?: () => Promise; + host?: string; + port?: number; + } = {}; + try { + const { getCortexLifecycleCallbacks } = + await import("../../extensions/shared/cortex-lifecycle-registry.js"); + const callbacks = getCortexLifecycleCallbacks(); + if (callbacks) { + lifecycleCallbacks = callbacks; + } + } catch { + // Registry not available — continue without callbacks + } + const started = Date.now(); let exitCode: number | null = 0; let stderrTail: string | null = null; try { - await installOrUpdateCortex(); + await installOrUpdateCortex( + (msg) => { + stderrTail = msg; // capture last log line for progress reporting + }, + { + cortexHost: lifecycleCallbacks.host, + cortexPort: lifecycleCallbacks.port, + onBeforeReplace: lifecycleCallbacks.onBeforeReplace, + onAfterReplace: lifecycleCallbacks.onAfterReplace, + }, + ); } catch (err: unknown) { exitCode = 1; stderrTail = err instanceof Error ? err.message : String(err); From 3d5181c6fcf7f2cbc53d3d54cda8088fd3381cb0 Mon Sep 17 00:00:00 2001 From: It Apilium Date: Mon, 9 Mar 2026 16:32:05 +0100 Subject: [PATCH 6/7] fix: drain timeout, lock error messages, and external Cortex detection - Add 10s timeout to writeQueue.drain() during update and shutdown to prevent blocking if Cortex is unresponsive - Distinguish permission errors from lock conflicts in acquireLock - Log stale lock reclamation for diagnostics - Fix bug where external Cortex detection returned false instead of true (status was overwritten from "running" to "failed") --- extensions/memory-semantic/cortex-sidecar.ts | 26 ++++++++++++++------ extensions/memory-semantic/index.ts | 13 +++++++--- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/extensions/memory-semantic/cortex-sidecar.ts b/extensions/memory-semantic/cortex-sidecar.ts index d9b3b134..a45f995f 100644 --- a/extensions/memory-semantic/cortex-sidecar.ts +++ b/extensions/memory-semantic/cortex-sidecar.ts @@ -234,8 +234,11 @@ export class CortexSidecar { // Check if the port is already in use by something other than Cortex if (!(await this.ensurePortAvailable())) { this.releaseLock(); - this._status = "failed"; - return false; + // If ensurePortAvailable detected an external Cortex, status is "running" — don't overwrite + if (this._status !== "running") { + this._status = "failed"; + } + return this._status === "running"; } const args = ["--host", this.config.host, "--port", String(this.config.port), "--db", dbPath]; @@ -381,24 +384,33 @@ export class CortexSidecar { return false; } - /** Acquire a lock file in the data directory. Returns true on success. */ + /** + * Acquire a lock file in the data directory. Returns true on success. + * Reclaims stale locks from dead processes automatically. + */ private acquireLock(dataDir: string): boolean { const lockFile = join(dataDir, ".cortex.lock"); try { - // Exclusive create — fails if file exists + // Exclusive create — fails if file already exists writeFileSync(lockFile, String(process.pid), { flag: "wx" }); this.lockPath = lockFile; return true; - } catch { + } catch (createErr: unknown) { + // Check if the failure is a permission issue (not a lock conflict) + const code = (createErr as { code?: string })?.code; + if (code && code !== "EEXIST") { + console.error(`[cortex] cannot create lock file in ${dataDir}: ${code}`); + return false; + } // File exists — check if the PID is still alive try { const existingPid = Number(readFileSync(lockFile, "utf-8").trim()); if (existingPid && !isNaN(existingPid)) { try { - process.kill(existingPid, 0); // probe — throws if dead + process.kill(existingPid, 0); // probe — throws if process is dead return false; // process is alive, lock is valid } catch { - // Process is dead — stale lock, reclaim it + console.info(`[cortex] reclaiming stale lock from dead process ${existingPid}`); } } unlinkSync(lockFile); diff --git a/extensions/memory-semantic/index.ts b/extensions/memory-semantic/index.ts index d484c34d..cf5acd9b 100644 --- a/extensions/memory-semantic/index.ts +++ b/extensions/memory-semantic/index.ts @@ -2003,7 +2003,10 @@ const semanticMemoryPlugin = { onBeforeReplace: async () => { healthMonitor.stop(); try { - await writeQueue.drain(); + await Promise.race([ + writeQueue.drain(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); } catch { /* best-effort */ } @@ -2026,10 +2029,14 @@ const semanticMemoryPlugin = { /* best-effort */ } healthMonitor.stop(); - // Drain pending writes before stopping the queue and sidecar + // Drain pending writes before stopping (timeout prevents blocking shutdown) try { - const drained = await writeQueue.drain(); + const drained = (await Promise.race([ + writeQueue.drain(), + new Promise((resolve) => setTimeout(() => resolve(-1), 10_000)), + ])) as number; if (drained > 0) api.logger.info(`memory-semantic: drained ${drained} pending writes`); + else if (drained === -1) api.logger.warn("memory-semantic: drain timed out after 10s"); } catch { // best-effort — don't block shutdown } From 89c5a6babf831eff57b713263308ea5ce3c6467e Mon Sep 17 00:00:00 2001 From: It Apilium Date: Mon, 9 Mar 2026 16:39:59 +0100 Subject: [PATCH 7/7] fix: allow lock file reclaim on sidecar auto-restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the sidecar child process crashes and auto-restart triggers, acquireLock() found the existing lock with our own Node.js PID. Since the parent process is alive, the probe succeeded and the lock was refused — silently breaking all auto-restarts. Now checks if the lock belongs to the current process before probing, allowing self-reclaim for restart scenarios while still blocking concurrent instances from different processes. --- extensions/memory-semantic/cortex-sidecar.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/extensions/memory-semantic/cortex-sidecar.ts b/extensions/memory-semantic/cortex-sidecar.ts index a45f995f..d165e87b 100644 --- a/extensions/memory-semantic/cortex-sidecar.ts +++ b/extensions/memory-semantic/cortex-sidecar.ts @@ -406,11 +406,16 @@ export class CortexSidecar { try { const existingPid = Number(readFileSync(lockFile, "utf-8").trim()); if (existingPid && !isNaN(existingPid)) { - try { - process.kill(existingPid, 0); // probe — throws if process is dead - return false; // process is alive, lock is valid - } catch { - console.info(`[cortex] reclaiming stale lock from dead process ${existingPid}`); + // If the lock belongs to our own process (e.g. auto-restart after crash), reclaim it + if (existingPid === process.pid) { + console.info("[cortex] reclaiming own lock (sidecar restart)"); + } else { + try { + process.kill(existingPid, 0); // probe — throws if process is dead + return false; // different process is alive, lock is valid + } catch { + console.info(`[cortex] reclaiming stale lock from dead process ${existingPid}`); + } } } unlinkSync(lockFile);