Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/release-notes/v1.0.8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Memmy v1.0.8

## Highlights

- Made custom Agent onboarding more flexible with an optional conversation-history path and a simpler unified Agent selector.
- Improved Memory reliability and responsiveness with safer desktop service recovery and parallel turn routing and recall.

## Agent improvements

- You can now provide a custom Agent's conversation-history file or directory when connecting it. If left blank, Memmy continues to discover the location automatically.
- Combined preset selection and custom Agent naming into one keyboard-accessible field for a simpler setup flow.

## Desktop improvements

- Memory overview count cards now open their corresponding Memories, Policies, World Model, and Skills pages.
- Added a locale-aware documentation link beside the Memory service status.
- Improved recovery from stale or still-starting Memory service locks, reducing startup failures after an interrupted or abnormal shutdown.

## Memory improvements

- Turn routing and Memory retrieval now run in parallel, reducing avoidable waiting at the start of a turn while preserving routing evidence.
- Memory service shutdown now closes the HTTP server and storage backend cleanly before releasing its database lock.

## Packaging and reliability

- Hardened Windows packages by validating the bundled native database modules for both Memory and Memmy Agent against the Windows x64 Electron runtime.
- Hardened packaged runtime configuration and version-consistency checks across macOS and Windows.
- Added packaged-runtime smoke checks and migration-state compatibility checks for existing v1 and current v2 upgrade state.
2 changes: 1 addition & 1 deletion App/backend/local-api-contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"scripts": {
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
"test": "npm run typecheck",
"test": "npm run typecheck && vitest run tests/desktop-runtime-manifest.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion App/backend/local-api-contracts/src/cloud-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function resolveCloudServiceBaseUrl(raw: string | undefined): string {
const normalized = raw?.trim();
if (!normalized) {
throw new Error(
`${CLOUD_SERVICE_ENV_KEY} 未配置:网关地址唯一来源是仓库根 .env,请确认入口已加载该文件。`
`${CLOUD_SERVICE_ENV_KEY} 未配置:请确认外部环境、打包运行时清单或开发环境 .env 已提供网关地址。`
);
}
return normalized;
Expand Down
51 changes: 51 additions & 0 deletions App/backend/local-api-contracts/src/desktop-runtime-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** Public runtime configuration embedded in packaged desktop applications. */
export interface DesktopRuntimeManifest {
cloudService?: unknown;
[key: string]: unknown;
}

/**
* Normalizes the public cloud-service origin allowed in a packaged artifact.
* Credentials, paths, query strings, and fragments are rejected so secrets
* cannot be smuggled through a value that is intentionally public.
*/
export function normalizePublicCloudService(value: unknown): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error("MEMMY_CLOUD_SERVICE must be a non-empty HTTPS origin");
}

let url: URL;
try {
url = new URL(value.trim());
} catch {
throw new Error("MEMMY_CLOUD_SERVICE must be a valid HTTPS origin");
}

if (url.protocol !== "https:") {
throw new Error("MEMMY_CLOUD_SERVICE must use HTTPS");
}
if (url.username || url.password) {
throw new Error("MEMMY_CLOUD_SERVICE must not contain credentials");
}
if (url.search || url.hash) {
throw new Error("MEMMY_CLOUD_SERVICE must not contain a query or fragment");
}
if (url.pathname !== "/") {
throw new Error("MEMMY_CLOUD_SERVICE must be an origin without a path");
}
return url.origin;
}

/** Parses and validates the cloud-service field from a desktop manifest. */
export function cloudServiceFromDesktopRuntimeManifest(rawManifest: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(rawManifest);
} catch {
throw new Error("Desktop runtime manifest must contain valid JSON");
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("Desktop runtime manifest must be a JSON object");
}
return normalizePublicCloudService((parsed as DesktopRuntimeManifest).cloudService);
}
1 change: 1 addition & 0 deletions App/backend/local-api-contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from "./model-catalog-resolver.js";
export * from "./memory-runtime.js";
export * from "./endpoints.js";
export * from "./cloud-service.js";
export * from "./desktop-runtime-manifest.js";

export const MANAGED_AGENT_DISCOVERY_PENDING_DATA_PATH = "memmy-agent://history-discovery-pending";

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
cloudServiceFromDesktopRuntimeManifest,
normalizePublicCloudService,
} from "../src/desktop-runtime-manifest.js";

describe("desktop runtime manifest", () => {
it("normalizes the single public cloud-service origin", () => {
expect(normalizePublicCloudService(" https://api.example.test/ ")).toBe(
"https://api.example.test",
);
expect(
cloudServiceFromDesktopRuntimeManifest(
JSON.stringify({ edition: "cn", cloudService: "https://api.example.test" }),
),
).toBe("https://api.example.test");
});

it.each([
"",
"http://api.example.test",
"https://user:password@api.example.test",
"https://api.example.test/path",
"https://api.example.test?token=secret",
"https://api.example.test/#secret",
])("rejects a non-public runtime value without echoing it: %s", (value) => {
expect(() => normalizePublicCloudService(value)).toThrow(/MEMMY_CLOUD_SERVICE/);
try {
normalizePublicCloudService(value);
} catch (error) {
if (value) expect(String(error)).not.toContain(value);
}
});

it("rejects invalid or missing manifest data", () => {
expect(() => cloudServiceFromDesktopRuntimeManifest("not-json")).toThrow(/valid JSON/);
expect(() => cloudServiceFromDesktopRuntimeManifest("[]")).toThrow(/JSON object/);
expect(() => cloudServiceFromDesktopRuntimeManifest("{}"))
.toThrow(/MEMMY_CLOUD_SERVICE/);
});
});
47 changes: 41 additions & 6 deletions App/backend/src/load-env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/** Load env module. */
import {
cloudServiceFromDesktopRuntimeManifest,
} from "@memmy/local-api-contracts";
import { config as loadDotenv } from "dotenv";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

Expand All @@ -20,11 +23,43 @@ function findRepoEnvFile(startDir: string): string | null {
}
}

/** Reads load cloud service env. */
export function loadCloudServiceEnv(): void {
const moduleDir = dirname(fileURLToPath(import.meta.url));
const envPath = findRepoEnvFile(process.cwd()) ?? findRepoEnvFile(moduleDir);
export interface LoadCloudServiceEnvOptions {
cwd?: string;
moduleDir?: string;
manifestPath?: string;
env?: NodeJS.ProcessEnv;
loadDotenv?: typeof loadDotenv;
}

/** Loads the public cloud-service origin without allowing packaged raw env files. */
export function loadCloudServiceEnv(options: LoadCloudServiceEnvOptions = {}): string | null {
const env = options.env ?? process.env;
if (Object.prototype.hasOwnProperty.call(env, "MEMMY_CLOUD_SERVICE")) {
const externalValue = env.MEMMY_CLOUD_SERVICE?.trim();
if (externalValue) {
env.MEMMY_CLOUD_SERVICE = externalValue;
return "environment";
}
delete env.MEMMY_CLOUD_SERVICE;
}

if (options.manifestPath !== undefined) {
if (!existsSync(options.manifestPath)) {
throw new Error("Packaged desktop runtime manifest is missing");
}
env.MEMMY_CLOUD_SERVICE = cloudServiceFromDesktopRuntimeManifest(
readFileSync(options.manifestPath, "utf8"),
);
return options.manifestPath;
}

const moduleDir = options.moduleDir ?? dirname(fileURLToPath(import.meta.url));
const envPath = findRepoEnvFile(options.cwd ?? process.cwd()) ?? findRepoEnvFile(moduleDir);
if (envPath) {
loadDotenv({ path: envPath });
(options.loadDotenv ?? loadDotenv)({
path: envPath,
processEnv: env as Record<string, string>,
});
}
return envPath;
}
2 changes: 1 addition & 1 deletion App/backend/src/project-version.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/** Generated from the root package.json by scripts/sync-project-version.mjs. */
export const MEMMY_VERSION = "1.0.7";
export const MEMMY_VERSION = "1.0.8";
67 changes: 67 additions & 0 deletions App/backend/src/tests/load-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadCloudServiceEnv } from "../load-env.js";

const roots: string[] = [];

afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true });
});

describe("backend cloud-service env loading", () => {
it("keeps an explicit environment origin ahead of the packaged manifest", () => {
const root = fixtureRoot();
const manifestPath = writeManifest(root, "https://manifest.example.test");
const env = { MEMMY_CLOUD_SERVICE: "https://external.example.test" };

expect(loadCloudServiceEnv({ env, manifestPath })).toBe("environment");
expect(env.MEMMY_CLOUD_SERVICE).toBe("https://external.example.test");
});

it("loads only the allowlisted cloud service from a packaged manifest", () => {
const root = fixtureRoot();
const manifestPath = join(root, "desktop-edition.json");
writeFileSync(manifestPath, JSON.stringify({
edition: "cn",
cloudService: "https://manifest.example.test",
secretToken: "must-not-be-injected",
}));
const env: NodeJS.ProcessEnv = { MEMMY_CLOUD_SERVICE: " " };

expect(loadCloudServiceEnv({ env, manifestPath })).toBe(manifestPath);
expect(env).toEqual({ MEMMY_CLOUD_SERVICE: "https://manifest.example.test" });
});

it("falls back to a development .env when no manifest is requested", () => {
const root = fixtureRoot();
writeFileSync(join(root, ".env"), "MEMMY_CLOUD_SERVICE=https://dev.example.test\n");
const env: NodeJS.ProcessEnv = {};

expect(loadCloudServiceEnv({ cwd: root, moduleDir: root, env })).toBe(join(root, ".env"));
expect(env.MEMMY_CLOUD_SERVICE).toBe("https://dev.example.test");
});

it("fails closed when a requested packaged manifest is missing or malformed", () => {
const root = fixtureRoot();
expect(() => loadCloudServiceEnv({ env: {}, manifestPath: join(root, "missing.json") }))
.toThrow(/manifest is missing/);
const manifestPath = join(root, "desktop-edition.json");
writeFileSync(manifestPath, JSON.stringify({ cloudService: "http://unsafe.example.test" }));
expect(() => loadCloudServiceEnv({ env: {}, manifestPath })).toThrow(/HTTPS/);
});
});

function fixtureRoot(): string {
const root = mkdtempSync(join(tmpdir(), "memmy-backend-env-"));
roots.push(root);
mkdirSync(root, { recursive: true });
return root;
}

function writeManifest(root: string, cloudService: string): string {
const path = join(root, "desktop-edition.json");
writeFileSync(path, JSON.stringify({ cloudService }));
return path;
}
30 changes: 16 additions & 14 deletions App/frontend/desktop/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,8 @@ export const zhCNMessages = {
"memory.infrastructure": "基础设施状态",
"memory.cli": "memmy-memory CLI",
"memory.daemon": "记忆服务",
"memory.openDocs": "查看记忆服务文档",
"memory.learnMore": "了解更多",
"memory.cliInstalled": "已安装",
"memory.cliNotInstalled": "未安装",
"memory.daemonRunning": "运行中",
Expand Down Expand Up @@ -782,12 +784,11 @@ export const zhCNMessages = {
"memory.confirmAndStart": "确认并开始",
"memory.startingAgent": "正在启动...",
"memory.agentDiscoveryPending": "Memmy Agent 正在自动发现历史目录",
"memory.manualAgentAiHint": "只需填写 Agent 框架名称。确认后 Memmy 会新建会话,自动寻找历史目录、识别格式、安装完整 Skill、导入最近 500 个完整对话轮次,并保存后续免 AI 的自动同步规则。",
"memory.manualAgentAiHint": "填写 Agent 框架名称即可;如果知道历史会话路径,也可以一并提供。确认后 Memmy 会新建会话,验证或寻找历史目录、识别格式、安装完整 Skill、导入最近 500 个完整对话轮次,并保存后续免 AI 的自动同步规则。",
"memory.manualNamePlaceholder": "选择或输入 Agent 名称",
"memory.manualPresetPlaceholder": "从常用 Agent 中选择",
"memory.manualCustomName": "或输入其他 Agent",
"memory.manualPathPlaceholder": "例如 ~/.claude 或 /Users/you/.aider",
"memory.manualPathHint": "指定 Agent 的配置或对话历史目录,Memmy 将尝试读取其中的对话记录",
"memory.manualHistoryPathLabel": "历史会话路径(可选)",
"memory.manualPathPlaceholder": "例如 ~/.aider/history 或 /Users/you/.agent/sessions",
"memory.manualPathHint": "填写该 Agent 存储历史会话的文件或目录;留空时由 Memmy 自动寻找。",
"memory.manualNameRequired": "请输入 Agent 名称",
"memory.manualPathRequired": "请输入 Agent 数据路径",
"memory.manualDuplicate": "该 Agent 已存在",
Expand Down Expand Up @@ -1085,7 +1086,7 @@ export const zhCNMessages = {
"memory.worldModel.structuredCognition": "结构化认知",
"memory.placeholder.comingSoon": "(即将到来)",
"memory.scanHint": "点击“同步新增”按钮后,只会读取上次同步后产生的新对话;还没同步过的 Agent 会先同步一次",
"memory.incrementHint": "需要回扫完整旧历史时,请在 Agent 列表下方的高级操作中手动开启深度扫描",
"memory.incrementHint": "需要回扫完整旧历史时,请在 Agent 列表下方的高级中手动开启深度扫描",
"memory.cliDescription": "各 Agent 通过 Hook、插件或 Skill 接入 memmy-memory",
"memory.daemonDescription": "Hook、CLI 与插件统一通过记忆服务读写记忆",
"memory.unavailable": "记忆服务未连接。真实模式下不会展示假记忆数据,请先启动或配置记忆层。",
Expand Down Expand Up @@ -1151,7 +1152,7 @@ export const zhCNMessages = {
"memory.wipeConfirmTitle": "清空记忆数据库?",
"memory.wipeConfirmBody": "将清空本地记忆数据库、导入去重缓存和同步进度记录,不会删除模型、API Key、账号或 ~/.memmy/config.yaml 配置。",
"memory.wipeConfirmAction": "清空记忆数据库",
"memory.advancedActions": "高级操作",
"memory.advancedActions": "高级",
"memory.deepScanAll": "扫描全部历史",
"memory.deepScanDescription": "回扫所选 Agent 的完整历史对话,可能耗时较长并产生较高 token 消耗。",
"memory.deepScanOpen": "扫描全部历史...",
Expand Down Expand Up @@ -2277,6 +2278,8 @@ export const enUSMessages: Record<keyof typeof zhCNMessages, string> = {
"memory.infrastructure": "Infrastructure",
"memory.cli": "memmy-memory CLI",
"memory.daemon": "Memory service",
"memory.openDocs": "View memory service documentation",
"memory.learnMore": "Learn more",
"memory.cliInstalled": "Installed",
"memory.cliNotInstalled": "Not installed",
"memory.daemonRunning": "Running",
Expand Down Expand Up @@ -2313,12 +2316,11 @@ export const enUSMessages: Record<keyof typeof zhCNMessages, string> = {
"memory.confirmAndStart": "Confirm and start",
"memory.startingAgent": "Starting...",
"memory.agentDiscoveryPending": "Memmy Agent is discovering the history location",
"memory.manualAgentAiHint": "Only enter the Agent framework name. Memmy will open a new session, discover its history, identify the format, install the full Skill, import the latest 500 complete turns, and save a reusable rule for later syncs without AI.",
"memory.manualAgentAiHint": "Enter the Agent framework name and, if known, its conversation-history path. Memmy will open a new session, validate or discover the history location, identify the format, install the full Skill, import the latest 500 complete turns, and save a reusable rule for later syncs without AI.",
"memory.manualNamePlaceholder": "Choose or enter an Agent name",
"memory.manualPresetPlaceholder": "Choose a common Agent",
"memory.manualCustomName": "Or enter another Agent",
"memory.manualPathPlaceholder": "e.g. ~/.claude or /Users/you/.aider",
"memory.manualPathHint": "Specify the Agent config or conversation history directory; Memmy will try to read conversations from it",
"memory.manualHistoryPathLabel": "Conversation history path (optional)",
"memory.manualPathPlaceholder": "e.g. ~/.aider/history or /Users/you/.agent/sessions",
"memory.manualPathHint": "Enter the file or directory where this Agent stores conversation history, or leave it blank for Memmy to discover.",
"memory.manualNameRequired": "Enter an Agent name",
"memory.manualPathRequired": "Enter an Agent data path",
"memory.manualDuplicate": "This Agent already exists",
Expand Down Expand Up @@ -2616,7 +2618,7 @@ export const enUSMessages: Record<keyof typeof zhCNMessages, string> = {
"memory.worldModel.structuredCognition": "Structured cognition",
"memory.placeholder.comingSoon": "(Coming soon)",
"memory.scanHint": "Click \"Sync new\" to read only conversations created since the last sync. Agents that have not synced before will run an initial sync.",
"memory.incrementHint": "To backfill complete older history, open deep scan from Advanced actions below the Agent list",
"memory.incrementHint": "To backfill complete older history, open deep scan from Advanced below the Agent list",
"memory.cliDescription": "Agents connect to memmy-memory through Hooks, plugins, or Skills",
"memory.daemonDescription": "Hooks, CLI, and plugins read and write memory through the memory service",
"memory.unavailable": "Memory service is not connected. Real mode will not show fake memory data; start or configure the memory layer first.",
Expand Down Expand Up @@ -2683,7 +2685,7 @@ export const enUSMessages: Record<keyof typeof zhCNMessages, string> = {
"memory.wipeConfirmTitle": "Clear memory database?",
"memory.wipeConfirmBody": "This clears the local memory database, import dedupe cache, and sync progress records. Model settings, API keys, account data, and ~/.memmy/config.yaml are kept.",
"memory.wipeConfirmAction": "Clear memory database",
"memory.advancedActions": "Advanced actions",
"memory.advancedActions": "Advanced",
"memory.deepScanAll": "Scan all history",
"memory.deepScanDescription": "Backfill complete conversation history for selected Agents. This may take longer and use more tokens.",
"memory.deepScanOpen": "Scan all history...",
Expand Down
8 changes: 4 additions & 4 deletions App/frontend/desktop/src/pages/memory-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,10 @@ export function MemoryPage(props: MemoryPageProps) {
const [referenceRequest, setReferenceRequest] = useState<(MemoryReferenceOpenRequest & { page: MemoryReferencePage }) | null>(null);
const client = clients?.memoryRuntime ?? null;

function handleSubPageChange(page: MemorySubPageId) {
const handleSubPageChange = useCallback((page: MemorySubPageId) => {
setReferenceRequest(null);
setActivePage(page);
}
}, []);

const handleOpenMemoryReference = useCallback<OpenMemoryReference>((id, fallbackPage) => {
const page = resolveMemoryReferencePage(id, fallbackPage);
Expand All @@ -135,7 +135,7 @@ export function MemoryPage(props: MemoryPageProps) {

const childByPage = useMemo<Record<MemorySubPageId, ReactNode>>(
() => ({
overview: <OverviewSubPage client={client} />,
overview: <OverviewSubPage client={client} onNavigate={handleSubPageChange} />,
memories: (
<MemoriesSubPage
client={client}
Expand Down Expand Up @@ -172,7 +172,7 @@ export function MemoryPage(props: MemoryPageProps) {
logs: <LogsSubPage client={client} />,
sources: <SourcesSubPage />
}),
[client, dispatch, handleOpenMemoryReference, referenceRequest]
[client, dispatch, handleOpenMemoryReference, handleSubPageChange, referenceRequest]
);

useEffect(() => {
Expand Down
Loading
Loading