diff --git a/.github/release-notes/v1.1.1.md b/.github/release-notes/v1.1.1.md new file mode 100644 index 000000000..982eb3d50 --- /dev/null +++ b/.github/release-notes/v1.1.1.md @@ -0,0 +1,25 @@ +# Memmy v1.1.1 + +## Highlights + +- Added headless Goal execution for Memmy Agent, including durable project binding and multiline objectives, so long-running automation can be launched and resumed from the CLI without an interactive terminal. +- Added a one-line Linux CLI installer with managed systemd gateway lifecycle commands and documented setup paths for both international and China users. +- Restored local embedding model selection and made account-mode model changes stay synchronized across backend state, runtime configuration, and Desktop settings. + +## Agent and CLI improvements + +- Goal commands now preserve multiline instructions, resolve the intended terminal target, and require the correct project binding before headless execution. +- Linux installs can build and verify the CLI archive, bootstrap Memmy with a single command, and manage the local gateway through systemd with explicit start, stop, restart, status, and log flows. +- Root-terminal option handling and gateway lifecycle coverage were expanded to keep interactive and headless entry points consistent. + +## Memory improvements + +- Memory retrieval layers can now be configured explicitly, including bounded observation and session-layer behavior used by Agent integrations. +- Memory CLI setup and generated Agent instructions now carry the required project and retrieval configuration without losing existing user settings. +- Session turn capture, REST contracts, and Memory configuration tests were extended for the new retrieval behavior. + +## Desktop and setup reliability + +- Local embedding selections are preserved when switching account modes, while deleted models are pruned from stale workspace assignments and runtime projections. +- Windows keeps memory refresh available without exposing unsupported drawer controls, and update launching now preserves Unicode installer paths. +- BYOK setup and model workspace feedback more clearly reflect saved, synchronized selections across login, settings, and token-detail flows. diff --git a/.github/workflows/linux-cli-installer.yml b/.github/workflows/linux-cli-installer.yml new file mode 100644 index 000000000..ab74338d1 --- /dev/null +++ b/.github/workflows/linux-cli-installer.yml @@ -0,0 +1,209 @@ +name: Linux CLI installer + +on: + release: + types: [published] + pull_request: + paths: + - ".github/workflows/linux-cli-installer.yml" + - "scripts/install.sh" + - "scripts/internal/linux/**" + - "App/memmy-agent/**" + - "Memory/**" + - "Migrations/**" + - "App/backend/**" + - "tests/linux-cli-packaging.test.mjs" + - "package.json" + - "package-lock.json" + push: + branches: [main] + paths: + - ".github/workflows/linux-cli-installer.yml" + - "scripts/install.sh" + - "scripts/internal/linux/**" + - "App/memmy-agent/**" + - "Memory/**" + - "Migrations/**" + - "App/backend/**" + - "tests/linux-cli-packaging.test.mjs" + - "package.json" + - "package-lock.json" + workflow_dispatch: + inputs: + version: + description: Version matching package.json (without the v prefix) + required: false + type: string + upload_to_release: + description: Upload the verified Linux assets to an existing GitHub Release + required: false + default: false + type: boolean + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.sha }} + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm ci --prefix App/memmy-agent + - name: Resolve and verify version + id: version + env: + REQUESTED_VERSION: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.version }} + run: | + set -euo pipefail + repository_version="$(node -p "require('./package.json').version")" + version="${REQUESTED_VERSION:-$repository_version}" + version="${version#v}" + if [[ "$version" != "$repository_version" ]]; then + echo "Requested version $version does not match package.json version $repository_version" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Test Linux launcher and installer contracts + run: npm run test:linux-cli + - name: Build Linux CLI release assets + env: + VERSION: ${{ steps.version.outputs.version }} + run: bash scripts/internal/linux/build-cli-archive.sh --version "$VERSION" --output release-assets + - uses: actions/upload-artifact@v4 + with: + name: memmy-linux-cli-assets + path: | + release-assets/memmy-agent-linux-cli.tar.gz + release-assets/memmy-agent-linux-cli.tar.gz.sha256 + release-assets/install.sh + if-no-files-found: error + + install-smoke: + needs: build + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + architecture: x64 + - runner: ubuntu-24.04-arm + architecture: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/download-artifact@v4 + with: + name: memmy-linux-cli-assets + path: release-assets + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install twice and exercise the CLI + env: + VERSION: ${{ needs.build.outputs.version }} + EXPECTED_ARCH: ${{ matrix.architecture }} + MEMMY_INSTALL_ROOT: ${{ runner.temp }}/memmy-agent + MEMMY_BIN_DIR: ${{ runner.temp }}/bin + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64|amd64) actual_arch="x64" ;; + aarch64|arm64) actual_arch="arm64" ;; + *) echo "Unsupported runner architecture: $(uname -m)" >&2; exit 1 ;; + esac + [[ "$actual_arch" == "$EXPECTED_ARCH" ]] + export MEMMY_VERSION="$VERSION" + export MEMMY_RELEASE_BASE_URL="file://$PWD/release-assets" + bash release-assets/install.sh + bash release-assets/install.sh + "$MEMMY_BIN_DIR/memmy" --version + "$MEMMY_BIN_DIR/memmy" --help >/dev/null + "$MEMMY_BIN_DIR/memmy-memory" health >/dev/null + systemctl --user is-enabled --quiet memmy-memory.service + if systemctl --user is-enabled --quiet memmy-gateway.service; then + echo "Gateway must remain disabled until the first bare memmy onboarding" >&2 + exit 1 + fi + systemd_verify_output="$(systemd-analyze --user verify \ + "$HOME/.config/systemd/user/memmy-memory.service" \ + "$HOME/.config/systemd/user/memmy-gateway.service" 2>&1)" || { + printf '%s\n' "$systemd_verify_output" >&2 + exit 1 + } + if [[ -n "$systemd_verify_output" ]]; then + printf '%s\n' "$systemd_verify_output" >&2 + exit 1 + fi + export MEMMY_SMOKE_SECRET="linux-smoke-${EXPECTED_ARCH}" + ( + cd "$MEMMY_INSTALL_ROOT/current/App/memmy-agent" + CONFIG_PATH="$HOME/.memmy/config.yaml" node --input-type=module <<'NODE' + import { readFileSync, writeFileSync } from "node:fs"; + import YAML from "yaml"; + + const configPath = process.env.CONFIG_PATH; + const config = YAML.parse(readFileSync(configPath, "utf8")) ?? {}; + config.agents ??= {}; + config.agents.defaults ??= {}; + config.agents.defaults.model = "ollama/llama3.2"; + config.tools ??= {}; + config.tools.web ??= {}; + config.tools.web.search ??= {}; + config.tools.web.search.apiKey = "${MEMMY_SMOKE_SECRET}"; + config.tools.browser = { enabled: false }; + writeFileSync(configPath, YAML.stringify(config), "utf8"); + NODE + ) + set +e + timeout --signal=TERM --kill-after=5s 45s \ + script -qefc "$MEMMY_BIN_DIR/memmy" "$RUNNER_TEMP/memmy-tui.log" &2 + exit "$tui_status" + fi + systemctl --user is-enabled --quiet memmy-gateway.service + systemctl --user is-active --quiet memmy-gateway.service + grep -Fq 'MEMMY_SMOKE_SECRET="linux-smoke-' "$HOME/.memmy/systemd/gateway.env" + gateway_pid_before_update="$(systemctl --user show memmy-gateway.service --property=MainPID --value)" + bash release-assets/install.sh + gateway_pid_after_update="$(systemctl --user show memmy-gateway.service --property=MainPID --value)" + [[ "$gateway_pid_after_update" != "$gateway_pid_before_update" ]] + systemctl --user is-active --quiet memmy-gateway.service + curl --fail --silent --show-error http://127.0.0.1:18970/health >/dev/null + "$MEMMY_BIN_DIR/memmy-memory" health >/dev/null + systemctl --user stop memmy-gateway.service + + upload: + if: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.upload_to_release == true) }} + needs: [build, install-smoke] + runs-on: ubuntu-24.04 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.build.outputs.version }} + steps: + - uses: actions/download-artifact@v4 + with: + name: memmy-linux-cli-assets + path: release-assets + - name: Upload only Linux CLI assets to the existing Release + run: | + set -euo pipefail + gh release view "v$VERSION" --repo "$GITHUB_REPOSITORY" >/dev/null + gh release upload "v$VERSION" \ + release-assets/memmy-agent-linux-cli.tar.gz \ + release-assets/memmy-agent-linux-cli.tar.gz.sha256 \ + release-assets/install.sh \ + --clobber \ + --repo "$GITHUB_REPOSITORY" diff --git a/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts b/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts index e99e0107f..7977372f2 100644 --- a/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts +++ b/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts @@ -25,6 +25,8 @@ export interface AccountSessionRepository { activateByCloudUuid(cloudUuid: string, accountChannel?: AccountChannel): boolean; upsert(input: UpsertAccountSessionInput): AccountSessionView; clear(): void; + /** Clears only when the expected cloud credential still belongs to the active session. */ + clearIfCloudUuid(cloudUuid: string): boolean; getLastCodeSentAt(key: string): string | null; markCodeSent(key: string, at: string): void; } @@ -216,6 +218,12 @@ export function createAccountSessionRepository(db: DatabaseSync, secretStore: Se setActiveAccountUuid(db, null); }, + clearIfCloudUuid(cloudUuid) { + if (getCloudUuidFromRow(secretStore, getActiveAccountRow(db)) !== cloudUuid) return false; + setActiveAccountUuid(db, null); + return true; + }, + getLastCodeSentAt(key) { const throttleKey = toThrottleKey(key); const row = db diff --git a/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts b/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts index e8eec9def..3f8587b3b 100644 --- a/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts +++ b/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts @@ -269,6 +269,33 @@ describe("account session repository", () => { expect(fabricatedAccount).toBeUndefined(); }); + it("clears only the session whose cloud uuid is still active", () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-account-session-")); + const store = createAppStateStore({ databasePath: join(tempDir, "app.sqlite") }); + store.repositories.accountSession.upsert({ + profile: { + userId: "user-1", + email: "hello@example.com", + phoneNumber: null, + nickname: "hello", + avatarUrl: null, + planType: "free", + hasFinishedGuide: false, + region: null, + registeredAt: null, + rawProfile: { id: "user-1" } + }, + uuid: "cloud-account-a", + cloudUuid: "cloud.login.uuid.a" + }); + + expect(store.repositories.accountSession.clearIfCloudUuid("cloud.login.uuid.b")).toBe(false); + expect(store.repositories.accountSession.get()).toMatchObject({ authenticated: true }); + expect(store.repositories.accountSession.clearIfCloudUuid("cloud.login.uuid.a")).toBe(true); + expect(store.repositories.accountSession.get()).toEqual({ authenticated: false }); + store.close(); + }); + it("infers a legacy login channel only from one unambiguous bound contact", () => { tempDir = mkdtempSync(join(tmpdir(), "memmy-account-session-")); const store = createAppStateStore({ databasePath: join(tempDir, "app.sqlite") }); diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index 8cc5b5495..43a410866 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -36,6 +36,7 @@ import { normalizeTimeZoneOffset } from "../../utils/time-zone.js"; const MEMMY_ACCOUNT_PROVIDER = "memmy_account"; const MEMMY_ACCOUNT_MODEL = "agent_chat"; const MEMMY_ACCOUNT_IMAGE_MODEL = "image_gen"; +const LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE = "accountByokLocalSelectionBaseline"; const ACCOUNT_MODELS = { agent: MEMMY_ACCOUNT_MODEL, memory_summary: "memory_summary", @@ -130,7 +131,12 @@ export interface MemmyConfigWriter { /** * Clear the account-mode runtime login projection. */ - clearAccountModelProjection?(input?: { ownerAccountId?: string; force?: boolean }): Promise; + clearAccountModelProjection?(input?: { + ownerAccountId?: string; + force?: boolean; + syncSelectedByokToLocal?: boolean; + expectedCloudUuid?: string; + }): Promise; /** * Patch a single memmy-agent channel config. @@ -519,6 +525,7 @@ export async function writeAccountModelProjectionToMemmyConfig( const appConfig = isRecord(config.app) ? { ...config.app } : {}; if (normalizedCloudUuid) appConfig.cloudUuid = normalizedCloudUuid; if (normalizedUserId) appConfig.userId = normalizedUserId; + delete appConfig[LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE]; setAppConfig(config, appConfig); delete config.uuid; delete config.identity; @@ -594,9 +601,15 @@ export async function writeAccountModelProjectionToMemmyConfig( */ export async function clearAccountModelProjectionFromMemmyConfig( configPath = resolveDefaultMemmyConfigPath(), - input: { ownerAccountId?: string; force?: boolean } = {} + input: { + ownerAccountId?: string; + force?: boolean; + syncSelectedByokToLocal?: boolean; + expectedCloudUuid?: string; + } = {} ): Promise { const requestedOwnerAccountId = input.ownerAccountId?.trim(); + const expectedCloudUuid = input.expectedCloudUuid?.trim(); const result = await mutateRuntimeConfig(configPath, (config) => { const appConfig = isRecord(config.app) ? { ...config.app } : {}; const providers = isRecord(config.providers) ? { ...config.providers } : {}; @@ -604,6 +617,7 @@ export async function clearAccountModelProjectionFromMemmyConfig( if (input.force) { delete appConfig.cloudUuid; delete appConfig.userId; + delete appConfig[LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE]; setAppConfig(config, appConfig); delete config.uuid; delete config.identity; @@ -620,10 +634,19 @@ export async function clearAccountModelProjectionFromMemmyConfig( config.modelAssignments = assignments; return { memoryConfigAffected: false }; } + const currentCloudUuid = existingString(appConfig.cloudUuid) ?? existingString(accountProvider?.apiKey); + if (expectedCloudUuid && currentCloudUuid !== expectedCloudUuid) { + return { memoryConfigAffected: false }; + } const ownerAccountId = requestedOwnerAccountId ?? existingString(appConfig.userId) ?? existingString(accountProvider?.ownerAccountId); if (!ownerAccountId) return { memoryConfigAffected: false }; + delete appConfig[LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE]; + + if (input.syncSelectedByokToLocal) { + syncSelectedAccountByokCandidatesToLocal(config, ownerAccountId); + } if (!existingString(appConfig.userId) || appConfig.userId === ownerAccountId) { delete appConfig.cloudUuid; @@ -732,10 +755,17 @@ function updateAccountAssignment( const currentCandidates = Array.isArray(agent.candidates) ? agent.candidates.filter((value): value is string => typeof value === "string") : []; - const candidates = currentCandidates.filter((presetId) => assignmentPresetIsUsable( - presets, presetId, "agent", ownerAccountId - )); - if (!candidates.includes(presetIds.agent)) candidates.push(presetIds.agent); + const platformCandidates = [...new Set(currentCandidates.filter((presetId) => { + const preset = asRecord(presets[presetId]); + return preset?.source === "account" + && assignmentPresetIsUsable(presets, presetId, "agent", ownerAccountId); + }))]; + if (!platformCandidates.includes(presetIds.agent)) platformCandidates.push(presetIds.agent); + + const byok = isRecord(assignments.byok) ? assignments.byok : {}; + const byokAgent = isRecord(byok.agent) ? byok.agent : {}; + const localCandidates = selectedUsableByokAgentCandidates(byokAgent, presets, ownerAccountId); + const candidates = [...platformCandidates, ...localCandidates]; const currentDefault = existingString(agent.default); agent.candidates = candidates; agent.default = currentDefault && candidates.includes(currentDefault) ? currentDefault : presetIds.agent; @@ -758,6 +788,43 @@ function updateAccountAssignment( config.modelAssignments = assignments; } +function syncSelectedAccountByokCandidatesToLocal( + config: Record, + ownerAccountId: string +): void { + const assignments = isRecord(config.modelAssignments) ? { ...config.modelAssignments } : {}; + const account = isRecord(assignments.account) ? assignments.account : {}; + if (existingString(account.ownerAccountId) !== ownerAccountId) return; + + const presets = isRecord(config.modelPresets) ? config.modelPresets : {}; + const accountAgent = isRecord(account.agent) ? account.agent : {}; + const selectedByokCandidates = selectedUsableByokAgentCandidates(accountAgent, presets, ownerAccountId); + + const byok = isRecord(assignments.byok) ? { ...assignments.byok } : {}; + const byokAgent = isRecord(byok.agent) ? { ...byok.agent } : {}; + if (selectedByokCandidates.length === 0) return; + + const localDefault = existingString(byokAgent.default); + byokAgent.candidates = selectedByokCandidates; + byokAgent.default = localDefault && selectedByokCandidates.includes(localDefault) + ? localDefault + : selectedByokCandidates[0]; + byok.agent = byokAgent; + assignments.byok = byok; + config.modelAssignments = assignments; +} + +function selectedUsableByokAgentCandidates( + agent: Record, + presets: Record, + ownerAccountId: string +): string[] { + return [...new Set((Array.isArray(agent.candidates) ? agent.candidates : []) + .filter((value): value is string => typeof value === "string") + .filter((presetId) => asRecord(presets[presetId])?.source === "byok" + && assignmentPresetIsUsable(presets, presetId, "agent", ownerAccountId)))]; +} + function assignmentPresetIsUsable( presets: Record, presetId: string, diff --git a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts index 24b892435..77158b8a9 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts @@ -6,6 +6,8 @@ import YAML from "yaml"; import { afterEach, describe, expect, it } from "vitest"; import { clearAccountModelProjectionFromMemmyConfig, + readModelConfigCatalog, + writeModelConfigCatalog, writeAccountModelProjectionToMemmyConfig } from "../index.js"; @@ -48,6 +50,12 @@ function currentByokCatalog(): Record { byokAgent: { provider: "openai", endpoint: "chat", model: "gpt-5", source: "byok", capabilities: ["agent"] }, + byokAgent2: { + provider: "openai", endpoint: "chat", model: "gpt-5.1", source: "byok", capabilities: ["agent"] + }, + byokUnchecked: { + provider: "openai", endpoint: "chat", model: "gpt-4.1", source: "byok", capabilities: ["agent"] + }, byokSummary: { provider: "openai", endpoint: "chat", model: "gpt-5-mini", source: "byok", capabilities: ["memory_summary"] } @@ -112,7 +120,7 @@ describe("account model projection current catalog", () => { expect(saved.modelAssignments.account).toMatchObject({ ownerAccountId: "owner-a", agent: { - candidates: ["byokAgent", accountId("owner-a", "agent")], + candidates: [accountId("owner-a", "agent"), "byokAgent"], default: "byokAgent" }, memorySummary: "byokSummary", @@ -123,6 +131,47 @@ describe("account model projection current catalog", () => { expect(saved.providers.openai.futureProviderField).toBe("keep-provider"); }); + it("synchronizes the account BYOK candidates from the current local selection on every login", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent2", "byokAgent2", "byokAgent"], + default: "byokAgent2" + }; + initial.modelAssignments.account.agent = { + candidates: ["byokUnchecked", "byokAgent"], + default: "byokUnchecked" + }; + const file = await configFile(initial); + const beforeByok = (await readConfig(file)).modelAssignments.byok; + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const afterFirstLogin = await readConfig(file); + expect(afterFirstLogin.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokAgent2", "byokAgent"], + default: accountId("owner-a", "agent") + }); + expect(afterFirstLogin.modelAssignments.byok).toEqual(beforeByok); + + afterFirstLogin.modelAssignments.byok.agent = { + candidates: ["byokUnchecked"], + default: "byokUnchecked" + }; + await writeFile(file, YAML.stringify(afterFirstLogin), "utf8"); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const afterSecondLogin = await readConfig(file); + expect(afterSecondLogin.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokUnchecked"], + default: accountId("owner-a", "agent") + }); + expect(afterSecondLogin.modelAssignments.byok.agent).toEqual({ + candidates: ["byokUnchecked"], + default: "byokUnchecked" + }); + }); + it("switches owners without reviving the previous owner's platform definitions", async () => { const file = await configFile(currentByokCatalog()); await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); @@ -157,6 +206,245 @@ describe("account model projection current catalog", () => { expect(after.app?.userId).toBeUndefined(); }); + it("manual logout synchronizes every selected account BYOK candidate back to local mode", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent"], + default: "byokAgent" + }; + initial.app = { + accountByokLocalSelectionBaseline: { + ownerAccountId: "owner-a", + candidates: ["byokAgent"] + } + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [ + accountId("owner-a", "agent"), + "byokAgent2", + "byokAgent2", + "byokAgent" + ], + default: "byokAgent2" + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + const loggedOut = await readConfig(file); + expect(loggedOut.modelAssignments.byok.agent).toEqual({ + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent" + }); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + const loggedInAgain = await readConfig(file); + expect(loggedInAgain.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokAgent2", "byokAgent"], + default: "byokAgent2" + }); + }); + + it("synchronizes the logout fallback into account mode after all account BYOK models were cleared", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent"], + default: "byokAgent" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent")], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + const loggedOut = await readConfig(file); + expect(loggedOut.modelAssignments.byok.agent).toEqual({ + candidates: ["byokAgent"], + default: "byokAgent" + }); + expect(loggedOut.app?.accountByokLocalSelectionBaseline).toBeUndefined(); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + const loggedInAgain = await readConfig(file); + expect(loggedInAgain.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokAgent"], + default: accountId("owner-a", "agent") + }); + + await expect(writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file)) + .resolves.toEqual({ changed: false, memoryConfigAffected: false }); + expect((await readConfig(file)).modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokAgent"], + default: accountId("owner-a", "agent") + }); + }); + + it("synchronizes a local selection changed after an account selected no BYOK model", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent2" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent")], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + const localView = await readModelConfigCatalog(file); + const locallyChangedAssignments = structuredClone(localView.modelAssignments); + locallyChangedAssignments.byok.agent = { + candidates: ["byokUnchecked"], + default: "byokUnchecked" + }; + await writeModelConfigCatalog(file, { + configRevision: localView.configRevision, + providers: localView.providers, + modelAssignments: locallyChangedAssignments + }); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedInAgain = await readConfig(file); + expect(loggedInAgain.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokUnchecked"], + default: accountId("owner-a", "agent") + }); + }); + + it("synchronizes a local selection cleared after an account selected no BYOK model", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent2" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent")], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + const localView = await readModelConfigCatalog(file); + const locallyChangedAssignments = structuredClone(localView.modelAssignments); + locallyChangedAssignments.byok.agent = { candidates: [], default: null }; + await writeModelConfigCatalog(file, { + configRevision: localView.configRevision, + providers: localView.providers, + modelAssignments: locallyChangedAssignments + }); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedInAgain = await readConfig(file); + expect(loggedInAgain.modelAssignments.byok.agent).toEqual({ candidates: [], default: null }); + expect(loggedInAgain.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent")], + default: accountId("owner-a", "agent") + }); + }); + + it("manual logout keeps a still-selected local default when the account default is platform", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent", "byokUnchecked"], + default: "byokAgent" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent"), "byokAgent2", "byokAgent"], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + expect((await readConfig(file)).modelAssignments.byok.agent).toEqual({ + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent" + }); + }); + + it("manual logout falls back to the first selected local model when neither prior default remains", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokUnchecked"], + default: "byokUnchecked" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent"), "byokAgent2", "byokAgent"], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + expect((await readConfig(file)).modelAssignments.byok.agent).toEqual({ + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent2" + }); + }); + + it("does not clear or synchronize a newer projection for the same account", async () => { + const file = await configFile(currentByokCatalog()); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-old", userId: "owner-a" }, file); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-new", userId: "owner-a" }, file); + const beforeLateLogout = await readConfig(file); + + const result = await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true, + expectedCloudUuid: "token-old" + }); + + expect(result).toEqual({ changed: false, memoryConfigAffected: false }); + expect(await readConfig(file)).toEqual(beforeLateLogout); + }); + it("does not expose the account identifier in deterministic preset IDs", async () => { const file = await configFile({}); await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "secret-token", userId: "person@example.test" }, file); diff --git a/App/backend/src/project-version.ts b/App/backend/src/project-version.ts index f9404ca04..45441c7e5 100644 --- a/App/backend/src/project-version.ts +++ b/App/backend/src/project-version.ts @@ -1,2 +1,2 @@ /** Generated from the root package.json by scripts/sync-project-version.mjs. */ -export const MEMMY_VERSION = "1.1.0"; +export const MEMMY_VERSION = "1.1.1"; diff --git a/App/backend/src/services/account-service.ts b/App/backend/src/services/account-service.ts index 61d8f4bf2..553ae28f3 100644 --- a/App/backend/src/services/account-service.ts +++ b/App/backend/src/services/account-service.ts @@ -185,20 +185,26 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco await clearLocalAccountState( options, - session.authenticated ? session.profile.userId : undefined + session.authenticated ? session.profile.userId : undefined, + true, + uuid ?? undefined ); return { ok: true }; }, async getSession() { const session = AccountSessionViewSchema.parse(options.accountSessionRepository.get()); + const cloudUuid = session.authenticated ? options.accountSessionRepository.getCloudUuid() : null; return refreshCloudGuideState({ cloudClient: options.cloudClient, accountSessionRepository: options.accountSessionRepository, session, + cloudUuid: cloudUuid ?? undefined, onAuthenticationInvalid: () => clearLocalAccountState( options, - session.authenticated ? session.profile.userId : undefined + session.authenticated ? session.profile.userId : undefined, + false, + cloudUuid ?? undefined ) }); } @@ -232,10 +238,20 @@ async function reloadMemoryConfigIfNeeded( async function clearLocalAccountState( options: CreateAccountServiceOptions, - ownerAccountId?: string + ownerAccountId?: string, + syncSelectedByokToLocal = false, + expectedCloudUuid?: string ): Promise { - const projection = await options.memmyConfigWriter?.clearAccountModelProjection?.({ ownerAccountId }); - options.accountSessionRepository.clear(); + const projection = await options.memmyConfigWriter?.clearAccountModelProjection?.({ + ownerAccountId, + syncSelectedByokToLocal, + expectedCloudUuid + }); + if (expectedCloudUuid) { + options.accountSessionRepository.clearIfCloudUuid(expectedCloudUuid); + } else { + options.accountSessionRepository.clear(); + } await reloadMemoryConfigIfNeeded(projection, options); } diff --git a/App/backend/src/services/runtime-config-sync-service.ts b/App/backend/src/services/runtime-config-sync-service.ts index 233236e7c..a329cb311 100644 --- a/App/backend/src/services/runtime-config-sync-service.ts +++ b/App/backend/src/services/runtime-config-sync-service.ts @@ -263,6 +263,10 @@ async function hydrateAccountRuntimeConfig( reason: "account_projection_has_no_matching_local_session" }; } + const projection = await writeAccountModelProjectionToMemmyConfig({ + cloudUuid: state.cloudUuid, + userId: session.profile.userId + }, options.memmyConfigPath); appStateStore.repositories.bootstrap.updateAppSettings({ userMode: "account" }); return { source: "runtime_config", @@ -270,8 +274,10 @@ async function hydrateAccountRuntimeConfig( provider: "memmy_account", model: "agent_chat", hydratedAppState: true, - wroteConfig: false, - reason: "hydrated_account_from_runtime_config" + wroteConfig: projection.changed, + reason: projection.changed + ? "refreshed_account_projection_and_hydrated_account" + : "hydrated_account_from_runtime_config" }; } diff --git a/App/backend/src/services/tests/account-service.test.ts b/App/backend/src/services/tests/account-service.test.ts index 3465bf814..60a78bd56 100644 --- a/App/backend/src/services/tests/account-service.test.ts +++ b/App/backend/src/services/tests/account-service.test.ts @@ -573,6 +573,10 @@ describe("AccountService", () => { }, clear() { calls.push("clear"); + }, + clearIfCloudUuid(cloudUuid) { + calls.push(`clear-if:${cloudUuid}`); + return true; } }, memmyConfigWriter: { @@ -580,8 +584,10 @@ describe("AccountService", () => { calls.push("write-account"); return projectionResult(); }, - async clearAccountModelProjection() { - calls.push("clear-account-config"); + async clearAccountModelProjection(input) { + calls.push( + `clear-account-config:${input.syncSelectedByokToLocal ?? false}:${input.expectedCloudUuid ?? "none"}` + ); return projectionResult(); }, async writeByokModelProjection() { @@ -599,7 +605,72 @@ describe("AccountService", () => { }); await expect(service.logout()).resolves.toEqual({ ok: true }); - expect(calls).toEqual(["cloud-logout:cloud.login.uuid", "clear-account-config", "clear"]); + expect(calls).toEqual([ + "cloud-logout:cloud.login.uuid", + "clear-account-config:true:cloud.login.uuid", + "clear-if:cloud.login.uuid" + ]); + }); + + it("does not clear a newer account session when an older manual logout finishes late", async () => { + const calls: string[] = []; + let activeCloudUuid: string | null = "cloud.login.uuid"; + let releaseLogout: () => void = () => undefined; + const logoutGate = new Promise((resolve) => { + releaseLogout = resolve; + }); + const service = createAccountService({ + cloudClient: { + ...createCloudClientStub(), + async logout() { + calls.push("cloud-logout"); + await logoutGate; + } + }, + accountSessionRepository: { + ...createAccountSessionRepositoryStub(), + getCloudUuid() { + return activeCloudUuid; + }, + clearIfCloudUuid(cloudUuid) { + calls.push(`clear-if:${cloudUuid}`); + if (activeCloudUuid !== cloudUuid) return false; + activeCloudUuid = null; + return true; + } + }, + memmyConfigWriter: { + async writeAccountModelProjection() { + return projectionResult(); + }, + async clearAccountModelProjection(input) { + calls.push(`clear-account-config:${input.expectedCloudUuid ?? "none"}`); + return projectionResult(); + }, + async writeByokModelProjection() { + return projectionResult(); + }, + async writeActiveMemoryProfile() { + return projectionResult(); + }, + async patchChannelConfig() { + return undefined; + } + } + }); + + const logout = service.logout(); + await new Promise((resolve) => setImmediate(resolve)); + activeCloudUuid = "cloud.new.uuid"; + releaseLogout(); + await expect(logout).resolves.toEqual({ ok: true }); + + expect(activeCloudUuid).toBe("cloud.new.uuid"); + expect(calls).toEqual([ + "cloud-logout", + "clear-account-config:cloud.login.uuid", + "clear-if:cloud.login.uuid" + ]); }); it("clears the owner-scoped account projection when cloud authentication expires", async () => { @@ -635,6 +706,10 @@ describe("AccountService", () => { }, clear() { calls.push("clear-session"); + }, + clearIfCloudUuid(cloudUuid) { + calls.push(`clear-session-if:${cloudUuid}`); + return true; } }, memmyConfigWriter: { @@ -642,7 +717,10 @@ describe("AccountService", () => { return projectionResult(); }, async clearAccountModelProjection(input) { - calls.push(`clear-account-config:${input.ownerAccountId ?? "none"}`); + calls.push( + `clear-account-config:${input.ownerAccountId ?? "none"}:${input.syncSelectedByokToLocal ?? false}` + + `:${input.expectedCloudUuid ?? "none"}` + ); return projectionResult(); }, async writeByokModelProjection() { @@ -661,7 +739,10 @@ describe("AccountService", () => { message: "session expired", code: "unauthorized" }); - expect(calls).toEqual(["clear-account-config:user-1", "clear-session"]); + expect(calls).toEqual([ + "clear-account-config:user-1:false:cloud.login.uuid", + "clear-session-if:cloud.login.uuid" + ]); }); }); @@ -727,6 +808,9 @@ function createAccountSessionRepositoryStub() { clear() { return undefined; }, + clearIfCloudUuid() { + return true; + }, getLastCodeSentAt() { return null; }, diff --git a/App/backend/src/services/tests/runtime-config-sync-service.test.ts b/App/backend/src/services/tests/runtime-config-sync-service.test.ts index 283233aaf..2f47590d9 100644 --- a/App/backend/src/services/tests/runtime-config-sync-service.test.ts +++ b/App/backend/src/services/tests/runtime-config-sync-service.test.ts @@ -5,7 +5,10 @@ import { dirname, join } from "node:path"; import YAML from "yaml"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createAppStateStore, type AppStateStore } from "../../infrastructure/app-state-store/index.js"; -import { createMemmyConfigWriter } from "../../infrastructure/memmy-config/index.js"; +import { + createMemmyConfigWriter, + writeAccountModelProjectionToMemmyConfig +} from "../../infrastructure/memmy-config/index.js"; import { createAppConfigService } from "../app-config-service.js"; import { syncRuntimeConfigWithAppState } from "../runtime-config-sync-service.js"; @@ -76,7 +79,7 @@ describe("syncRuntimeConfigWithAppState", () => { ...context, accountChannel: "email" })).resolves.toMatchObject({ - source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: false + source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: true }); expect(context.store.repositories.bootstrap.getAppSettings().userMode).toBe("account"); expect(context.store.repositories.accountSession.get()).toMatchObject({ @@ -86,6 +89,42 @@ describe("syncRuntimeConfigWithAppState", () => { expect(context.store.db.prepare("SELECT uuid FROM cloud_accounts WHERE uuid = ?").get("cloud-token-a")).toBeUndefined(); }); + it("refreshes local BYOK Agent candidates into an already authenticated account during startup", async () => { + const context = createContext(); + seedAccountSession(context); + context.writeConfig(currentByokCatalog()); + await writeAccountModelProjectionToMemmyConfig({ + cloudUuid: "cloud-token-a", + userId: "owner-a" + }, context.memmyConfigPath); + + const stale = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + stale.app.userMode = "account"; + stale.app.accountByokLocalSelectionBaseline = { + ownerAccountId: "owner-a", + candidates: ["agent"] + }; + stale.modelAssignments.account.agent.candidates = stale.modelAssignments.account.agent.candidates + .filter((presetId: string) => stale.modelPresets[presetId]?.source === "account"); + stale.modelAssignments.account.agent.default = stale.modelAssignments.account.agent.candidates[0]; + context.writeConfig(stale); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "email" + })).resolves.toMatchObject({ + source: "runtime_config", + mode: "account", + hydratedAppState: true, + wroteConfig: true, + reason: "refreshed_account_projection_and_hydrated_account" + }); + + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.modelAssignments.account.agent.candidates).toContain("agent"); + expect(saved.app.accountByokLocalSelectionBaseline).toBeUndefined(); + }); + it("keeps an unmarked legacy email session when the INTL package starts", async () => { const context = createContext(); context.store.repositories.accountSession.upsert({ @@ -103,7 +142,7 @@ describe("syncRuntimeConfigWithAppState", () => { ...context, accountChannel: "email" })).resolves.toMatchObject({ - source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: false + source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: true }); expect(context.store.repositories.accountSession.get()).toMatchObject({ authenticated: true, diff --git a/App/frontend/desktop/src/app/login-mode.ts b/App/frontend/desktop/src/app/login-mode.ts index 2634598e2..eb83a1247 100644 --- a/App/frontend/desktop/src/app/login-mode.ts +++ b/App/frontend/desktop/src/app/login-mode.ts @@ -6,7 +6,7 @@ import { appActions, type AppAction } from "../state/app-actions.js"; /** Contract for persist login mode selection input. */ export interface PersistLoginModeSelectionInput { - configClient?: Pick + configClient?: Pick & Partial>; dispatch: Dispatch; userMode: Extract; @@ -17,6 +17,9 @@ export interface PersistLoginModeSelectionInput { export async function persistLoginModeSelection(input: PersistLoginModeSelectionInput): Promise { const settingsPatch = { userMode: input.userMode }; const savedSettings = await saveSettingsPatch(input.configClient, settingsPatch); + const modelConfig = input.userMode === "account" + ? await requireCanonicalModelConfig(input.configClient) + : null; if (input.userMode === "account" && input.configClient?.getTokenUsage) { try { @@ -30,6 +33,7 @@ export async function persistLoginModeSelection(input: PersistLoginModeSelection } input.dispatch(appActions.settingsUpdated(savedSettings)); + if (modelConfig) input.dispatch(appActions.modelConfigUpdated(modelConfig)); if (!input.onboarding) { return; @@ -39,6 +43,14 @@ export async function persistLoginModeSelection(input: PersistLoginModeSelection input.dispatch(appActions.onboardingUpdated(savedOnboarding)); } +/** Loads the post-login canonical model catalog required before account-mode rendering. */ +async function requireCanonicalModelConfig( + configClient: PersistLoginModeSelectionInput["configClient"] +) { + if (!configClient) throw new Error("Config client is unavailable after account login"); + return configClient.getModelConfig(); +} + /** Writes save settings patch. */ async function saveSettingsPatch( configClient: PersistLoginModeSelectionInput["configClient"], diff --git a/App/frontend/desktop/src/app/tests/login-mode.test.ts b/App/frontend/desktop/src/app/tests/login-mode.test.ts index f09499f43..ab855afb8 100644 --- a/App/frontend/desktop/src/app/tests/login-mode.test.ts +++ b/App/frontend/desktop/src/app/tests/login-mode.test.ts @@ -4,6 +4,75 @@ import type { ConfigClient } from "../../api/config-client.js"; import { persistLoginModeSelection } from "../login-mode.js"; describe("persistLoginModeSelection", () => { + it("登录账号后先刷新 canonical 模型配置,再完成 onboarding", async () => { + const calls: string[] = []; + const dispatch = vi.fn(); + const modelConfig = { + provider: "memmy_account", + endpoint: "https://cloud.example.test/v1", + model: "platform-model", + apiKey: "", + apiKeyMasked: "", + configured: true + } as Awaited>; + const configClient = { + async updateSettings(settings) { + calls.push(`settings:${settings.userMode}`); + return settings; + }, + async updateOnboarding(onboarding) { + calls.push(`onboarding:${onboarding.currentStep}`); + return onboarding; + }, + async getModelConfig() { + calls.push("model-config"); + return modelConfig; + } + } satisfies Pick; + + await persistLoginModeSelection({ + configClient, + dispatch, + userMode: "account", + onboarding: { currentStep: "permissions_required" } + }); + + expect(calls).toEqual(["settings:account", "model-config", "onboarding:permissions_required"]); + expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual([ + "settings/updated", + "modelConfig/updated", + "onboarding/updated" + ]); + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + type: "modelConfig/updated", + config: modelConfig + })); + }); + + it("账号模式 canonical 模型配置刷新失败时不渲染旧账号候选", async () => { + const dispatch = vi.fn(); + const configClient = { + async updateSettings(settings) { + return settings; + }, + async updateOnboarding(onboarding) { + return onboarding; + }, + async getModelConfig() { + throw new Error("model config offline"); + } + } satisfies Pick; + + await expect(persistLoginModeSelection({ + configClient, + dispatch, + userMode: "account", + onboarding: { currentStep: "permissions_required" } + })).rejects.toThrow("model config offline"); + + expect(dispatch).not.toHaveBeenCalled(); + }); + it("persists BYOK mode and onboarding step through config client", async () => { const calls: string[] = []; const dispatch = vi.fn(); @@ -15,8 +84,11 @@ describe("persistLoginModeSelection", () => { async updateOnboarding(onboarding) { calls.push(`onboarding:${onboarding.currentStep}`); return onboarding; + }, + async getModelConfig() { + throw new Error("BYOK mode must not load account model config"); } - } satisfies Pick; + } satisfies Pick; await persistLoginModeSelection({ configClient, @@ -37,8 +109,11 @@ describe("persistLoginModeSelection", () => { }, async updateOnboarding() { throw new Error("onboarding offline"); + }, + async getModelConfig() { + throw new Error("BYOK mode must not load account model config"); } - } satisfies Pick; + } satisfies Pick; await expect(persistLoginModeSelection({ configClient, diff --git a/App/frontend/desktop/src/pages/api-key-page.tsx b/App/frontend/desktop/src/pages/api-key-page.tsx index 20e35a2b9..f3ddd6e7b 100644 --- a/App/frontend/desktop/src/pages/api-key-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-page.tsx @@ -15,6 +15,7 @@ import { assignCatalogPreset, createModelWorkspace, modelConfigInput, + setModelAssignment, upsertByokPreset } from "../state/model-workspace.js"; import { @@ -38,7 +39,7 @@ import { testModelConnection } from "./model-config.js"; -type EmbeddingMode = "custom"; +type EmbeddingMode = "local" | "custom"; interface EmbeddingCustomConfig { model: string; @@ -97,7 +98,10 @@ export function ApiKeyPage() { hasExistingApiKey: Boolean(apiKeyMasked) }; const [llmValidation, setLlmValidation] = useState(initialModelForm.llmValidation); - const initialEmbeddingMode: EmbeddingMode = "custom"; + const initialWorkspace = createModelWorkspace(state.modelConfig); + const initialEmbeddingMode: EmbeddingMode = initialWorkspace.catalog.modelAssignments.byok.embedding + ? "custom" + : "local"; const [embeddingMode, setEmbeddingMode] = useState(initialEmbeddingMode); const [embeddingConfig, setEmbeddingConfig] = useState({ model: initialModelForm.embModelId, @@ -116,7 +120,7 @@ export function ApiKeyPage() { }; const [embeddingValidation, setEmbeddingValidation] = useState(initialModelForm.embValidation); const canSave = canSaveModelConfig(modelFormValues, llmValidation) - && canSaveOptionalModelConfig(true, embeddingFormValues, embeddingValidation); + && canSaveOptionalModelConfig(embeddingMode === "custom", embeddingFormValues, embeddingValidation); const [savePending, setSavePending] = useState(false); const [saveError, setSaveError] = useState(null); const testedKey = createModelConfigValidationKey(modelFormValues); @@ -137,9 +141,8 @@ export function ApiKeyPage() { embeddingConfig.apiKey, embeddingConfig.apiKeyMasked ); - const saveSignature = `${testedKey}\n${embeddingTestKey}`; + const saveSignature = `${embeddingMode}\n${testedKey}\n${embeddingTestKey}`; const savedCatalogSignatureRef = useRef(null); - const initialWorkspace = createModelWorkspace(state.modelConfig); const initialAgentEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "agent"); const initialEmbeddingEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "embedding"); const savedEndpointIdentitiesRef = useRef>>({ @@ -211,20 +214,24 @@ export function ApiKeyPage() { }); workspace = assignCatalogPreset(agent.workspace, "byok", "agent", agent.presetId); const savedEmbeddingIdentity = savedEndpointIdentitiesRef.current.embedding; - const embeddingEndpointId = savedEmbeddingIdentity?.credentialSignature === embeddingCredentialSignature - ? savedEmbeddingIdentity.endpointId - : undefined; - const embedding = upsertByokPreset(workspace, { - provider: "openai", - ...(embeddingEndpointId ? { endpointId: embeddingEndpointId } : {}), - endpoint: embeddingConfig.endpoint, - protocol: "openai-embeddings", - ...(embeddingConfig.apiKey.trim() ? { apiKey: embeddingConfig.apiKey.trim() } : {}), - ...(embeddingConfig.apiKeyMasked ? { apiKeyMasked: embeddingConfig.apiKeyMasked } : {}), - model: embeddingConfig.model, - capabilities: ["embedding"] - }); - workspace = assignCatalogPreset(embedding.workspace, "byok", "embedding", embedding.presetId); + if (embeddingMode === "custom") { + const embeddingEndpointId = savedEmbeddingIdentity?.credentialSignature === embeddingCredentialSignature + ? savedEmbeddingIdentity.endpointId + : undefined; + const embedding = upsertByokPreset(workspace, { + provider: "openai", + ...(embeddingEndpointId ? { endpointId: embeddingEndpointId } : {}), + endpoint: embeddingConfig.endpoint, + protocol: "openai-embeddings", + ...(embeddingConfig.apiKey.trim() ? { apiKey: embeddingConfig.apiKey.trim() } : {}), + ...(embeddingConfig.apiKeyMasked ? { apiKeyMasked: embeddingConfig.apiKeyMasked } : {}), + model: embeddingConfig.model, + capabilities: ["embedding"] + }); + workspace = assignCatalogPreset(embedding.workspace, "byok", "embedding", embedding.presetId); + } else { + workspace = setModelAssignment(workspace, "byok", "embedding", null); + } const saved = await clients.config.saveModelCatalog(modelConfigInput(workspace)); if (!saved.catalog?.modelAssignments.byok.agent.candidates.length) { throw new Error("persisted BYOK Agent assignment is empty"); @@ -240,6 +247,8 @@ export function ApiKeyPage() { : {}), ...(savedEmbeddingEndpointId ? { embedding: { endpointId: savedEmbeddingEndpointId, credentialSignature: embeddingCredentialSignature } } + : embeddingMode === "local" && savedEmbeddingIdentity + ? { embedding: savedEmbeddingIdentity } : {}) }; savedCatalogSignatureRef.current = saveSignature; @@ -330,10 +339,11 @@ export function ApiKeyPage() { onValueChange={(value) => setEmbeddingMode(value as EmbeddingMode)} className="select-control--subtle" options={[ + { value: "local", label: t("apiKey.localEmbedding") }, { value: "custom", label: t("apiKey.customEmbedding") } ]} /> - {( + {embeddingMode === "custom" ? ( <> - )} + ) : null} diff --git a/App/frontend/desktop/src/pages/login-page.tsx b/App/frontend/desktop/src/pages/login-page.tsx index 069cb74b2..6a46bb332 100644 --- a/App/frontend/desktop/src/pages/login-page.tsx +++ b/App/frontend/desktop/src/pages/login-page.tsx @@ -31,6 +31,7 @@ export function LoginPage() { const [inviteCode, setInviteCode] = useState(""); const [modePersistencePending, setModePersistencePending] = useState(false); const [modePersistenceFeedback, setModePersistenceFeedback] = useState<{ text: string; tone: "error" | "success" } | null>(null); + const [pendingAccountOnboarding, setPendingAccountOnboarding] = useState | null>(null); const channel = resolveDesktopAccountChannel(); const invitationEnabled = state.bootstrap?.promotions?.invitation?.enabled === true; const canContinue = Boolean(identifier.trim() && code.trim()); @@ -40,6 +41,7 @@ export function LoginPage() { setCode(""); setInviteCode(""); setModePersistenceFeedback(null); + setPendingAccountOnboarding(null); verificationCodeAuth.resetInteractionState(); }, [channel, verificationCodeAuth.resetInteractionState]); @@ -52,10 +54,15 @@ export function LoginPage() { } async function submitLogin() { - if (!canContinue || verificationCodeAuth.loginPending || modePersistencePending) { + if (verificationCodeAuth.loginPending || modePersistencePending) { return; } setModePersistenceFeedback(null); + if (pendingAccountOnboarding) { + await continueAfterRegistration(pendingAccountOnboarding); + return; + } + if (!canContinue) return; const loginResult = await verificationCodeAuth.login( channel, @@ -88,17 +95,16 @@ export function LoginPage() { registeredAt: session.profile.registeredAt })); - if (session.profile.hasFinishedGuide) { - await continueAfterRegistration({ + const onboardingPatch: Partial = session.profile.hasFinishedGuide + ? { completed: true, currentStep: "completed", completedAt: new Date().toISOString(), hasAcceptedTerms: true - }); - return; - } - - await continueAfterRegistration(); + } + : buildAccountOnboardingStartPatch(); + setPendingAccountOnboarding(onboardingPatch); + await continueAfterRegistration(onboardingPatch); } async function continueAfterRegistration(forcedOnboarding?: Partial) { @@ -119,6 +125,7 @@ export function LoginPage() { userMode: "account", onboarding: onboardingPatch }); + setPendingAccountOnboarding(null); dispatch(appActions.navigate(nextRoute)); } catch (error) { console.error("persist account mode failed", error); @@ -149,7 +156,7 @@ export function LoginPage() { identifierType={channel} code={code} inviteCode={inviteCode} - disabled={!canContinue || verificationCodeAuth.loginPending || modePersistencePending} + disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || modePersistencePending} sendCodeDisabled={verificationCodeAuth.sendCodeDisabled} sendCodeLabel={verificationCodeAuth.sendCodeLabel} feedback={modePersistenceFeedback ?? verificationCodeAuth.feedback} diff --git a/App/frontend/desktop/src/pages/model-workspace-section.tsx b/App/frontend/desktop/src/pages/model-workspace-section.tsx index a0332c64f..32224b0bd 100644 --- a/App/frontend/desktop/src/pages/model-workspace-section.tsx +++ b/App/frontend/desktop/src/pages/model-workspace-section.tsx @@ -53,6 +53,7 @@ export type ModelKind = "text" | "embedding" | "asr" | "image"; const DEFAULT_TEXT_CAPABILITIES: ModelCapability[] = ["chat", "memorySummary", "memoryEvolution"]; const MODEL_KIND_OPTIONS = ["text", "embedding", "asr", "image"] as const; +const LOCAL_EMBEDDING_OPTION_VALUE = "builtin:local-embedding"; export function modelCapabilitiesForKind(kind: ModelKind): ModelCapability[] { if (kind === "text") return [...DEFAULT_TEXT_CAPABILITIES]; @@ -585,7 +586,12 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { } function updateAssignment(kind: ModelAssignmentKind, candidateId: string) { - commitWorkspace(setModelAssignment(workspace, props.mode, kind, candidateId)); + const assignment = kind === "embedding" + && props.mode === "byok" + && candidateId === LOCAL_EMBEDDING_OPTION_VALUE + ? null + : candidateId; + commitWorkspace(setModelAssignment(workspace, props.mode, kind, assignment)); } function toggleTaskCandidate(candidateId: string) { @@ -654,7 +660,19 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { candidate.source, candidate.provider )); - const embeddingOptions: SelectOption[] = embeddingModelOptions; + const embeddingOptions: SelectOption[] = props.mode === "byok" + ? [ + { + value: LOCAL_EMBEDDING_OPTION_VALUE, + label: t("settings.modelWorkspace.localEmbedding"), + selectedLabel: t("settings.modelWorkspace.localEmbeddingShort"), + groupLabel: t("settings.modelWorkspace.specialBuiltins") + }, + ...embeddingModelOptions + ] + : embeddingModelOptions; + const embeddingAssignment = space.assignments.embedding + ?? (props.mode === "byok" ? LOCAL_EMBEDDING_OPTION_VALUE : undefined); const editorExistingConnection = editor?.connectionId ? space.connections.find((connection) => connection.id === editor.connectionId) : undefined; @@ -973,7 +991,7 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { kind="embedding" label={t("settings.model.embeddingSearch")} description={t("settings.model.embeddingDesc")} - value={space.assignments.embedding} + value={embeddingAssignment} options={embeddingOptions} onChange={updateAssignment} /> diff --git a/App/frontend/desktop/src/pages/settings-page.tsx b/App/frontend/desktop/src/pages/settings-page.tsx index da76e0384..d123528ba 100644 --- a/App/frontend/desktop/src/pages/settings-page.tsx +++ b/App/frontend/desktop/src/pages/settings-page.tsx @@ -1,7 +1,7 @@ /** Settings page for account, model, token usage, and desktop preferences. */ import { useCallback, useEffect, useRef, useState, type CSSProperties, type Dispatch, type ReactNode } from "react"; import { Brain, Palette, Rocket, Settings2, Shield, User, Zap, ArrowRight, Bell, ExternalLink, FolderOpen, Gift, Info, KeyRound, LogOut, Wrench, Eye, EyeOff, ChevronDown, ChevronUp, Database, Loader2, CheckCircle2, XCircle, Check, AlertTriangle, Mic, Image as ImageIcon, Copy} from "lucide-react"; -import type { AccountInvitationView, AppSettingsDto, ByokTokenUsageByKind, ByokTokenUsageByModel, ByokTokenUsageCapability, ByokTokenUsageKind, ByokTokenUsageSummary, Language, PrivacySettingsDto, TokenQuotaEligibility, TokenSceneUsageDto, TokenUsageDto } from "@memmy/local-api-contracts"; +import type { AccountInvitationView, AppSettingsDto, ByokTokenUsageByKind, ByokTokenUsageByModel, ByokTokenUsageCapability, ByokTokenUsageKind, ByokTokenUsageSummary, Language, ModelConfigView, PrivacySettingsDto, TokenQuotaEligibility, TokenSceneUsageDto, TokenUsageDto } from "@memmy/local-api-contracts"; import { useApiClients } from "../app/providers.js"; import { copyInvitationCode } from "../app/invitation-analytics.js"; import { resolveGiftTokenUsage } from "../app/routes.js"; @@ -11,7 +11,7 @@ import { useAnalytics } from "../analytics/use-analytics.js"; import type { AccountClient } from "../api/account-client.js"; import type { ByokTokenUsageClient } from "../api/byok-token-usage-client.js"; import type { TokenQuotaClient } from "../api/token-quota-client.js"; -import type { ConfigClient } from "../api/config-client.js"; +import type { ConfigClient, ModelProviderConfig } from "../api/config-client.js"; import { readCloseMainWindowAction, writeCloseMainWindowAction, @@ -289,6 +289,44 @@ export function shouldSaveAccountNicknameOnKeyDown(event: import("react").Keyboa return event.key === "Enter" && !isComposingKeyboardEvent(event); } +/** Returns whether the canonical catalog still contains a configured BYOK Agent model. */ +export function hasConfiguredByokAgentModel(catalog: ModelConfigView | null | undefined): boolean { + return Boolean(catalog?.providers.some((provider) => provider.models.some((model) => ( + model.source === "byok" && model.capabilities.includes("agent") + )))); +} + +/** Reconciles local UI state after the backend account session has already been cleared. */ +export async function finalizeAccountLogout(input: { + modelConfig: ModelProviderConfig; + configClient?: Pick; + dispatch: Dispatch; +}): Promise<"byok" | "unset"> { + let latestModelConfig = input.modelConfig; + try { + const refreshedModelConfig = await input.configClient?.getModelConfig(); + if (refreshedModelConfig) { + latestModelConfig = refreshedModelConfig; + input.dispatch(appActions.modelConfigUpdated(latestModelConfig)); + } + } catch (error) { + console.warn("refresh model config after logout failed", error); + } + + input.dispatch(appActions.accountCleared()); + const userMode = hasConfiguredByokAgentModel(latestModelConfig.catalog) ? "byok" : "unset"; + input.dispatch(appActions.settingsUpdated({ userMode })); + try { + if (input.configClient) { + const savedSettings = await input.configClient.updateSettings({ userMode }); + input.dispatch(appActions.settingsUpdated(savedSettings)); + } + } catch (error) { + console.warn("persist mode after logout failed", error); + } + return userMode; +} + /** * Renders the pure settings-page view. * @@ -1149,13 +1187,12 @@ export function SettingsPageView(props: SettingsPageViewProps) { try { await (accountClient?.logout() ?? Promise.resolve({ ok: true as const })); track({ name: "account_logout", params: { page_path: "/settings" }, consentTier: "basic" }); - dispatch(appActions.accountCleared()); - const canEnterByok = Boolean(state.modelConfig.catalog?.modelAssignments.byok.agent.candidates.length); - if (canEnterByok) { - dispatch(appActions.settingsUpdated({ userMode: "byok" })); - persistSettings({ userMode: "byok" }); - } else { - persistSettings({ userMode: "unset" }); + const nextUserMode = await finalizeAccountLogout({ + modelConfig: state.modelConfig, + configClient, + dispatch + }); + if (nextUserMode === "unset") { dispatch(appActions.navigate("/welcome")); } setConfirm(null); diff --git a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts index 193d7e8e6..581753311 100644 --- a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts +++ b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts @@ -31,6 +31,11 @@ describe("ApiKeyPage source", () => { expect(fieldsSource).toContain("auth-code-form-input"); expect(source).toContain("testEmbeddingConnection"); expect(source).toContain('"embedding"'); + expect(source).toContain('type EmbeddingMode = "local" | "custom"'); + expect(source).toContain('{ value: "local", label: t("apiKey.localEmbedding") }'); + expect(source).toContain('canSaveOptionalModelConfig(embeddingMode === "custom"'); + expect(source).toContain('const saveSignature = `${embeddingMode}\\n${testedKey}\\n${embeddingTestKey}`'); + expect(source).toContain('workspace = setModelAssignment(workspace, "byok", "embedding", null)'); expect(source).not.toContain("testAsrConnection"); expect(source).not.toContain("testImageGenConnection"); expect(source).not.toContain("optionalModelMissingWarning"); diff --git a/App/frontend/desktop/src/pages/tests/auth-flow.test.ts b/App/frontend/desktop/src/pages/tests/auth-flow.test.ts index c5d9fc83f..2dee475ed 100644 --- a/App/frontend/desktop/src/pages/tests/auth-flow.test.ts +++ b/App/frontend/desktop/src/pages/tests/auth-flow.test.ts @@ -20,6 +20,24 @@ describe("auth flow pages", () => { expect(persistIndex).toBeGreaterThan(verifyIndex); }); + it.each([ + ["welcome-page.tsx"], + ["token-detail-page.tsx"], + ["login-page.tsx"] + ])("%s 登录已成功但本地配置刷新失败时只重试登录后续流程", (fileName) => { + const source = readSource(fileName); + const submitIndex = source.indexOf("async function submitLogin()"); + const pendingRetryIndex = source.indexOf("if (pendingAccountOnboarding)", submitIndex); + const cloudLoginIndex = source.indexOf("await verificationCodeAuth.login(", submitIndex); + const rememberIndex = source.indexOf("setPendingAccountOnboarding(onboardingPatch)", cloudLoginIndex); + const clearIndex = source.indexOf("setPendingAccountOnboarding(null)", rememberIndex); + + expect(pendingRetryIndex).toBeGreaterThan(submitIndex); + expect(pendingRetryIndex).toBeLessThan(cloudLoginIndex); + expect(rememberIndex).toBeGreaterThan(cloudLoginIndex); + expect(clearIndex).toBeGreaterThan(rememberIndex); + }); + it.each([ ["welcome-page.tsx"], ["token-detail-page.tsx"], @@ -31,7 +49,7 @@ describe("auth flow pages", () => { expect(source).toContain("feedback={modePersistenceFeedback ?? verificationCodeAuth.feedback}"); expect(source).toContain("sendCodeDisabled={verificationCodeAuth.sendCodeDisabled}"); expect(source).toContain("sendCodeLabel={verificationCodeAuth.sendCodeLabel}"); - expect(source).toContain("disabled={!canContinue || verificationCodeAuth.loginPending || modePersistencePending}"); + expect(source).toContain("disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || modePersistencePending}"); expect(hookSource).toContain("validateAuthIdentifier(channel, rawIdentifier)"); expect(hookSource).toContain("resolveIdentifierValidationMessage(channel, validation.reason, t)"); expect(hookSource).toContain('"login.error.invalidPhone"'); diff --git a/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx index b882fe04c..f6cd72a99 100644 --- a/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx @@ -77,6 +77,94 @@ describe("BYOK setup save feedback", () => { expect(container.querySelector('[role="alert"]')).toBeNull(); }); + it("defaults a genuinely absent BYOK embedding assignment to the local model", async () => { + const catalog = configuredCatalog(false); + catalog.modelAssignments.byok.embedding = null; + mocks.state = { + ...createInitialAppState(), + modelConfig: { ...savedModelConfig(catalog), embedding: null } + }; + mocks.clients = createClients(vi.fn(async () => savedModelConfig(catalog))); + + await render(); + + expect(combobox("apiKey.embeddingMode").textContent).toContain("apiKey.localEmbedding"); + expect(hasField("apiKey.embeddingModel")).toBe(false); + expect(hasField("apiKey.embeddingEndpoint")).toBe(false); + expect(hasField("apiKey.embeddingKey")).toBe(false); + }); + + it("keeps an explicit invalid BYOK embedding assignment in custom mode", async () => { + const catalog = configuredCatalog(false); + catalog.modelAssignments.byok.embedding = "missing-embedding-preset"; + mocks.state = { + ...createInitialAppState(), + modelConfig: { ...savedModelConfig(catalog), embedding: null } + }; + mocks.clients = createClients(vi.fn(async () => savedModelConfig(catalog))); + + await render(); + + expect(combobox("apiKey.embeddingMode").textContent).toContain("apiKey.customEmbedding"); + expect(hasField("apiKey.embeddingModel")).toBe(true); + expect(button("apiKey.next").disabled).toBe(true); + }); + + it("saves local embedding without deleting the custom preset or account assignment", async () => { + const initialCatalog = catalogWithSharedEmbeddingAssignment(); + const embeddingPresetId = initialCatalog.modelAssignments.byok.embedding; + const server = createCatalogServer(initialCatalog); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { getModelConfig: server.getModelConfig }); + await render(); + + await selectOption("apiKey.embeddingMode", "apiKey.localEmbedding"); + await click(button("apiKey.next")); + + const saved = server.catalog(); + expect(saved.modelAssignments.byok.embedding).toBeNull(); + expect(saved.modelAssignments.account.embedding).toBe(embeddingPresetId); + expect(saved.providers.flatMap((provider) => provider.models).map((model) => model.presetId)) + .toContain(embeddingPresetId); + }); + + it("reuses masked custom embedding identity after a partial local-mode save", async () => { + const initialCatalog = catalogWithSharedEmbeddingAssignment(); + const initialEmbeddingEndpointId = assignedCatalogEndpointId( + createModelWorkspace(initialCatalog), + "byok", + "embedding" + ); + const initialEmbeddingPresetId = initialCatalog.modelAssignments.byok.embedding; + const initialProviderCount = initialCatalog.providers.length; + const initialPresetCount = initialCatalog.providers.flatMap((provider) => provider.models).length; + const server = createCatalogServer(initialCatalog); + const updateSettings = vi.fn(async (settings: unknown) => settings) + .mockRejectedValueOnce(new Error("settings offline")); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { + getModelConfig: server.getModelConfig, + updateSettings + }); + await render(); + + await selectOption("apiKey.embeddingMode", "apiKey.localEmbedding"); + await click(button("apiKey.next")); + expect(server.catalog().modelAssignments.byok.embedding).toBeNull(); + + await selectOption("apiKey.embeddingMode", "apiKey.customEmbedding"); + await click(button("apiKey.next")); + + const restored = server.catalog(); + expect(server.saveModelCatalog).toHaveBeenCalledTimes(2); + expect(assignedCatalogEndpointId(createModelWorkspace(restored), "byok", "embedding")) + .toBe(initialEmbeddingEndpointId); + expect(restored.modelAssignments.byok.embedding).toBe(initialEmbeddingPresetId); + expect(restored.modelAssignments.account.embedding).toBe(initialEmbeddingPresetId); + expect(restored.providers).toHaveLength(initialProviderCount); + expect(restored.providers.flatMap((provider) => provider.models)).toHaveLength(initialPresetCount); + }); + it("shows a first-step conflict, stays put, and allows a successful retry", async () => { const firstSave = deferred(); const saveModelCatalog = vi.fn() @@ -119,8 +207,33 @@ describe("BYOK setup save feedback", () => { expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/api-key-models")); }); + it("saves the catalog again when a partial custom save is retried as local", async () => { + const initialCatalog = catalogWithSharedEmbeddingAssignment(); + const server = createCatalogServer(initialCatalog); + const updateSettings = vi.fn(async (settings: unknown) => settings) + .mockRejectedValueOnce(new Error("settings offline")); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { + getModelConfig: server.getModelConfig, + updateSettings + }); + await render(); + + await click(button("apiKey.next")); + expect(server.saveModelCatalog).toHaveBeenCalledTimes(1); + expect(server.catalog().modelAssignments.byok.embedding).not.toBeNull(); + + await selectOption("apiKey.embeddingMode", "apiKey.localEmbedding"); + await click(button("apiKey.next")); + + expect(server.saveModelCatalog).toHaveBeenCalledTimes(2); + expect(server.catalog().modelAssignments.byok.embedding).toBeNull(); + expect(server.catalog().modelAssignments.account.embedding) + .toBe(initialCatalog.modelAssignments.account.embedding); + }); + it("reuses first-step endpoint identities when only the model changes after partial success", async () => { - const server = createCatalogServer(); + const server = createCatalogServer(configuredCatalog(false)); const updateSettings = vi.fn(async (settings: unknown) => settings) .mockRejectedValueOnce(new Error("settings offline")); mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig(server.catalog()) }; @@ -176,7 +289,7 @@ describe("BYOK setup save feedback", () => { }); it("invalidates only the changed first-step credential identity", async () => { - const server = createCatalogServer(); + const server = createCatalogServer(configuredCatalog(false)); const updateSettings = vi.fn(async (settings: unknown) => settings) .mockRejectedValueOnce(new Error("settings offline")); mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig(server.catalog()) }; @@ -373,6 +486,31 @@ describe("BYOK setup save feedback", () => { return target; } + function combobox(labelText: string): HTMLButtonElement { + const label = [...container.querySelectorAll(".select-control__label")] + .find((candidate) => candidate.textContent === labelText); + const target = label?.parentElement?.querySelector('button[role="combobox"]'); + if (!(target instanceof HTMLButtonElement)) { + throw new Error(`combobox not found: ${labelText}`); + } + return target; + } + + async function selectOption(labelText: string, optionText: string) { + await click(combobox(labelText)); + const target = [...container.querySelectorAll('button[role="option"]')] + .find((candidate) => candidate.textContent?.includes(optionText)); + if (!(target instanceof HTMLButtonElement)) { + throw new Error(`option not found: ${optionText}`); + } + await click(target); + } + + function hasField(labelText: string): boolean { + return [...container.querySelectorAll("label")] + .some((candidate) => candidate.textContent === labelText); + } + async function changeField(labelText: string, value: string) { const label = [...container.querySelectorAll("label")] .find((candidate) => candidate.textContent === labelText); @@ -517,6 +655,12 @@ function configuredCatalog(includeOptional = true): ModelConfigView { return catalogFromInput(modelConfigInput(workspace), empty, 1); } +function catalogWithSharedEmbeddingAssignment(): ModelConfigView { + const catalog = configuredCatalog(false); + catalog.modelAssignments.account.embedding = catalog.modelAssignments.byok.embedding; + return catalog; +} + function endpointCount(catalog: ModelConfigView): number { return catalog.providers.reduce((total, provider) => total + provider.endpoints.length, 0); } diff --git a/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx index 870738e69..68b5ac983 100644 --- a/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx @@ -127,16 +127,86 @@ describe("ModelWorkspaceSection BYOK connection deletion", () => { expect(bgeModel?.capabilities).toEqual(["embedding"]); }); - function renderWorkspace(seedConfig: ModelProviderConfig) { + it("shows the built-in embedding option for BYOK without changing labels", () => { + renderWorkspace(createSeedConfig(1)); + + const embeddingSelect = getAssignmentCombobox("Embedding 检索"); + expect(embeddingSelect.disabled).toBe(false); + expect(embeddingSelect.textContent).toContain("本地 Embedding"); + + act(() => embeddingSelect.click()); + expect(getOption("本地 · Xenova/all-MiniLM-L6-v2")).not.toBeNull(); + }); + + it("does not offer the built-in embedding option in account mode", () => { + const seedConfig = createEmbeddingSeedConfig(); + const presetId = seedConfig.catalog.modelAssignments.byok.embedding!; + seedConfig.catalog.modelAssignments.account.embedding = presetId; + + renderWorkspace(seedConfig, "account"); + + const embeddingSelect = getAssignmentCombobox("Embedding 检索"); + expect(embeddingSelect.textContent).toContain("text-embedding-3-small"); + act(() => embeddingSelect.click()); + expect(getOption("本地 · Xenova/all-MiniLM-L6-v2")).toBeNull(); + }); + + it("persists the built-in BYOK embedding as a null assignment", async () => { + const seedConfig = createEmbeddingSeedConfig(); + const presetId = seedConfig.catalog.modelAssignments.byok.embedding!; + seedConfig.catalog.modelAssignments.account.embedding = presetId; + const configClient = { + getModelConfig: vi.fn(async () => seedConfig), + saveModelCatalog: vi.fn(async () => seedConfig), + testModelConfig: vi.fn(async () => ({ + ok: true, + message: "ok", + checkedAt: "2026-08-13T00:00:00.000Z" + })) + }; + + await act(async () => { + root.render( + + + + ); + await Promise.resolve(); + }); + + act(() => getAssignmentCombobox("Embedding 检索").click()); + const localOption = getOption("本地 · Xenova/all-MiniLM-L6-v2"); + expect(localOption).not.toBeNull(); + act(() => localOption!.click()); + + await vi.waitFor(() => expect(configClient.saveModelCatalog).toHaveBeenCalledTimes(1)); + const input = configClient.saveModelCatalog.mock.calls[0]![0]; + expect(input.modelAssignments.byok.embedding).toBeNull(); + expect(input.modelAssignments.account.embedding).toBe(presetId); + }); + + function renderWorkspace(seedConfig: ModelProviderConfig, mode: "byok" | "account" = "byok") { act(() => { root.render( - + ); }); } + function getAssignmentCombobox(label: string): HTMLButtonElement { + const labelNode = [...container.querySelectorAll(".model-assignment-label")] + .find((node) => node.textContent === label)!; + return labelNode.closest("div.flex.items-center.justify-between")! + .querySelector('[role="combobox"]')!; + } + + function getOption(label: string): HTMLButtonElement | null { + return [...container.querySelectorAll('[role="option"]')] + .find((option) => option.textContent?.includes(label)) ?? null; + } + function getDeleteButtons(): HTMLButtonElement[] { return [...container.querySelectorAll('button[aria-label="删除 openai 配置"]')]; } diff --git a/App/frontend/desktop/src/pages/tests/nickname-modal-flow.test.ts b/App/frontend/desktop/src/pages/tests/nickname-modal-flow.test.ts index 6512c778c..1d7e86c46 100644 --- a/App/frontend/desktop/src/pages/tests/nickname-modal-flow.test.ts +++ b/App/frontend/desktop/src/pages/tests/nickname-modal-flow.test.ts @@ -14,7 +14,7 @@ describe("nickname modal flow", () => { for (const { path, continueFn } of accountRegistrationEntries) { const pageSource = readSource(path); - expect(pageSource).toContain(`await ${continueFn}();`); + expect(pageSource).toContain(`await ${continueFn}(`); // The new-user registration branch no longer opens the nickname modal during registration, to avoid pushing the nickname ahead of the scan authorization step. expect(pageSource).not.toContain('dispatch(appActions.modalChanged("nickname", true));'); } diff --git a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx index 2582d6e84..4b1ce5b25 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -13,7 +13,9 @@ import type { UpdateCoordinatorValue } from "../../app/update-coordinator.js"; import { LOG_LEVEL_STORAGE_KEY, SettingsPageView, + finalizeAccountLogout, formatUsageUpdatedAt, + hasConfiguredByokAgentModel, isPendingQuotaRequestError, resolveQuotaEligibilityMessage, resolveSettingsTabFromHash, @@ -752,7 +754,7 @@ describe("SettingsPageView", () => { expect(html).toContain("打磨 Agent 技能与偏好"); expect(html).toContain("Embedding 检索"); expect(html).toContain("记忆向量化检索"); - expect(html).not.toContain("Xenova/all-MiniLM-L6-v2"); + expect(html).toContain("Xenova/all-MiniLM-L6-v2"); expect(html).toContain("语音识别 ASR"); expect(html).toContain("生图模型"); expect(html).toContain("未配置"); @@ -862,6 +864,92 @@ describe("SettingsPageView", () => { expect(source).toContain("appActions.accountCleared()"); }); + it("退出登录后刷新 canonical 配置,并按实际 BYOK Agent 模型决定落点", async () => { + const catalogWithUnselectedByokAgent = createCatalog(true); + catalogWithUnselectedByokAgent.modelAssignments.byok.agent = { candidates: [], default: null }; + expect(hasConfiguredByokAgentModel(catalogWithUnselectedByokAgent)).toBe(true); + expect(hasConfiguredByokAgentModel(createCatalog(false))).toBe(false); + + const dispatch = vi.fn(); + const canonicalModelConfig = { + ...createAccountModeWithSavedModelState().modelConfig, + catalog: catalogWithUnselectedByokAgent + }; + const configClient = { + getModelConfig: vi.fn(async () => canonicalModelConfig), + updateSettings: vi.fn(async (settings) => settings) + }; + + await expect(finalizeAccountLogout({ + modelConfig: createAccountModeState().modelConfig, + configClient, + dispatch + })).resolves.toBe("byok"); + + expect(configClient.getModelConfig).toHaveBeenCalledOnce(); + expect(configClient.updateSettings).toHaveBeenCalledWith({ userMode: "byok" }); + expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual([ + "modelConfig/updated", + "account/cleared", + "settings/updated", + "settings/updated" + ]); + }); + + it("退出后的 canonical 刷新失败时仅使用缓存 BYOK catalog 回退", async () => { + const dispatch = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const configClient = { + getModelConfig: vi.fn(async () => { throw new Error("model config offline"); }), + updateSettings: vi.fn(async (settings) => settings) + }; + + try { + await expect(finalizeAccountLogout({ + modelConfig: createAccountModeWithSavedModelState().modelConfig, + configClient, + dispatch + })).resolves.toBe("byok"); + } finally { + warn.mockRestore(); + } + + expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual([ + "account/cleared", + "settings/updated", + "settings/updated" + ]); + }); + + it("退出后的模式保存失败不回滚已清除账号,并继续返回欢迎页落点", async () => { + const dispatch = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const canonicalModelConfig = createAccountModeState().modelConfig; + const configClient = { + getModelConfig: vi.fn(async () => canonicalModelConfig), + updateSettings: vi.fn(async () => { throw new Error("settings offline"); }) + }; + + try { + await expect(finalizeAccountLogout({ + modelConfig: createAccountModeWithSavedModelState().modelConfig, + configClient, + dispatch + })).resolves.toBe("unset"); + } finally { + warn.mockRestore(); + } + + expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual([ + "modelConfig/updated", + "account/cleared", + "settings/updated" + ]); + const source = readFileSync(settingsPageSourcePath, "utf8"); + expect(source).toContain('if (nextUserMode === "unset")'); + expect(source).toContain('dispatch(appActions.navigate("/welcome"))'); + }); + it("中文输入法组合输入中的 Enter 只确认候选,不保存账户昵称", () => { expect(shouldSaveAccountNicknameOnKeyDown(nicknameKeyEvent({ nativeEvent: { isComposing: true } }))).toBe(false); expect(shouldSaveAccountNicknameOnKeyDown(nicknameKeyEvent({ nativeEvent: { keyCode: 229 } }))).toBe(false); diff --git a/App/frontend/desktop/src/pages/token-detail-page.tsx b/App/frontend/desktop/src/pages/token-detail-page.tsx index 10a654558..2628b256c 100644 --- a/App/frontend/desktop/src/pages/token-detail-page.tsx +++ b/App/frontend/desktop/src/pages/token-detail-page.tsx @@ -31,6 +31,7 @@ export function TokenDetailPage() { const [inviteCode, setInviteCode] = useState(""); const [modePersistencePending, setModePersistencePending] = useState(false); const [modePersistenceFeedback, setModePersistenceFeedback] = useState<{ text: string; tone: "error" | "success" } | null>(null); + const [pendingAccountOnboarding, setPendingAccountOnboarding] = useState | null>(null); const channel = resolveDesktopAccountChannel(); const invitationEnabled = state.bootstrap?.promotions?.invitation?.enabled === true; const canContinue = Boolean(identifier.trim() && code.trim()); @@ -41,6 +42,7 @@ export function TokenDetailPage() { setCode(""); setInviteCode(""); setModePersistenceFeedback(null); + setPendingAccountOnboarding(null); verificationCodeAuth.resetInteractionState(); }, [channel, verificationCodeAuth.resetInteractionState]); @@ -53,10 +55,15 @@ export function TokenDetailPage() { } async function submitLogin() { - if (!canContinue || verificationCodeAuth.loginPending || modePersistencePending) { + if (verificationCodeAuth.loginPending || modePersistencePending) { return; } setModePersistenceFeedback(null); + if (pendingAccountOnboarding) { + await continueAfterRegistration(pendingAccountOnboarding); + return; + } + if (!canContinue) return; const loginResult = await verificationCodeAuth.login( channel, @@ -89,17 +96,16 @@ export function TokenDetailPage() { registeredAt: session.profile.registeredAt })); - if (session.profile.hasFinishedGuide) { - await continueAfterRegistration({ + const onboardingPatch: Partial = session.profile.hasFinishedGuide + ? { completed: true, currentStep: "completed", completedAt: new Date().toISOString(), hasAcceptedTerms: true - }); - return; - } - - await continueAfterRegistration(); + } + : buildAccountOnboardingStartPatch(); + setPendingAccountOnboarding(onboardingPatch); + await continueAfterRegistration(onboardingPatch); } async function continueAfterRegistration(forcedOnboarding?: Partial) { @@ -120,6 +126,7 @@ export function TokenDetailPage() { userMode: "account", onboarding: onboardingPatch }); + setPendingAccountOnboarding(null); dispatch(appActions.navigate(nextRoute)); } catch (error) { console.error("persist account mode failed", error); @@ -173,7 +180,7 @@ export function TokenDetailPage() { identifierType={channel} code={code} inviteCode={inviteCode} - disabled={!canContinue || verificationCodeAuth.loginPending || modePersistencePending} + disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || modePersistencePending} sendCodeDisabled={verificationCodeAuth.sendCodeDisabled} sendCodeLabel={verificationCodeAuth.sendCodeLabel} feedback={modePersistenceFeedback ?? verificationCodeAuth.feedback} diff --git a/App/frontend/desktop/src/pages/welcome-page.tsx b/App/frontend/desktop/src/pages/welcome-page.tsx index b9a4c6cc4..0c33049fa 100644 --- a/App/frontend/desktop/src/pages/welcome-page.tsx +++ b/App/frontend/desktop/src/pages/welcome-page.tsx @@ -33,6 +33,7 @@ export function WelcomePage() { const [inviteCode, setInviteCode] = useState(""); const [modePersistencePending, setModePersistencePending] = useState(false); const [modePersistenceFeedback, setModePersistenceFeedback] = useState<{ text: string; tone: "error" | "success" } | null>(null); + const [pendingAccountOnboarding, setPendingAccountOnboarding] = useState | null>(null); const channel = resolveDesktopAccountChannel(); const invitationEnabled = state.bootstrap?.promotions?.invitation?.enabled === true; const canContinue = Boolean(identifier.trim() && code.trim()); @@ -46,6 +47,7 @@ export function WelcomePage() { setCode(""); setInviteCode(""); setModePersistenceFeedback(null); + setPendingAccountOnboarding(null); verificationCodeAuth.resetInteractionState(); }, [channel, verificationCodeAuth.resetInteractionState]); @@ -60,10 +62,15 @@ export function WelcomePage() { /** Handles submit login. */ async function submitLogin() { - if (!canContinue || verificationCodeAuth.loginPending || modePersistencePending) { + if (verificationCodeAuth.loginPending || modePersistencePending) { return; } setModePersistenceFeedback(null); + if (pendingAccountOnboarding) { + await continueAfterAccountEntry(pendingAccountOnboarding); + return; + } + if (!canContinue) return; const loginResult = await verificationCodeAuth.login( channel, @@ -96,19 +103,16 @@ export function WelcomePage() { registeredAt: session.profile.registeredAt })); - if (session.profile.hasFinishedGuide) { - await continueAfterAccountEntry({ + const onboardingPatch: Partial = session.profile.hasFinishedGuide + ? { completed: true, currentStep: "completed", completedAt: new Date().toISOString(), hasAcceptedTerms: true - }); - return; - } - - // Welcome page module. - // Welcome page module. - await continueAfterAccountEntry(); + } + : buildAccountOnboardingStartPatch(); + setPendingAccountOnboarding(onboardingPatch); + await continueAfterAccountEntry(onboardingPatch); } /** Handles continue after account entry. */ @@ -130,6 +134,7 @@ export function WelcomePage() { userMode: "account", onboarding: onboardingPatch }); + setPendingAccountOnboarding(null); dispatch(appActions.navigate(nextRoute)); } catch (error) { console.error("persist account mode failed", error); @@ -214,7 +219,7 @@ export function WelcomePage() { identifierType={channel} code={code} inviteCode={inviteCode} - disabled={!canContinue || verificationCodeAuth.loginPending || modePersistencePending} + disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || modePersistencePending} sendCodeDisabled={verificationCodeAuth.sendCodeDisabled} sendCodeLabel={verificationCodeAuth.sendCodeLabel} feedback={modePersistenceFeedback ?? verificationCodeAuth.feedback} diff --git a/App/frontend/desktop/src/state/model-workspace.ts b/App/frontend/desktop/src/state/model-workspace.ts index 822100b84..c2231a631 100644 --- a/App/frontend/desktop/src/state/model-workspace.ts +++ b/App/frontend/desktop/src/state/model-workspace.ts @@ -494,14 +494,14 @@ export function deleteModelConnection( const next = cloneCatalog(workspace.catalog); const provider = next.providers.find((item) => item.provider === connection.provider && !item.accountManaged); if (!provider) return { workspace, error: "connection_not_found" }; - const removedIds = provider.models.filter((item) => item.endpointId === connection.endpointId).map((item) => item.presetId); provider.endpoints = provider.endpoints.filter((item) => item.endpointId !== connection.endpointId); provider.models = provider.models.filter((item) => item.endpointId !== connection.endpointId); if (!provider.endpoints.length || !provider.models.length) { next.providers = next.providers.filter((item) => item !== provider); } - clearAssignmentReferences(next.modelAssignments.byok, removedIds); - clearAssignmentReferences(next.modelAssignments.account, removedIds); + const remainingIds = new Set(next.providers.flatMap((item) => item.models.map((model) => model.presetId))); + pruneInvalidAssignmentReferences(next.modelAssignments.byok, remainingIds); + pruneInvalidAssignmentReferences(next.modelAssignments.account, remainingIds); refreshEffectiveCandidates(next); return { workspace: createModelWorkspace(next), error: null }; } @@ -553,13 +553,18 @@ export function setModelAssignment( workspace: ModelWorkspace, mode: ModelWorkspaceMode, kind: ModelAssignmentKind, - candidateId: string + candidateId: string | null ): ModelWorkspace { + const key = kind === "image" ? "imageGeneration" : kind; + if (candidateId === null) { + const next = cloneCatalog(workspace.catalog); + next.modelAssignments[mode][key] = null; + return createModelWorkspace(next); + } const capability = assignmentCapability(kind); const allowed = new Set(getModelCandidates(workspace, mode, capability).map((candidate) => candidate.id)); if (!allowed.has(candidateId)) return workspace; const next = cloneCatalog(workspace.catalog); - const key = kind === "image" ? "imageGeneration" : kind; next.modelAssignments[mode][key] = candidateId; return createModelWorkspace(next); } @@ -783,12 +788,13 @@ function replaceIds(current: string[], oldIds: string[], nextIds: string[]): str return unique([...kept, ...nextIds]); } -function clearAssignmentReferences(assignment: ModelAssignment, removedIds: string[]): void { - const removed = new Set(removedIds); - assignment.agent.candidates = assignment.agent.candidates.filter((id) => !removed.has(id)); - if (assignment.agent.default && removed.has(assignment.agent.default)) assignment.agent.default = assignment.agent.candidates[0] ?? null; +function pruneInvalidAssignmentReferences(assignment: ModelAssignment, validIds: ReadonlySet): void { + assignment.agent.candidates = assignment.agent.candidates.filter((id) => validIds.has(id)); + if (!assignment.agent.default || !assignment.agent.candidates.includes(assignment.agent.default)) { + assignment.agent.default = assignment.agent.candidates[0] ?? null; + } for (const key of ["memorySummary", "memoryEvolution", "embedding", "asr", "imageGeneration"] as const) { - if (assignment[key] && removed.has(assignment[key]!)) assignment[key] = null; + if (assignment[key] && !validIds.has(assignment[key]!)) assignment[key] = null; } } diff --git a/App/frontend/desktop/src/state/tests/model-workspace.test.ts b/App/frontend/desktop/src/state/tests/model-workspace.test.ts index b9a965f44..6fd1c9c54 100644 --- a/App/frontend/desktop/src/state/tests/model-workspace.test.ts +++ b/App/frontend/desktop/src/state/tests/model-workspace.test.ts @@ -443,6 +443,50 @@ describe("canonical model workspace adapter", () => { expect(Object.values(raw.modelPresets ?? {})).not.toContainEqual(expect.objectContaining({ provider: "dashscope" })); }); + it("真实 Backend catalog:删除共享配置时同时清理账号空间的失效平台 preset 引用", async () => { + const file = catalogFixture(); + const empty = await readModelConfigCatalog(file); + let workspace = createModelWorkspace(empty); + const created = upsertByokPreset(workspace, { + provider: "openai", + endpoint: "https://api.openai.com/v1", + protocol: "openai-chat-completions", + apiKey: "test-api-key", + model: "gpt-4o", + capabilities: ["agent"] + }); + workspace = assignCatalogPreset(created.workspace, "byok", "agent", created.presetId); + workspace = assignCatalogPreset(workspace, "account", "agent", created.presetId); + const createdCatalog = await persistModelCatalogMutation(modelConfigInput(workspace), { + read: () => readModelConfigCatalog(file), + write: (input) => writeModelConfigCatalog(file, input) + }, empty); + const byokPresetId = createdCatalog.modelAssignments.byok.agent.default!; + const staleAccountPresetId = "memmy-account-946b1209029f-agent"; + const raw = YAML.parse(readFileSync(file, "utf8")) as any; + raw.modelAssignments.account = { + ownerAccountId: "owner-a", + agent: { candidates: [staleAccountPresetId, byokPresetId], default: staleAccountPresetId }, + memorySummary: null, + memoryEvolution: null, + embedding: null, + asr: null, + imageGeneration: null + }; + writeFileSync(file, YAML.stringify(raw), "utf8"); + + const base = await readModelConfigCatalog(file); + const connection = createModelWorkspace(base).spaces.account.connections.find((item) => item.provider === "openai")!; + const deleted = deleteModelConnection(createModelWorkspace(base), "account", connection.id); + const saved = await persistModelCatalogMutation(modelConfigInput(deleted.workspace), { + read: () => readModelConfigCatalog(file), + write: (input) => writeModelConfigCatalog(file, input) + }, base); + + expect(saved.providers.some((provider) => provider.provider === "openai")).toBe(false); + expect(saved.modelAssignments.account.agent).toEqual({ candidates: [], default: null }); + }); + it("真实 Backend catalog:删除遇到不可见 Key 并发轮换时拒绝重放", async () => { const file = catalogFixture(); const base = await deletionCatalogFixture(file); @@ -736,6 +780,21 @@ describe("canonical model workspace adapter", () => { expect(assigned.catalog.modelAssignments.byok).toEqual(originalByok); }); + it("清空本地 Embedding Assignment 时保留账号 Assignment 与目录项", () => { + const workspace = createModelWorkspace(catalog()); + const before = modelConfigInput(workspace); + + const cleared = modelConfigInput(setModelAssignment(workspace, "byok", "embedding", null)); + + expect(cleared.modelAssignments.byok).toEqual({ + ...before.modelAssignments.byok, + embedding: null + }); + expect(cleared.modelAssignments.account).toEqual(before.modelAssignments.account); + expect(cleared.providers).toEqual(before.providers); + expect(workspace.catalog.modelAssignments.byok.embedding).toBe("byok-embedding"); + }); + it("删除账号空间可见的共享 BYOK 连接时同步清理两个空间的引用", () => { const result = deleteModelConnection(createModelWorkspace(catalog()), "account", "openai:chat"); diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index eb8e7b431..57def6074 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -3894,6 +3894,10 @@ code { margin-bottom: 20px; } +body.memmy-platform-windows .memory-panel__header { + padding-top: var(--codex-toolbar-height); +} + .memory-panel__header--single-line { align-items: flex-start; flex-wrap: wrap; @@ -5055,6 +5059,10 @@ code { -webkit-app-region: no-drag; } +body.memmy-platform-windows .memory-drawer { + padding-top: var(--codex-toolbar-height); +} + .memory-drawer, .memory-drawer * { -webkit-app-region: no-drag; diff --git a/App/frontend/desktop/src/theme/tests/style-alignment.test.ts b/App/frontend/desktop/src/theme/tests/style-alignment.test.ts index d4d748fca..47ecde9e1 100644 --- a/App/frontend/desktop/src/theme/tests/style-alignment.test.ts +++ b/App/frontend/desktop/src/theme/tests/style-alignment.test.ts @@ -276,6 +276,22 @@ describe("prototype style alignment", () => { expect(memorySummaryRule).toContain("-webkit-line-clamp: 2;"); }); + it("keeps the Windows memory refresh action below the native title-bar overlay", () => { + const windowsMemoryHeaderRule = globalCss.match( + /body\.memmy-platform-windows \.memory-panel__header\s*\{[^}]*\}/ + )?.[0] ?? ""; + + expect(windowsMemoryHeaderRule).toContain("padding-top: var(--codex-toolbar-height);"); + }); + + it("keeps the Windows memory drawer close action below the native title-bar overlay", () => { + const windowsMemoryDrawerRule = globalCss.match( + /body\.memmy-platform-windows \.memory-drawer\s*\{[^}]*\}/ + )?.[0] ?? ""; + + expect(windowsMemoryDrawerRule).toContain("padding-top: var(--codex-toolbar-height);"); + }); + it("keeps memory drawer IDs selectable inside the window drag area", () => { const memoryDrawerBackdropRule = globalCss.match(/\.memory-drawer-backdrop\s*\{[^}]*\}/)?.[0] ?? ""; const memoryDrawerBackdropCloseRule = globalCss.match(/\.memory-drawer-backdrop__close\s*\{[^}]*\}/)?.[0] ?? ""; diff --git a/App/memmy-agent/package-lock.json b/App/memmy-agent/package-lock.json index 2e3e6aadf..825c5fc13 100644 --- a/App/memmy-agent/package-lock.json +++ b/App/memmy-agent/package-lock.json @@ -1,12 +1,12 @@ { "name": "memmy-agent", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memmy-agent", - "version": "1.1.0", + "version": "1.1.1", "dependencies": { "@anthropic-ai/sdk": "^0.100.1", "@aws-sdk/client-bedrock-runtime": "^3.1061.0", diff --git a/App/memmy-agent/package.json b/App/memmy-agent/package.json index 765c5202e..aa0bde7e4 100644 --- a/App/memmy-agent/package.json +++ b/App/memmy-agent/package.json @@ -1,6 +1,6 @@ { "name": "memmy-agent", - "version": "1.1.0", + "version": "1.1.1", "description": "TypeScript refactor of memmy's agent runtime.", "type": "module", "main": "./dist/index.js", diff --git a/App/memmy-agent/src/command/builtin.ts b/App/memmy-agent/src/command/builtin.ts index 48f2ec3ea..f662962e9 100644 --- a/App/memmy-agent/src/command/builtin.ts +++ b/App/memmy-agent/src/command/builtin.ts @@ -427,10 +427,11 @@ export async function cmdGoal(ctx: CommandContext): Promise { const runtime = ctx.loop?.goalRuntime; if (!runtime) return reply(ctx, "Goal runtime is unavailable.", { renderAs: "text" }); const rawArgs = ctx.args.trim(); - const [first = "", ...remaining] = rawArgs.split(/\s+/); + const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(rawArgs); + const first = match?.[1] ?? ""; const command = first.toLowerCase(); const explicitSubcommand = GOAL_COMMAND_SUBCOMMANDS.has(command); - const argument = explicitSubcommand ? remaining.join(" ").trim() : rawArgs; + const argument = explicitSubcommand ? (match?.[2] ?? "").trim() : rawArgs; const current = runtime.get(ctx.key); try { if (!rawArgs || command === "status") { diff --git a/App/memmy-agent/src/config/schema.ts b/App/memmy-agent/src/config/schema.ts index 1c45fca3d..391351e10 100644 --- a/App/memmy-agent/src/config/schema.ts +++ b/App/memmy-agent/src/config/schema.ts @@ -1075,6 +1075,7 @@ export class MemmyMemoryConfig extends Base { userId = "local-user"; version?: number; storage?: Dict; + retrievalLayers?: Array<"L1" | "L2" | "L3" | "Skill">; summary?: Dict; evolution?: Dict; embedding?: Dict; @@ -1091,6 +1092,12 @@ export class MemmyMemoryConfig extends Base { this.userId = options.userId ?? pick(init, ["userId"], this.userId); this.version = pick(init, ["version"], undefined); this.storage = pick(init, ["storage"], undefined); + const retrievalLayers = pick(init, ["retrievalLayers"], undefined); + this.retrievalLayers = retrievalLayers === undefined + ? undefined + : [...new Set(assertStringArray("memmyMemory.retrievalLayers", retrievalLayers).map((layer, index) => + assertOneOf(`memmyMemory.retrievalLayers[${index}]`, layer, ["L1", "L2", "L3", "Skill"] as const) + ))]; this.summary = undefined; this.evolution = undefined; this.embedding = undefined; @@ -1103,6 +1110,7 @@ export class MemmyMemoryConfig extends Base { userId: this.userId, version: this.version, storage: this.storage, + retrievalLayers: this.retrievalLayers, algorithm: this.algorithm, }); } diff --git a/App/memmy-agent/src/entrypoints/cli/commands.ts b/App/memmy-agent/src/entrypoints/cli/commands.ts index 7057fca5f..9b6fe77e7 100644 --- a/App/memmy-agent/src/entrypoints/cli/commands.ts +++ b/App/memmy-agent/src/entrypoints/cli/commands.ts @@ -22,6 +22,10 @@ import { SessionManager, type WebuiSessionBinding, } from "../../core/session/manager.js"; +import { + publicGoalState, + type AgentGoalState, +} from "../../core/session/goal-state.js"; import { WEBUI_LANGUAGE_METADATA_KEY } from "../../core/session/webui-turns.js"; import { API_MAX_BODY_BYTES, @@ -74,6 +78,8 @@ import { } from "./onboard.js"; import { StreamRenderer, ThinkingSpinner } from "./stream.js"; import { prepareStartupMigrations } from "./startup-migrations.js"; +import { parseRootTerminalOptions } from "./root-terminal-options.js"; +import { runLinuxRootTerminal } from "./linux-systemd-gateway.js"; const CLI_TEMPLATES_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../templates"); @@ -82,6 +88,13 @@ export const app = new Command("memmy") .option("--standalone", "Create a new standalone terminal session") .option("--project ", "Create a terminal session bound to a project path"); +export function resolveCliActionOptions>( + localOptions: T, + command: Pick, +): T { + return { ...localOptions, ...command.optsWithGlobals() }; +} + export type GatewayRuntime = { bus: MessageBus; loop: AgentLoop; @@ -92,6 +105,37 @@ export type GatewayRuntime = { stop: () => Promise; }; +type GatewayLifecycleProcess = Pick; + +export function installGatewaySignalLifecycle( + runtime: GatewayRuntime, + lifecycle: GatewayLifecycleProcess = process, +): void { + let stopping = false; + const signals: NodeJS.Signals[] = ["SIGHUP", "SIGINT", "SIGTERM"]; + const remove = () => { + for (const signal of signals) lifecycle.off(signal, shutdown); + }; + const shutdown = () => { + if (stopping) return; + stopping = true; + remove(); + const forceExit = setTimeout(() => lifecycle.exit(1), 10_000); + forceExit.unref?.(); + void runtime.stop().then( + () => { + clearTimeout(forceExit); + lifecycle.exit(0); + }, + () => { + clearTimeout(forceExit); + lifecycle.exit(1); + }, + ); + }; + for (const signal of signals) lifecycle.on(signal, shutdown); +} + let cliRuntimeLogs = false; export function setCliRuntimeLogs(enabled: boolean): void { @@ -340,7 +384,7 @@ export function resolveTerminalTarget( key = standalone || project ? `cli:${crypto.randomUUID()}` : "cli:direct"; const existing = reload(key); if (!existing && !dependencies.hasUsableDefaultModel()) { - throw new Error("No usable default model is configured. Run `memmy onboard` first."); + throw new Error("No usable default model is configured. Run `memmy onboard --wizard` first."); } if (existing) { binding = readWebuiSessionBinding(existing); @@ -446,9 +490,9 @@ export async function runRootInteractiveAgent({ sessionId?: string | null; standalone?: boolean; project?: string | null; -} = {}): Promise { +} = {}, runtimeConfig?: Config): Promise { if (rootInteractiveRunnerForTest) return rootInteractiveRunnerForTest(); - const loaded = loadRuntimeConfig(null, null); + const loaded = runtimeConfig ?? loadRuntimeConfig(null, null); const workspace = syncRuntimeWorkspaceTemplates(loaded); const target = resolveTerminalTarget({ sessions: new SessionManager(path.join(workspace, "sessions"), { @@ -470,31 +514,6 @@ export async function runRootInteractiveAgent({ return runInkInteractiveAgent(loaded, target.sessionId, target); } -function rootTerminalOptions(argv: string[]): { - sessionId?: string; - standalone?: boolean; - project?: string; -} | null { - if (argv.length <= 2) return {}; - const args = argv.slice(2); - const options: { sessionId?: string; standalone?: boolean; project?: string } = {}; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === "--standalone") options.standalone = true; - else if (arg === "--session" || arg === "-s") { - const value = args[++index]; - if (!value || value.startsWith("-")) throw new Error("--session requires a sessionId"); - options.sessionId = value; - } else if (arg === "--project") { - const value = args[++index]; - if (!value || value.startsWith("-")) throw new Error("--project requires a path"); - options.project = value; - } - else return null; - } - return options; -} - export async function runInternalCommand(argv: string[]): Promise { if (argv[2] !== "internal") return false; if (argv.length !== 4 || argv[3] !== "browser-prepare") { @@ -524,10 +543,18 @@ export async function main(argv: string[] = process.argv): Promise { versionCallback(true); return; } - const rootTarget = rootTerminalOptions(argv); + const rootTarget = parseRootTerminalOptions(argv); if (rootTarget) { await prepareStartupMigrations(); - await runRootInteractiveAgent(rootTarget); + if (rootInteractiveRunnerForTest) { + await runRootInteractiveAgent(rootTarget); + return; + } + await runLinuxRootTerminal({ + loadConfig: () => loadRuntimeConfig(null, null), + onboardWizard: () => onboard({ wizard: true }), + runInteractive: (config) => runRootInteractiveAgent(rootTarget, config), + }); return; } @@ -585,7 +612,8 @@ export async function main(argv: string[] = process.argv): Promise { .option("-c, --config ", "Path to config file") .option("-v, --verbose", "Enable verbose runtime logs", false) .action(async (opts) => { - await gateway(opts); + const runtime = await gateway(opts); + installGatewaySignalLifecycle(runtime); }); app @@ -601,10 +629,34 @@ export async function main(argv: string[] = process.argv): Promise { .option("--no-markdown", "Render final responses as plain text") .option("--logs", "Enable runtime logs", false) .option("--no-logs", "Disable runtime logs") - .action(async (opts) => { + .action(async (localOpts, actionCommand) => { + const opts = resolveCliActionOptions(localOpts, actionCommand); await agent({ ...opts, sessionId: opts.session }); }); + app + .command("goal") + .description("Run a persistent Goal to a terminal state.") + .option("-m, --message ", "Goal objective") + .option("--message-file ", "Read the Goal objective from a UTF-8 file") + .option("-s, --session ", "Existing cli:* session ID") + .option("--standalone", "Create a new standalone terminal session") + .option("--project ", "Create a terminal session bound to a project path") + .option("--token-budget ", "Cumulative Goal token budget") + .option("-t, --timeout ", "Maximum wall-clock runtime", "14400") + .option("-o, --output ", "Write the structured result as JSON") + .option("-w, --workspace ", "Workspace directory") + .option("-c, --config ", "Path to config file") + .option("--logs", "Enable runtime logs", false) + .option("--no-logs", "Disable runtime logs") + .action(async (localOpts, actionCommand) => { + const opts = resolveCliActionOptions(localOpts, actionCommand); + const result = await goal({ ...opts, sessionId: opts.session }); + if (result.status !== "success") { + throw new Error(`Goal stopped with ${result.goal.status}: ${result.summary}`); + } + }); + const sessionsCommand = app.command("sessions").description("Manage terminal sessions."); sessionsCommand .command("list") @@ -1495,6 +1547,258 @@ export async function gateway({ }; } +export type HeadlessGoalResult = { + status: "success" | "warning" | "error"; + summary: string; + next_actions: string[]; + artifacts: { + workspace: string; + project: string | null; + session_file: string | null; + result_file: string | null; + }; + goal: AgentGoalState; + session_id: string | null; + timed_out: boolean; + last_message: string | null; + metrics: { + tokens_used: number; + time_used_seconds: number; + }; +}; + +function positiveIntegerOption(value: string | number | null | undefined, name: string): number | null { + if (value === null || value === undefined || value === "") return null; + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +function headlessGoalObjective( + message: string | null | undefined, + messageFile: string | null | undefined, +): string { + if (message && messageFile) throw new Error("--message and --message-file are mutually exclusive"); + const value = messageFile + ? fs.readFileSync(path.resolve(messageFile), "utf8") + : message ?? (process.stdin.isTTY ? "" : fs.readFileSync(0, "utf8")); + const objective = value.trim(); + if (!objective) throw new Error("A non-empty Goal objective is required"); + return objective; +} + +function writeHeadlessGoalResult(result: HeadlessGoalResult, output: string | null | undefined): void { + const rendered = `${JSON.stringify(result, null, 2)}\n`; + if (!output) { + process.stdout.write(rendered); + return; + } + const outputPath = path.resolve(output); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, rendered, "utf8"); + console.log(`Goal result: ${outputPath}`); +} + +function drainGoalOutput(bus: MessageBus, messages: string[]): void { + while (true) { + const outbound = bus.outbound.getNowait(); + if (!outbound) return; + const content = String(outbound.content ?? "").trim(); + if (content) messages.push(content); + } +} + +function headlessGoalOutcome( + goalState: AgentGoalState, + timedOut: boolean, + lastMessage: string | null, +): Pick { + if (timedOut) { + return { + status: "warning", + summary: `Goal paused after reaching the headless timeout (${goalState.status ?? "unknown"}).`, + next_actions: ["Inspect the session artifact and increase --timeout before retrying."], + }; + } + if (goalState.status === "completed") { + return { + status: "success", + summary: lastMessage ?? "Goal completed.", + next_actions: [], + }; + } + const status = goalState.status ?? "unknown"; + return { + status: "warning", + summary: lastMessage ?? `Goal stopped with status ${status}.`, + next_actions: [`Inspect the session artifact before resuming the ${status} Goal.`], + }; +} + +export async function goal({ + message = null, + messageFile = null, + sessionId = null, + standalone = false, + project = null, + tokenBudget = null, + timeout = 14_400, + output = null, + workspace = null, + config = null, + logs = false, +}: { + message?: string | null; + messageFile?: string | null; + sessionId?: string | null; + standalone?: boolean; + project?: string | null; + tokenBudget?: string | number | null; + timeout?: string | number | null; + output?: string | null; + workspace?: string | null; + config?: string | null; + logs?: boolean; +} = {}): Promise { + const objective = headlessGoalObjective(message, messageFile); + const budget = positiveIntegerOption(tokenBudget, "--token-budget"); + const timeoutSeconds = positiveIntegerOption(timeout, "--timeout") ?? 14_400; + const invocationCwd = process.cwd(); + const loaded = loadRuntimeConfig(config, workspace); + const workspacePath = syncRuntimeWorkspaceTemplates(loaded); + setCliRuntimeLogs(Boolean(logs)); + const bus = new MessageBus(); + const loop = AgentLoop.fromConfig(loaded, bus); + let target: TerminalTarget | null = null; + let runTask: Promise | null = null; + let runError: unknown = null; + let runFinished = false; + let timedOut = false; + const messages: string[] = []; + + try { + target = resolveTerminalTarget(terminalTargetDependenciesForLoop(loop), { + sessionId, + standalone, + project, + invocationCwd, + }); + if (project) { + const requestedProject = fs.realpathSync(path.resolve(invocationCwd, expandHomePath(project))); + const actualProject = fs.realpathSync(target.cwd); + if (target.target !== "project" || actualProject !== requestedProject) { + throw new Error( + `Goal project binding mismatch: requested ${requestedProject}, resolved ${actualProject}`, + ); + } + } + if (loop.sessions instanceof SessionManager) { + loop.guiTranscriptMirror = new GuiTranscriptMirror(loop.sessions, target.cwd); + loop.guiTranscriptMirror.sessionUpdated(target.sessionId); + } + + const createResponse = await loop.processDirect(`/goal create ${objective}`, { + sessionKey: target.sessionId, + channel: "cli", + chatId: target.sessionId.slice("cli:".length) || "goal", + }); + const created = loop.goalRuntime.get(target.sessionId); + if (!created) { + throw new Error(createResponse?.content?.trim() || "Goal creation failed"); + } + if (budget !== null) { + await loop.goalRuntime.setBudget(target.sessionId, created.goalId, budget); + await loop.goalRuntime.flushEffects(target.sessionId); + } + + runTask = loop.run() + .then(() => { + runFinished = true; + }) + .catch((error) => { + runError = error; + }); + const deadline = Date.now() + timeoutSeconds * 1_000; + while (true) { + drainGoalOutput(bus, messages); + if (runError) throw runError; + const current = loop.goalRuntime.get(target.sessionId); + const activeTurns = loop.activeTasks.get(target.sessionId)?.length ?? 0; + if (current && current.status !== "active" && activeTurns === 0) break; + if (runFinished) throw new Error("Goal runtime stopped before reaching a terminal state"); + if (Date.now() >= deadline) { + timedOut = true; + if (current?.status === "active") { + await loop.goalRuntime.pauseAndCancel(target.sessionId, current.goalId); + await loop.goalRuntime.flushEffects(target.sessionId); + } + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } catch (error) { + const current = target ? publicGoalState(loop.goalRuntime.get(target.sessionId)) : publicGoalState(null); + const result: HeadlessGoalResult = { + status: "error", + summary: error instanceof Error ? error.message : String(error), + next_actions: ["Inspect the runtime log and configuration before retrying."], + artifacts: { + workspace: workspacePath, + project: target?.cwd ?? (project ? path.resolve(invocationCwd, project) : null), + session_file: target && typeof loop.sessions.pathFor === "function" + ? loop.sessions.pathFor(target.sessionId) + : null, + result_file: output ? path.resolve(output) : null, + }, + goal: current, + session_id: target?.sessionId ?? null, + timed_out: timedOut, + last_message: messages.at(-1)?.slice(-8_000) ?? null, + metrics: { + tokens_used: current.tokens_used, + time_used_seconds: current.time_used_seconds, + }, + }; + writeHeadlessGoalResult(result, output); + return result; + } finally { + loop.stop(); + await Promise.allSettled([ + runTask ?? Promise.resolve(), + closeLoopRuntimeTools(loop), + Promise.resolve(loop.sessions.flushAll()), + ]); + drainGoalOutput(bus, messages); + } + + const current = publicGoalState(target ? loop.goalRuntime.get(target.sessionId) : null); + const lastMessage = messages.at(-1)?.slice(-8_000) ?? null; + const outcome = headlessGoalOutcome(current, timedOut, lastMessage); + const result: HeadlessGoalResult = { + ...outcome, + artifacts: { + workspace: workspacePath, + project: target?.cwd ?? null, + session_file: target && typeof loop.sessions.pathFor === "function" + ? loop.sessions.pathFor(target.sessionId) + : null, + result_file: output ? path.resolve(output) : null, + }, + goal: current, + session_id: target?.sessionId ?? null, + timed_out: timedOut, + last_message: lastMessage, + metrics: { + tokens_used: current.tokens_used, + time_used_seconds: current.time_used_seconds, + }, + }; + writeHeadlessGoalResult(result, output); + return result; +} + export async function agent({ message = null, sessionId = null, diff --git a/App/memmy-agent/src/entrypoints/cli/linux-systemd-gateway.ts b/App/memmy-agent/src/entrypoints/cli/linux-systemd-gateway.ts new file mode 100644 index 000000000..1aad8a91a --- /dev/null +++ b/App/memmy-agent/src/entrypoints/cli/linux-systemd-gateway.ts @@ -0,0 +1,611 @@ +import { execFile } from "node:child_process"; +import crypto from "node:crypto"; +import { + chmodSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import { mutateRuntimeConfig } from "@memmy/migrations"; +import type { Config } from "../../config/schema.js"; +import { getConfigPath, getRuntimeSubdir } from "../../config/paths.js"; +import { tuiGatewayOptionsFromConfig } from "./tui-gateway-client.js"; + +const execFileAsync = promisify(execFile); +const STARTUP_TIMEOUT_MS = 30_000; +const PROBE_TIMEOUT_MS = 1_500; +const POLL_INTERVAL_MS = 150; +const SERVICE_STABILITY_MS = 500; +const MEMORY_SERVICE_NAME = "memmy-memory.service"; +const SYSTEMD_GATEWAY_ENV = "MEMMY_LINUX_SYSTEMD_GATEWAY"; + +const PERSISTED_GATEWAY_ENV_KEYS = [ + "PATH", + "SHELL", + "LANG", + "LC_ALL", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + "GIT_SSH_COMMAND", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + "GROQ_API_KEY", + "GROQ_BASE_URL", + "DEEPSEEK_API_KEY", + "TAVILY_API_KEY", + "BRAVE_API_KEY", + "JINA_API_KEY", + "KAGI_API_KEY", + "OLOSTEP_API_KEY", + "SEARXNG_BASE_URL", + "OPENAI_TRANSCRIPTION_BASE_URL", + "GITHUB_TOKEN", + "GH_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_BEARER_TOKEN_BEDROCK", + "OAUTH_CLI_KIT_TOKEN_PATH", + "OPENAI_CODEX_TOKEN_PATH", + "CHATGPT_TOKEN_PATH", + "OPENAI_CODEX_ACCESS_TOKEN", + "CHATGPT_ACCESS_TOKEN", + "OPENAI_CODEX_ACCOUNT_ID", + "CHATGPT_ACCOUNT_ID", + "MEMMY_AGENT_PATH_APPEND", + "MEMMY_AGENT_STREAM_IDLE_TIMEOUT_S", + "MEMMY_CLOUD_SERVICE", + "MEMMY_APP_EDITION", +] as const; + +export class LinuxSystemdGatewayError extends Error { + constructor(message: string) { + super(message); + this.name = "LinuxSystemdGatewayError"; + } +} + +export type GatewayProbe = + | { status: "ready" } + | { status: "unavailable"; detail: string } + | { status: "unexpected"; detail: string }; + +type GatewayEndpoint = { + baseUrl: string; + bootstrapSecret: string | null; +}; + +type MemoryServiceEndpoint = { + baseUrl: string; + token: string | null; +}; + +type LinuxRootTerminalDependencies = { + loadConfig: () => Config; + onboardWizard: () => Promise; + runInteractive: (config?: Config) => Promise; + platform?: NodeJS.Platform; + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; + systemdGatewayEnabled?: boolean; + prepareGatewayConfig?: () => Promise; + refreshMemoryService?: (config: Config) => Promise; + prepareGatewayEnvironment?: () => Promise; + enableGatewayService?: (options?: { restart?: boolean }) => Promise; + probeGateway?: (config: Config) => Promise; + gatewayServiceMainPid?: () => Promise; + now?: () => number; + sleep?: (milliseconds: number) => Promise; + startupTimeoutMs?: number; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function mutableRecord(value: unknown): Record { + return isRecord(value) ? { ...value } : {}; +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function errorCode(error: unknown): string | null { + let current: unknown = error; + for (let depth = 0; depth < 4 && isRecord(current); depth += 1) { + if (typeof current.code === "string") return current.code; + current = current.cause; + } + return null; +} + +function unavailableNetworkError(error: unknown): boolean { + if (error instanceof Error && error.name === "AbortError") return true; + return new Set([ + "ECONNREFUSED", + "EHOSTUNREACH", + "ENETUNREACH", + "ENETDOWN", + "ETIMEDOUT", + ]).has(errorCode(error) ?? ""); +} + +function gatewayEndpoint(config: Config): GatewayEndpoint { + const options = tuiGatewayOptionsFromConfig(config, "cli:systemd-probe"); + return { + baseUrl: options.baseUrl, + bootstrapSecret: options.bootstrapSecret?.trim() || null, + }; +} + +function memoryServiceEndpoint(config: Config): MemoryServiceEndpoint { + const storage = isRecord(config.memmyMemory.storage) ? config.memmyMemory.storage : {}; + return { + baseUrl: typeof storage.endpoint === "string" && storage.endpoint.trim() + ? storage.endpoint.trim() + : "http://127.0.0.1:18960", + token: typeof storage.token === "string" && storage.token.trim() + ? storage.token.trim() + : null, + }; +} + +function validBootstrap(value: unknown): boolean { + if (!isRecord(value)) return false; + return typeof value.token === "string" + && value.token.length > 0 + && typeof value.ws_path === "string" + && value.ws_path.startsWith("/"); +} + +export function hasUsableDefaultModel(config: Config): boolean { + try { + const preset = config.resolvePreset(); + return Boolean(preset.model.trim() && config.getProviderName(preset.model, { preset })); + } catch { + return false; + } +} + +export async function probeGateway( + config: Config, + { + fetchImpl = fetch, + timeoutMs = PROBE_TIMEOUT_MS, + }: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, +): Promise { + let endpoint: GatewayEndpoint; + try { + endpoint = gatewayEndpoint(config); + } catch (error) { + const detail = errorMessage(error); + if (detail.includes("WebSocket Gateway is disabled")) { + return { status: "unavailable", detail }; + } + return { status: "unexpected", detail: `invalid Gateway configuration: ${detail}` }; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(`${endpoint.baseUrl}/webui/bootstrap`, { + cache: "no-store", + headers: endpoint.bootstrapSecret + ? { authorization: `Bearer ${endpoint.bootstrapSecret}` } + : undefined, + signal: controller.signal, + }); + if (!response.ok) { + return { + status: "unexpected", + detail: `bootstrap HTTP ${response.status}`, + }; + } + let body: unknown; + try { + body = await response.json(); + } catch (error) { + return { status: "unexpected", detail: `invalid bootstrap JSON: ${errorMessage(error)}` }; + } + return validBootstrap(body) + ? { status: "ready" } + : { status: "unexpected", detail: "bootstrap response is incompatible" }; + } catch (error) { + if (unavailableNetworkError(error)) { + return { status: "unavailable", detail: errorMessage(error) }; + } + return { status: "unexpected", detail: errorMessage(error) }; + } finally { + clearTimeout(timer); + } +} + +export async function probeMemoryServiceAuthentication( + config: Config, + { + fetchImpl = fetch, + timeoutMs = PROBE_TIMEOUT_MS, + }: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, +): Promise { + const endpoint = memoryServiceEndpoint(config); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(new URL("/api/v1/panel/overview", endpoint.baseUrl), { + cache: "no-store", + headers: endpoint.token + ? { authorization: `Bearer ${endpoint.token}` } + : undefined, + signal: controller.signal, + }); + if (!response.ok) { + return { + status: "unexpected", + detail: `authenticated Memory HTTP ${response.status}`, + }; + } + return { status: "ready" }; + } catch (error) { + if (unavailableNetworkError(error)) { + return { status: "unavailable", detail: errorMessage(error) }; + } + return { status: "unexpected", detail: errorMessage(error) }; + } finally { + clearTimeout(timer); + } +} + +export async function prepareLinuxGatewayConfig( + loadConfig: () => Config, + configPath = getConfigPath(), +): Promise { + await mutateRuntimeConfig(configPath, (root) => { + const channels = mutableRecord(root.channels); + const websocket = mutableRecord(channels.websocket); + const gateway = mutableRecord(root.gateway); + + websocket.enabled = true; + websocket.host ??= "127.0.0.1"; + websocket.port ??= 18980; + websocket.tokenTtlS ??= 86_400; + websocket.websocketRequiresToken ??= true; + websocket.allowFrom ??= ["*"]; + if ( + (typeof websocket.tokenIssueSecret !== "string" || !websocket.tokenIssueSecret.trim()) + && (typeof websocket.token !== "string" || !websocket.token.trim()) + ) { + websocket.tokenIssueSecret = crypto.randomBytes(32).toString("hex"); + } + + gateway.enabled = true; + gateway.host ??= "127.0.0.1"; + gateway.port ??= 18970; + + channels.websocket = websocket; + root.channels = channels; + root.gateway = gateway; + }); + return loadConfig(); +} + +function environmentFilePath(): string { + const configured = process.env.MEMMY_GATEWAY_ENV_FILE?.trim(); + return configured ? path.resolve(configured) : path.join(getRuntimeSubdir("systemd"), "gateway.env"); +} + +function referencedEnvironmentKeys(configText: string): Set { + const keys = new Set(); + for (const match of configText.matchAll(/\$\{([A-Z_][A-Z0-9_]*)(?::[^}]*)?\}/gi)) { + keys.add(match[1]); + } + return keys; +} + +function quoteEnvironmentValue(key: string, value: string): string { + if (value.includes("\0") || value.includes("\n") || value.includes("\r")) { + throw new LinuxSystemdGatewayError( + `Environment variable ${key} contains a newline or NUL and cannot be persisted for systemd.`, + ); + } + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export async function prepareSystemdGatewayEnvironment( + { + configPath = getConfigPath(), + destination = environmentFilePath(), + env = process.env, + }: { + configPath?: string; + destination?: string; + env?: NodeJS.ProcessEnv; + } = {}, +): Promise { + let configText = ""; + try { + configText = readFileSync(configPath, "utf8"); + } catch (error) { + throw new LinuxSystemdGatewayError( + `Could not read Gateway configuration for systemd environment: ${errorMessage(error)}`, + ); + } + + const keys = referencedEnvironmentKeys(configText); + for (const key of PERSISTED_GATEWAY_ENV_KEYS) keys.add(key); + const lines = [ + "# Generated by memmy. Mode 0600; refreshed by each interactive memmy launch.", + ]; + for (const key of [...keys].sort()) { + const value = env[key]; + if (value === undefined) continue; + lines.push(`${key}=${quoteEnvironmentValue(key, value)}`); + } + const content = `${lines.join("\n")}\n`; + + let existing: string | null = null; + try { + existing = readFileSync(destination, "utf8"); + } catch { + existing = null; + } + try { + mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 }); + chmodSync(path.dirname(destination), 0o700); + if (existing === content) { + chmodSync(destination, 0o600); + return false; + } + } catch (error) { + throw new LinuxSystemdGatewayError( + `Could not prepare the private Gateway environment directory: ${errorMessage(error)}`, + ); + } + + const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" }); + chmodSync(temporary, 0o600); + renameSync(temporary, destination); + } catch (error) { + rmSync(temporary, { force: true }); + throw new LinuxSystemdGatewayError( + `Could not persist the private Gateway environment file: ${errorMessage(error)}`, + ); + } + return true; +} + +export async function enableSystemdGatewayService( + { restart = false }: { restart?: boolean } = {}, +): Promise { + try { + if (restart) { + await execFileAsync("systemctl", ["--user", "enable", "memmy-gateway.service"], { + timeout: 20_000, + }); + await execFileAsync("systemctl", ["--user", "restart", "memmy-gateway.service"], { + timeout: 20_000, + }); + } else { + await execFileAsync( + "systemctl", + ["--user", "enable", "--now", "memmy-gateway.service"], + { timeout: 20_000 }, + ); + } + } catch (error) { + const detail = isRecord(error) && typeof error.stderr === "string" + ? error.stderr.trim() + : errorMessage(error); + throw new LinuxSystemdGatewayError( + "Could not start memmy-gateway.service with systemd --user" + + `${detail ? `: ${detail}` : "."} ` + + "Install Memmy with the Linux installer and check " + + "`systemctl --user status memmy-gateway.service`.", + ); + } +} + +export async function systemdGatewayMainPid(): Promise { + return systemdServiceMainPid("memmy-gateway.service"); +} + +async function systemdServiceMainPid(serviceName: string): Promise { + try { + const { stdout } = await execFileAsync( + "systemctl", + ["--user", "show", serviceName, "--property=MainPID", "--value"], + { timeout: 5_000 }, + ); + const pid = Number.parseInt(stdout.trim(), 10); + if (!Number.isSafeInteger(pid) || pid <= 1) return null; + process.kill(pid, 0); + return pid; + } catch { + return null; + } +} + +export async function refreshSystemdMemoryService(config: Config): Promise { + try { + await execFileAsync("systemctl", ["--user", "restart", MEMORY_SERVICE_NAME], { + timeout: 20_000, + }); + } catch (error) { + const detail = isRecord(error) && typeof error.stderr === "string" + ? error.stderr.trim() + : errorMessage(error); + throw new LinuxSystemdGatewayError( + `Could not restart ${MEMORY_SERVICE_NAME} after onboarding${detail ? `: ${detail}` : "."}`, + ); + } + + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + let lastDetail = "connection refused"; + while (Date.now() < deadline) { + const result = await probeMemoryServiceAuthentication(config); + if (result.status === "ready") { + const firstPid = await systemdServiceMainPid(MEMORY_SERVICE_NAME); + if (firstPid !== null) { + await sleep(SERVICE_STABILITY_MS); + const secondPid = await systemdServiceMainPid(MEMORY_SERVICE_NAME); + if (secondPid === firstPid) return; + } + lastDetail = `${MEMORY_SERVICE_NAME} has no stable MainPID`; + } else { + lastDetail = result.detail; + if (result.status === "unexpected") { + throw new LinuxSystemdGatewayError( + `Memory service rejected the post-onboarding configuration (${result.detail}).`, + ); + } + } + await sleep(POLL_INTERVAL_MS); + } + + throw new LinuxSystemdGatewayError( + `${MEMORY_SERVICE_NAME} did not become authenticated and ready after onboarding ` + + `(${lastDetail}). Check \`systemctl --user status ${MEMORY_SERVICE_NAME}\` and ` + + `\`journalctl --user -u ${MEMORY_SERVICE_NAME}\`.`, + ); +} + +async function waitForGatewayReady( + config: Config, + dependencies: Pick< + LinuxRootTerminalDependencies, + "probeGateway" | "now" | "sleep" | "startupTimeoutMs" | "gatewayServiceMainPid" + >, +): Promise { + const probe = dependencies.probeGateway ?? ((candidate) => probeGateway(candidate)); + const now = dependencies.now ?? Date.now; + const wait = dependencies.sleep ?? sleep; + const serviceMainPid = dependencies.gatewayServiceMainPid ?? systemdGatewayMainPid; + const deadline = now() + (dependencies.startupTimeoutMs ?? STARTUP_TIMEOUT_MS); + let lastDetail = "connection refused"; + + while (now() < deadline) { + const result = await probe(config); + if (result.status === "ready") { + const firstPid = await serviceMainPid(); + if (firstPid !== null) { + await wait(SERVICE_STABILITY_MS); + const secondPid = await serviceMainPid(); + if (secondPid === firstPid) return; + } + lastDetail = "memmy-gateway.service has no stable MainPID"; + continue; + } + lastDetail = result.detail; + if (result.status === "unexpected") { + throw new LinuxSystemdGatewayError( + `Gateway endpoint is occupied, incompatible, or rejected authentication (${result.detail}).`, + ); + } + await wait(POLL_INTERVAL_MS); + } + + throw new LinuxSystemdGatewayError( + "memmy-gateway.service did not become ready" + + ` (${lastDetail}). Check \`systemctl --user status memmy-gateway.service\` and ` + + "`journalctl --user -u memmy-gateway.service`.", + ); +} + +export async function runLinuxRootTerminal( + dependencies: LinuxRootTerminalDependencies, +): Promise { + const platform = dependencies.platform ?? process.platform; + const stdinIsTTY = dependencies.stdinIsTTY ?? Boolean(process.stdin.isTTY); + const stdoutIsTTY = dependencies.stdoutIsTTY ?? Boolean(process.stdout.isTTY); + const systemdGatewayEnabled = dependencies.systemdGatewayEnabled + ?? process.env[SYSTEMD_GATEWAY_ENV] === "1"; + if (platform !== "linux" || !stdinIsTTY || !stdoutIsTTY || !systemdGatewayEnabled) { + return dependencies.runInteractive(); + } + + let config = dependencies.loadConfig(); + let onboarded = false; + if (!hasUsableDefaultModel(config)) { + await dependencies.onboardWizard(); + onboarded = true; + config = dependencies.loadConfig(); + if (!hasUsableDefaultModel(config)) { + throw new LinuxSystemdGatewayError( + "No usable default model was saved. Run `memmy onboard --wizard` to finish configuration.", + ); + } + } + + const probe = dependencies.probeGateway ?? ((candidate) => probeGateway(candidate)); + const serviceMainPid = dependencies.gatewayServiceMainPid ?? systemdGatewayMainPid; + let result = await probe(config); + let servicePid = await serviceMainPid(); + let restart = false; + + if (result.status === "ready" && servicePid === null) { + throw new LinuxSystemdGatewayError( + "A compatible Gateway is already running outside memmy-gateway.service. " + + "Stop that Gateway before starting the installed Memmy CLI.", + ); + } + if (result.status === "unexpected" && servicePid === null) { + throw new LinuxSystemdGatewayError( + `Gateway endpoint is occupied, incompatible, or rejected authentication (${result.detail}).`, + ); + } + + if (result.status !== "ready") { + config = dependencies.prepareGatewayConfig + ? await dependencies.prepareGatewayConfig() + : await prepareLinuxGatewayConfig(dependencies.loadConfig); + restart = servicePid !== null; + result = await probe(config); + servicePid = await serviceMainPid(); + if (result.status === "ready" && servicePid === null) { + throw new LinuxSystemdGatewayError( + "A compatible Gateway is already running outside memmy-gateway.service. " + + "Stop that Gateway before starting the installed Memmy CLI.", + ); + } + if (result.status === "unexpected" && servicePid === null) { + throw new LinuxSystemdGatewayError( + `Gateway endpoint is occupied, incompatible, or rejected authentication (${result.detail}).`, + ); + } + } + + if (onboarded) { + await (dependencies.refreshMemoryService ?? refreshSystemdMemoryService)(config); + } + + const environmentChanged = dependencies.prepareGatewayEnvironment + ? await dependencies.prepareGatewayEnvironment() + : await prepareSystemdGatewayEnvironment(); + restart ||= environmentChanged || result.status === "unexpected"; + await (dependencies.enableGatewayService ?? enableSystemdGatewayService)({ restart }); + await waitForGatewayReady(config, dependencies); + + return dependencies.runInteractive(config); +} diff --git a/App/memmy-agent/src/entrypoints/cli/root-terminal-options.ts b/App/memmy-agent/src/entrypoints/cli/root-terminal-options.ts new file mode 100644 index 000000000..42a9e1f21 --- /dev/null +++ b/App/memmy-agent/src/entrypoints/cli/root-terminal-options.ts @@ -0,0 +1,36 @@ +export type RootTerminalOptions = { + sessionId?: string; + standalone?: boolean; + project?: string; +}; + +export function parseRootTerminalOptions(argv: string[]): RootTerminalOptions | null { + if (argv.length <= 2) return {}; + + const args = argv.slice(2); + const options: RootTerminalOptions = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--standalone") { + options.standalone = true; + } else if (arg === "--session" || arg === "-s") { + const value = args[++index]; + if (!value || value.startsWith("-")) throw new Error("--session requires a sessionId"); + options.sessionId = value; + } else if (arg === "--project") { + const value = args[++index]; + if (!value || value.startsWith("-")) throw new Error("--project requires a path"); + options.project = value; + } else { + return null; + } + } + + const selected = Number(Boolean(options.sessionId)) + + Number(Boolean(options.standalone)) + + Number(Boolean(options.project)); + if (selected > 1) { + throw new Error("--session, --standalone, and --project are mutually exclusive"); + } + return options; +} diff --git a/App/memmy-agent/src/main.ts b/App/memmy-agent/src/main.ts index 77cd228e1..d2e714b5b 100644 --- a/App/memmy-agent/src/main.ts +++ b/App/memmy-agent/src/main.ts @@ -3,14 +3,14 @@ import "./load-env.js"; import { main } from "./entrypoints/cli/commands.js"; import { ConfigError } from "./config/loader.js"; +import { LinuxSystemdGatewayError } from "./entrypoints/cli/linux-systemd-gateway.js"; try { await main(); } catch (error) { - if (!(error instanceof ConfigError)) throw error; - // Config load/validation failures are expected user-facing errors (bad YAML, invalid - // field, missing env var reference) — report them as a concise fatal message instead of - // an unhandled-rejection stack trace, and exit non-zero so scripts can detect the failure. + if (!(error instanceof ConfigError) && !(error instanceof LinuxSystemdGatewayError)) throw error; + // Config validation and Linux systemd Gateway failures are expected user-facing errors. + // Report them concisely and exit non-zero so scripts can detect the failure. console.error(`memmy: ${error.message}`); process.exitCode = 1; } diff --git a/App/memmy-agent/src/memmy-memory/config.ts b/App/memmy-agent/src/memmy-memory/config.ts index 22ab67047..4122661a8 100644 --- a/App/memmy-agent/src/memmy-memory/config.ts +++ b/App/memmy-agent/src/memmy-memory/config.ts @@ -1,14 +1,22 @@ import type { Config } from "../config/schema.js"; -import type { MemmyMemoryResolvedConfig } from "./types.js"; +import type { MemmyMemoryLayer, MemmyMemoryResolvedConfig } from "./types.js"; + +const MEMORY_LAYERS = new Set(["L1", "L2", "L3", "Skill"]); export function resolveMemmyMemoryConfig(config: Config | Record | null | undefined): MemmyMemoryResolvedConfig { const raw = (config as any)?.memmyMemory ?? {}; return { enabled: Boolean(raw?.enabled ?? raw?.enable ?? true), userId: stringOrUndefined(raw?.userId) ?? "local-user", + retrievalLayers: memoryLayersOrUndefined(raw?.retrievalLayers), }; } function stringOrUndefined(value: any): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } + +function memoryLayersOrUndefined(value: any): MemmyMemoryLayer[] | undefined { + if (!Array.isArray(value)) return undefined; + return [...new Set(value.filter((layer): layer is MemmyMemoryLayer => MEMORY_LAYERS.has(layer)))]; +} diff --git a/App/memmy-agent/src/memmy-memory/hook.ts b/App/memmy-agent/src/memmy-memory/hook.ts index ece29af34..1ea3a9220 100644 --- a/App/memmy-agent/src/memmy-memory/hook.ts +++ b/App/memmy-agent/src/memmy-memory/hook.ts @@ -66,6 +66,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime | "workspace" | "profileLabel" | "userId" + | "retrievalLayers" | "getAnalyticsClientId" | "getAnalyticsUserId" | "getAnalyticsUserMode" @@ -74,6 +75,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime workspace: string | null; profileLabel: string | null; userId: string | null; + retrievalLayers: NonNullable | null; getAnalyticsClientId: (() => string | null | undefined) | null; getAnalyticsUserId: (() => string | null | undefined) | null; getAnalyticsUserMode: (() => string | null | undefined) | null; @@ -97,6 +99,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime profileId: options.profileId ?? PROFILE_ID, profileLabel: options.profileLabel ?? PROFILE_ID, userId: options.userId ?? null, + retrievalLayers: options.retrievalLayers ?? null, getAnalyticsClientId: options.getAnalyticsClientId ?? null, getAnalyticsUserId: options.getAnalyticsUserId ?? null, getAnalyticsUserMode: options.getAnalyticsUserMode ?? null, @@ -190,7 +193,12 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime const events = this.eventsFor(sessionKey, ctx); this.analytics.track(events.turnStarted, this.turnAnalyticsParams(turn)); - const searchBase = this.memoryOpParams(turn, MEMORY_OP_MODES.turnStart, "all", sessionKey, ctx); + const retrievalLayerLabel = this.options.retrievalLayers === null + ? "all" + : this.options.retrievalLayers.length > 0 + ? this.options.retrievalLayers.join("+") + : "none"; + const searchBase = this.memoryOpParams(turn, MEMORY_OP_MODES.turnStart, retrievalLayerLabel, sessionKey, ctx); this.analytics.track(events.searchStarted, searchBase); const searchStartedAt = Date.now(); try { @@ -198,6 +206,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime ...this.requestEnvelope(sessionKey, ctx), sessionId, query: userText || "(conversation continued)", + layers: this.options.retrievalLayers ?? undefined, })); turn.episodeId = stringOrUndefined(response?.episodeId); turn.sourceMemoryIds = arrayOfStrings(response?.sourceMemoryIds); @@ -206,7 +215,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime this.injectMemoryContext(messages, response?.injectedContext); turn.messageStartIndex = messages.length; this.analytics.track(events.searchSucceeded, { - ...this.memoryOpParams(turn, MEMORY_OP_MODES.turnStart, "all", sessionKey, ctx), + ...this.memoryOpParams(turn, MEMORY_OP_MODES.turnStart, retrievalLayerLabel, sessionKey, ctx), duration_ms: elapsedMs(searchStartedAt), success: true, hit_count: hitCountFromSearchResponse(response), diff --git a/App/memmy-agent/src/memmy-memory/register.ts b/App/memmy-agent/src/memmy-memory/register.ts index 0ee2672ee..97913f2ce 100644 --- a/App/memmy-agent/src/memmy-memory/register.ts +++ b/App/memmy-agent/src/memmy-memory/register.ts @@ -44,6 +44,7 @@ export function createMemmyMemoryIntegration( const hook = new MemmyMemoryHook(client, { workspace: options.workspace ?? null, userId: resolved.userId, + retrievalLayers: resolved.retrievalLayers, // Prefer disk config: AgentLoop keeps a cloned in-memory Config that stays // stale after desktop switches account ↔ byok and rewrites config.yaml. getAnalyticsUserId: () => resolveLiveLoggedInAnalyticsUserId(), diff --git a/App/memmy-agent/src/memmy-memory/types.ts b/App/memmy-agent/src/memmy-memory/types.ts index a7c20bc2e..d52044899 100644 --- a/App/memmy-agent/src/memmy-memory/types.ts +++ b/App/memmy-agent/src/memmy-memory/types.ts @@ -20,6 +20,8 @@ export type { WorkspaceUri }; +export type MemmyMemoryLayer = "L1" | "L2" | "L3" | "Skill"; + export type MemmyMemoryRuntimeNamespace = { source: string; profileId: string; @@ -50,6 +52,7 @@ export type MemmyMemoryConnection = { export type MemmyMemoryResolvedConfig = { enabled: boolean; userId?: string; + retrievalLayers?: MemmyMemoryLayer[]; }; export type MemmyMemoryInstallOptions = { @@ -88,6 +91,8 @@ export type MemmyMemoryHookOptions = { profileId?: string; profileLabel?: string; userId?: string; + /** Optional per-run upper bound on layers eligible for automatic turn-start retrieval. */ + retrievalLayers?: MemmyMemoryLayer[]; /** Optional override for GA4 client_id; defaults to reading desktop-written ~/.memmy/analytics-client-id. */ getAnalyticsClientId?: () => string | null | undefined; /** Optional logged-in account id for GA4 user_id; omitted when anonymous. */ diff --git a/App/memmy-agent/tests/command/goal-command.test.ts b/App/memmy-agent/tests/command/goal-command.test.ts index 87940db75..9bf7f3f99 100644 --- a/App/memmy-agent/tests/command/goal-command.test.ts +++ b/App/memmy-agent/tests/command/goal-command.test.ts @@ -89,6 +89,15 @@ describe("/goal command", () => { expect(loop.goalRuntime.get("cli:direct")?.objective).toBe("pause migration"); }); + it("preserves multiline formatting in an explicitly created objective", async () => { + const loop = makeLoop(); + await cmdGoal(context(loop, "/goal create first line\n\n- keep this item", "turn-create")); + + expect(loop.goalRuntime.get("cli:direct")?.objective).toBe( + "first line\n\n- keep this item", + ); + }); + it("marks only successful WebUI Goal creation acknowledgements as hidden", async () => { const webuiLoop = makeLoop(); const created = await cmdGoal(context( diff --git a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts index 840a9892b..7be3b5920 100644 --- a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts +++ b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { Command } from "commander"; import { afterEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; import { buildHelpText, builtinCommandPalette } from "../../../src/command/builtin.js"; @@ -32,6 +33,7 @@ import { cliRuntimeLogsEnabled, deleteOauthFiles, gateway, + goal, isRootInteractiveRequest, isRootVersionRequest, loadRuntimeConfig, @@ -42,6 +44,7 @@ import { pluginsListRows, providerLogin, providerLogout, + resolveCliActionOptions, resolveOauthProvider, runInternalCommand, serve, @@ -242,6 +245,34 @@ describe("CLI command helpers", () => { expect(help).toContain("--project "); }); + it("merges terminal target options parsed by the root command into subcommand options", async () => { + const program = new Command("memmy") + .option("--project "); + let resolved: Record | null = null; + program + .command("goal") + .option("--message-file ") + .option("--project ") + .action((localOpts, actionCommand) => { + resolved = resolveCliActionOptions(localOpts, actionCommand); + }); + + await program.parseAsync([ + "node", + "memmy", + "goal", + "--message-file", + "/tmp/objective.txt", + "--project", + "/app", + ]); + + expect(resolved).toEqual(expect.objectContaining({ + messageFile: "/tmp/objective.txt", + project: "/app", + })); + }); + it("stops before the root TUI when startup migrations fail", async () => { const root = tempRoot("memmy-root-migration-failure-"); const workspace = path.join(root, "workspace"); @@ -1258,6 +1289,99 @@ describe("CLI command helpers", () => { expect(loop.sessions.flushAll).toHaveBeenCalledTimes(1); }); + it("runs a headless Goal until completion and writes a structured result", async () => { + const root = tempRoot(); + const workspace = path.join(root, "workspace"); + const output = path.join(root, "result", "goal.json"); + const configPath = writeConfig(root, { + agents: { defaults: { workspace, model: "test-model" } }, + }); + const order: string[] = []; + let state: any = null; + const busMessages: OutboundMessage[] = []; + const fakeLoop = { + workspace, + projectStore: {} as any, + guiTranscriptMirror: null, + activeTasks: new Map(), + sessions: { + flushAll: vi.fn(() => 0), + pathFor: vi.fn((key: string) => path.join(workspace, "sessions", `${key}.jsonl`)), + }, + resolveTurnModelSelection: vi.fn(() => ({ model: "test-model" })), + processDirect: vi.fn(async (content: string) => { + order.push("create"); + state = { + goalId: "goal-1", + objective: content.slice("/goal create ".length), + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: "2026-08-18T00:00:00.000Z", + updatedAt: "2026-08-18T00:00:00.000Z", + }; + return { content: "Goal created." }; + }), + goalRuntime: { + get: vi.fn(() => state), + setBudget: vi.fn(async (_session: string, _goal: string, budget: number) => { + order.push("budget"); + state = { ...state, tokenBudget: budget }; + return state; + }), + flushEffects: vi.fn(async () => undefined), + pauseAndCancel: vi.fn(async () => undefined), + }, + run: vi.fn(async () => { + order.push("run"); + state = { + ...state, + status: "completed", + tokensUsed: 321, + timeUsedSeconds: 12, + updatedAt: "2026-08-18T00:00:12.000Z", + }; + for (const message of busMessages) await (fakeLoop as any).bus.publishOutbound(message); + }), + stop: vi.fn(), + closeMcp: vi.fn(async () => undefined), + }; + vi.spyOn(AgentLoop, "fromConfig").mockImplementation((_config, bus) => { + (fakeLoop as any).bus = bus; + busMessages.push(new OutboundMessage({ + channel: "cli", + chatId: "direct", + content: "Implemented and verified.", + })); + return fakeLoop as any; + }); + + const result = await goal({ + message: "Fix the repository task", + tokenBudget: 500, + timeout: 5, + output, + config: configPath, + }); + + expect(order).toEqual(["create", "budget", "run"]); + expect(result).toMatchObject({ + status: "success", + summary: "Implemented and verified.", + session_id: "cli:direct", + timed_out: false, + metrics: { tokens_used: 321, time_used_seconds: 12 }, + }); + expect(JSON.parse(fs.readFileSync(output, "utf8"))).toMatchObject({ + status: "success", + goal: { status: "completed", token_budget: 500 }, + }); + expect(fakeLoop.stop).toHaveBeenCalledTimes(1); + expect(fakeLoop.closeMcp).toHaveBeenCalledTimes(1); + expect(fakeLoop.sessions.flushAll).toHaveBeenCalledTimes(1); + }); + it("agent prints a matching CLI restart notice before a direct turn", async () => { const root = tempRoot(); const configPath = writeConfig(root, { agents: { defaults: { workspace: path.join(root, "workspace"), model: "test-model" } } }); diff --git a/App/memmy-agent/tests/entrypoints/cli/gateway-lifecycle.test.ts b/App/memmy-agent/tests/entrypoints/cli/gateway-lifecycle.test.ts new file mode 100644 index 000000000..e65a54b3a --- /dev/null +++ b/App/memmy-agent/tests/entrypoints/cli/gateway-lifecycle.test.ts @@ -0,0 +1,44 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { + installGatewaySignalLifecycle, + type GatewayRuntime, +} from "../../../src/entrypoints/cli/commands.js"; + +describe("Gateway signal lifecycle", () => { + it("stops the runtime once before exiting successfully on SIGTERM", async () => { + const lifecycle = new EventEmitter() as EventEmitter & { + exit: ReturnType; + }; + lifecycle.exit = vi.fn(); + const stop = vi.fn(async () => undefined); + const runtime = { stop } as unknown as GatewayRuntime; + + installGatewaySignalLifecycle(runtime, lifecycle as never); + lifecycle.emit("SIGTERM"); + lifecycle.emit("SIGTERM"); + + await vi.waitFor(() => { + expect(stop).toHaveBeenCalledTimes(1); + expect(lifecycle.exit).toHaveBeenCalledWith(0); + }); + }); + + it("exits unsuccessfully when graceful shutdown fails", async () => { + const lifecycle = new EventEmitter() as EventEmitter & { + exit: ReturnType; + }; + lifecycle.exit = vi.fn(); + const runtime = { + stop: vi.fn(async () => { + throw new Error("flush failed"); + }), + } as unknown as GatewayRuntime; + + installGatewaySignalLifecycle(runtime, lifecycle as never); + lifecycle.emit("SIGINT"); + + await vi.waitFor(() => expect(lifecycle.exit).toHaveBeenCalledWith(1)); + }); + +}); diff --git a/App/memmy-agent/tests/entrypoints/cli/linux-systemd-gateway.test.ts b/App/memmy-agent/tests/entrypoints/cli/linux-systemd-gateway.test.ts new file mode 100644 index 000000000..40c7431af --- /dev/null +++ b/App/memmy-agent/tests/entrypoints/cli/linux-systemd-gateway.test.ts @@ -0,0 +1,392 @@ +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; +import { Config } from "../../../src/config/schema.js"; +import { + hasUsableDefaultModel, + prepareLinuxGatewayConfig, + prepareSystemdGatewayEnvironment, + probeGateway, + probeMemoryServiceAuthentication, + runLinuxRootTerminal, + type GatewayProbe, +} from "../../../src/entrypoints/cli/linux-systemd-gateway.js"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function configured(): Config { + return new Config({ + agents: { defaults: { model: "ollama/llama3.2" } }, + channels: { + websocket: { + enabled: true, + host: "127.0.0.1", + port: 18980, + tokenIssueSecret: "secret", + }, + }, + }); +} + +function bootstrapResponse(status = 200): Response { + return new Response(JSON.stringify({ + token: "gateway-token", + ws_path: "/", + expires_in: 300, + model_name: "test-model", + model_selection: null, + tool_names: [], + }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function refused(): TypeError { + const error = new TypeError("fetch failed") as TypeError & { cause?: NodeJS.ErrnoException }; + error.cause = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); + return error; +} + +describe("Linux systemd Gateway probing", () => { + it("uses the configured bootstrap secret and accepts only a compatible response", async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.headers).toEqual({ authorization: "Bearer secret" }); + return bootstrapResponse(); + }) as unknown as typeof fetch; + + await expect(probeGateway(configured(), { fetchImpl })).resolves.toEqual({ status: "ready" }); + }); + + it("distinguishes a refused connection from authentication and protocol conflicts", async () => { + const unavailableFetch = vi.fn(async () => { + throw refused(); + }) as unknown as typeof fetch; + const unauthorizedFetch = vi.fn( + async () => new Response("Unauthorized", { status: 401 }), + ) as unknown as typeof fetch; + const invalidFetch = vi.fn(async () => new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + + await expect(probeGateway(configured(), { fetchImpl: unavailableFetch })).resolves.toMatchObject({ + status: "unavailable", + }); + await expect(probeGateway(configured(), { fetchImpl: unauthorizedFetch })).resolves.toEqual({ + status: "unexpected", + detail: "bootstrap HTTP 401", + }); + await expect(probeGateway(configured(), { fetchImpl: invalidFetch })).resolves.toEqual({ + status: "unexpected", + detail: "bootstrap response is incompatible", + }); + }); + + it("treats a disabled WebSocket channel as requiring service configuration", async () => { + await expect(probeGateway(new Config(), { + fetchImpl: vi.fn() as unknown as typeof fetch, + })).resolves.toMatchObject({ + status: "unavailable", + detail: expect.stringContaining("WebSocket Gateway is disabled"), + }); + }); + + it("treats a probe timeout as temporarily unavailable", async () => { + const timedOutFetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("timed out", "AbortError")); + }); + }); + return bootstrapResponse(); + }) as unknown as typeof fetch; + + await expect(probeGateway(configured(), { + fetchImpl: timedOutFetch, + timeoutMs: 1, + })).resolves.toMatchObject({ status: "unavailable" }); + }); +}); + +describe("Linux systemd Memory authentication probing", () => { + it("uses the configured Memory token and rejects a stale service token", async () => { + const config = new Config({ + memmyMemory: { + storage: { + endpoint: "http://127.0.0.1:18960", + token: "current-memory-token", + }, + }, + }); + const readyFetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.headers).toEqual({ authorization: "Bearer current-memory-token" }); + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + const staleFetch = vi.fn(async () => new Response("Unauthorized", { + status: 401, + })) as unknown as typeof fetch; + + await expect(probeMemoryServiceAuthentication(config, { fetchImpl: readyFetch })) + .resolves.toEqual({ status: "ready" }); + await expect(probeMemoryServiceAuthentication(config, { fetchImpl: staleFetch })) + .resolves.toEqual({ + status: "unexpected", + detail: "authenticated Memory HTTP 401", + }); + }); +}); + +describe("Linux systemd Gateway configuration", () => { + it("enables localhost defaults without overwriting custom endpoints", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-linux-systemd-config-")); + temporaryRoots.push(root); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + channels: { websocket: { enabled: false, host: "127.0.0.2", port: 29999 } }, + gateway: { enabled: false, host: "127.0.0.3", port: 29998 }, + futureSection: { keep: true }, + })); + const load = () => new Config(YAML.parse(readFileSync(configPath, "utf8"))); + + const result = await prepareLinuxGatewayConfig(load, configPath); + const saved = YAML.parse(readFileSync(configPath, "utf8")); + + expect(saved).toMatchObject({ + channels: { + websocket: { + enabled: true, + host: "127.0.0.2", + port: 29999, + websocketRequiresToken: true, + }, + }, + gateway: { enabled: true, host: "127.0.0.3", port: 29998 }, + futureSection: { keep: true }, + }); + expect(saved.channels.websocket.tokenIssueSecret).toMatch(/^[a-f0-9]{64}$/); + expect((result.channels as unknown as { websocket: Record }).websocket) + .toMatchObject({ host: "127.0.0.2", port: 29999 }); + }); + + it("persists referenced credentials and runtime PATH in a private environment file", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-linux-systemd-env-")); + temporaryRoots.push(root); + const configPath = join(root, "config.yaml"); + const destination = join(root, "private", "gateway.env"); + writeFileSync(configPath, [ + "providers:", + " custom:", + " apiKey: ${CUSTOM_PROVIDER_TOKEN}", + " apiBase: ${CUSTOM_API_BASE:https://fallback.invalid}", + "", + ].join("\n")); + const env = { + PATH: "/home/test/bin:/usr/bin", + CUSTOM_PROVIDER_TOKEN: 'secret with spaces and "quotes"', + OPENAI_API_KEY: "implicit-openai-key", + }; + + await expect(prepareSystemdGatewayEnvironment({ + configPath, + destination, + env, + })).resolves.toBe(true); + const saved = readFileSync(destination, "utf8"); + expect(saved).toContain('PATH="/home/test/bin:/usr/bin"'); + expect(saved).toContain('CUSTOM_PROVIDER_TOKEN="secret with spaces and \\"quotes\\""'); + expect(saved).toContain('OPENAI_API_KEY="implicit-openai-key"'); + expect(saved).not.toContain("CUSTOM_API_BASE="); + expect(statSync(destination).mode & 0o777).toBe(0o600); + + await expect(prepareSystemdGatewayEnvironment({ + configPath, + destination, + env, + })).resolves.toBe(false); + }); + + it("rejects multiline values instead of emitting an injectable EnvironmentFile", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-linux-systemd-env-invalid-")); + temporaryRoots.push(root); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, "apiKey: ${CUSTOM_PROVIDER_TOKEN}\n"); + + await expect(prepareSystemdGatewayEnvironment({ + configPath, + destination: join(root, "gateway.env"), + env: { CUSTOM_PROVIDER_TOKEN: "first\nsecond" }, + })).rejects.toThrow("contains a newline or NUL"); + }); +}); + +describe("Linux systemd Gateway root terminal flow", () => { + it("runs onboarding, enables the user service, waits for readiness, and leaves it running", async () => { + let current = new Config(); + let serviceRunning = false; + const prepared = configured(); + const order: string[] = []; + const probes: GatewayProbe[] = [ + { status: "unavailable", detail: "disabled" }, + { status: "unavailable", detail: "connection refused" }, + { status: "unavailable", detail: "starting" }, + { status: "ready" }, + ]; + + await runLinuxRootTerminal({ + platform: "linux", + stdinIsTTY: true, + stdoutIsTTY: true, + systemdGatewayEnabled: true, + loadConfig: () => current, + onboardWizard: async () => { + order.push("wizard"); + current = configured(); + }, + prepareGatewayConfig: async () => { + order.push("prepare"); + current = prepared; + return prepared; + }, + refreshMemoryService: async (config) => { + order.push("memory"); + expect(config).toBe(prepared); + }, + prepareGatewayEnvironment: async () => { + order.push("environment"); + return true; + }, + enableGatewayService: async (options) => { + order.push(`systemd:${String(options?.restart)}`); + serviceRunning = true; + }, + probeGateway: vi.fn(async (): Promise => probes.shift() ?? { status: "ready" }), + gatewayServiceMainPid: vi.fn(async () => serviceRunning ? 123 : null), + sleep: async () => undefined, + runInteractive: async (config) => { + order.push("tui"); + expect(config).toBe(prepared); + return null; + }, + }); + + expect(order).toEqual([ + "wizard", + "prepare", + "memory", + "environment", + "systemd:true", + "tui", + ]); + }); + + it("keeps systemd ownership and restarts when the persisted environment changes", async () => { + const config = configured(); + const prepareGatewayConfig = vi.fn(async () => config); + const refreshMemoryService = vi.fn(async () => undefined); + const enableGatewayService = vi.fn(async () => undefined); + const runInteractive = vi.fn(async () => null); + + await runLinuxRootTerminal({ + platform: "linux", + stdinIsTTY: true, + stdoutIsTTY: true, + systemdGatewayEnabled: true, + loadConfig: () => config, + onboardWizard: vi.fn(), + prepareGatewayConfig, + refreshMemoryService, + prepareGatewayEnvironment: async () => true, + enableGatewayService, + probeGateway: vi.fn(async (): Promise => ({ status: "ready" })), + gatewayServiceMainPid: vi.fn(async () => 456), + sleep: async () => undefined, + runInteractive, + }); + + expect(prepareGatewayConfig).not.toHaveBeenCalled(); + expect(refreshMemoryService).not.toHaveBeenCalled(); + expect(enableGatewayService).toHaveBeenCalledWith({ restart: true }); + expect(runInteractive).toHaveBeenCalledWith(config); + }); + + it("refuses to take over a compatible Gateway not owned by the user service", async () => { + const enableGatewayService = vi.fn(async () => undefined); + + await expect(runLinuxRootTerminal({ + platform: "linux", + stdinIsTTY: true, + stdoutIsTTY: true, + systemdGatewayEnabled: true, + loadConfig: configured, + onboardWizard: vi.fn(), + prepareGatewayEnvironment: async () => false, + enableGatewayService, + probeGateway: vi.fn(async (): Promise => ({ status: "ready" })), + gatewayServiceMainPid: vi.fn(async () => null), + runInteractive: vi.fn(async () => null), + })).rejects.toThrow("already running outside memmy-gateway.service"); + + expect(enableGatewayService).not.toHaveBeenCalled(); + }); + + it("does not start over an incompatible or unauthorized external endpoint", async () => { + const enableGatewayService = vi.fn(async () => undefined); + + await expect(runLinuxRootTerminal({ + platform: "linux", + stdinIsTTY: true, + stdoutIsTTY: true, + systemdGatewayEnabled: true, + loadConfig: configured, + onboardWizard: vi.fn(), + prepareGatewayEnvironment: async () => false, + enableGatewayService, + probeGateway: vi.fn(async (): Promise => ({ + status: "unexpected", + detail: "bootstrap HTTP 401", + })), + gatewayServiceMainPid: vi.fn(async () => null), + runInteractive: vi.fn(async () => null), + })).rejects.toThrow("occupied, incompatible, or rejected authentication"); + + expect(enableGatewayService).not.toHaveBeenCalled(); + }); + + it("leaves source-built Linux, non-Linux, and non-TTY invocations unchanged", async () => { + const loadConfig = vi.fn(configured); + const runInteractive = vi.fn(async () => null); + + await runLinuxRootTerminal({ + platform: "linux", + stdinIsTTY: true, + stdoutIsTTY: true, + systemdGatewayEnabled: false, + loadConfig, + onboardWizard: vi.fn(), + runInteractive, + }); + + expect(loadConfig).not.toHaveBeenCalled(); + expect(runInteractive).toHaveBeenCalledWith(); + }); + + it("detects whether a usable model is configured", () => { + expect(hasUsableDefaultModel(configured())).toBe(true); + expect(hasUsableDefaultModel(new Config())).toBe(false); + }); +}); diff --git a/App/memmy-agent/tests/entrypoints/cli/root-terminal-options.test.ts b/App/memmy-agent/tests/entrypoints/cli/root-terminal-options.test.ts new file mode 100644 index 000000000..8e1d1624f --- /dev/null +++ b/App/memmy-agent/tests/entrypoints/cli/root-terminal-options.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + parseRootTerminalOptions, + type RootTerminalOptions, +} from "../../../src/entrypoints/cli/root-terminal-options.js"; + +const argv = (...args: string[]): string[] => ["node", "memmy", ...args]; + +describe("parseRootTerminalOptions", () => { + it.each<[string[], RootTerminalOptions]>([ + [argv(), {}], + [argv("--standalone"), { standalone: true }], + [argv("--session", "cli:one"), { sessionId: "cli:one" }], + [argv("-s", "cli:short"), { sessionId: "cli:short" }], + [argv("--project", "/tmp/project"), { project: "/tmp/project" }], + ])("recognizes root terminal arguments: %j", (input, expected) => { + expect(parseRootTerminalOptions(input)).toEqual(expected); + }); + + it.each([ + [argv("--session"), "--session requires a sessionId"], + [argv("-s"), "--session requires a sessionId"], + [argv("--session", "--standalone"), "--session requires a sessionId"], + [argv("--project"), "--project requires a path"], + [argv("--project", "--standalone"), "--project requires a path"], + ])("rejects a missing root option value: %j", (input, message) => { + expect(() => parseRootTerminalOptions(input)).toThrow(message); + }); + + it.each([ + { input: argv("--standalone", "--session", "cli:one") }, + { input: argv("--standalone", "--project", "/tmp/project") }, + { input: argv("--session", "cli:one", "--project", "/tmp/project") }, + ])("rejects mutually exclusive root terminal options: $input", ({ input }) => { + expect(() => parseRootTerminalOptions(input)).toThrow( + "--session, --standalone, and --project are mutually exclusive", + ); + }); + + it.each([ + { input: argv("--help") }, + { input: argv("-h") }, + { input: argv("--version") }, + { input: argv("-V") }, + { input: argv("gateway") }, + { input: argv("onboard", "--wizard") }, + ])("leaves help, version, and subcommands to Commander: $input", ({ input }) => { + expect(parseRootTerminalOptions(input)).toBeNull(); + }); +}); diff --git a/App/memmy-agent/tests/entrypoints/cli/terminal-target.test.ts b/App/memmy-agent/tests/entrypoints/cli/terminal-target.test.ts index 15205d280..9dcef6d79 100644 --- a/App/memmy-agent/tests/entrypoints/cli/terminal-target.test.ts +++ b/App/memmy-agent/tests/entrypoints/cli/terminal-target.test.ts @@ -102,6 +102,15 @@ describe("terminal target resolution", () => { })).toThrow("mutually exclusive"); }); + it("directs an unconfigured terminal session to the interactive wizard", () => { + const { dependencies } = makeLoop(); + dependencies.hasUsableDefaultModel = () => false; + + expect(() => resolveTerminalTarget(dependencies, { standalone: true })).toThrow( + "No usable default model is configured. Run `memmy onboard --wizard` first.", + ); + }); + it("accepts project paths, reuses the registered canonical root, and fixes each binding", () => { const { root, dependencies } = makeLoop(); const projectPath = path.join(root, "code", "memmy"); diff --git a/App/memmy-agent/tests/memmy-memory/discovery.test.ts b/App/memmy-agent/tests/memmy-memory/discovery.test.ts index 799e87be6..44448efee 100644 --- a/App/memmy-agent/tests/memmy-memory/discovery.test.ts +++ b/App/memmy-agent/tests/memmy-memory/discovery.test.ts @@ -82,6 +82,7 @@ describe("memmy memory discovery", () => { enabled: true, version: 1, storage: { endpoint: "http://127.0.0.1:18960", token: "service-token" }, + retrievalLayers: ["L1", "L3", "L1"], }, }); const defaultConfig = new Config(); @@ -92,6 +93,7 @@ describe("memmy memory discovery", () => { expect(resolveMemmyMemoryConfig(enabled).enabled).toBe(true); expect(resolveMemmyMemoryConfig(defaultConfig).enabled).toBe(true); expect(resolveMemmyMemoryConfig(enabled).userId).toBe("user_config_1"); + expect(resolveMemmyMemoryConfig(enabled).retrievalLayers).toEqual(["L1", "L3"]); expect(resolveMemmyMemoryConfig(disabled).enabled).toBe(false); expect(resolveMemmyMemoryConfig(disabled).userId).toBe("local-user"); expect(enabled.toObject().memmyMemory).toEqual({ @@ -99,6 +101,7 @@ describe("memmy memory discovery", () => { userId: "user_config_1", version: 1, storage: { endpoint: "http://127.0.0.1:18960", token: "service-token" }, + retrievalLayers: ["L1", "L3"], }); expect(enabled.toObject().app).toEqual({ userId: "user_config_1", diff --git a/App/memmy-agent/tests/memmy-memory/hook.test.ts b/App/memmy-agent/tests/memmy-memory/hook.test.ts index 04089372e..847cf2eee 100644 --- a/App/memmy-agent/tests/memmy-memory/hook.test.ts +++ b/App/memmy-agent/tests/memmy-memory/hook.test.ts @@ -358,6 +358,29 @@ describe("MemmyMemoryHook", () => { expect(hook.currentTurnId("cli:direct")).toBeNull(); }); + it("forwards an explicit empty retrieval layer selection", async () => { + const client = fakeClient(); + const hook = new MemmyMemoryHook(client as any, { + workspace: "/tmp/workspace", + retrievalLayers: [], + }); + const spec = { + sessionKey: "cli:layer-ablation", + turnId: "agent-turn-layer-ablation", + workspace: "/tmp/workspace", + }; + + await hook.beforeRun(new AgentHookContext({ + spec, + messages: [{ role: "user", content: "Run without retrieved memory." }], + })); + + expect((client.startTurn as any).mock.calls[0][1]).toMatchObject({ + layers: [], + }); + expect(hook.lastError).toBeNull(); + }); + it("drops a user-cancelled turn even when partial assistant text exists", async () => { const client = fakeClient(); const hook = new MemmyMemoryHook(client as any, { workspace: "/tmp/workspace" }); diff --git a/App/shell/desktop/package.json b/App/shell/desktop/package.json index c68333f19..e6aa8634e 100644 --- a/App/shell/desktop/package.json +++ b/App/shell/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@memmy/desktop", - "version": "1.1.0", + "version": "1.1.1", "private": true, "type": "module", "description": "Memmy desktop client.", diff --git a/App/shell/desktop/src/main/main.ts b/App/shell/desktop/src/main/main.ts index 80839b2e2..40da1ba41 100644 --- a/App/shell/desktop/src/main/main.ts +++ b/App/shell/desktop/src/main/main.ts @@ -107,6 +107,7 @@ import { type WindowsDataMigrationConsistency, type WindowsDataLayout } from "./windows-data-layout.js"; +import { createWindowsUpdateLauncherFile } from "./windows-update-launcher.js"; let mainWindow: BrowserWindow | null = null; let petWindow: BrowserWindow | null = null; @@ -2666,7 +2667,7 @@ async function installWindowsUpdateInBackground( await clearWindowsUpdatePromptMarker().catch(() => undefined); } await writeFile(helperPath, createWindowsUpdateInstallScript(), { mode: 0o700 }); - await writeFile(launcherPath, createWindowsUpdateLauncherScript([ + await writeFile(launcherPath, createWindowsUpdateLauncherFile([ "powershell.exe", "-NoProfile", "-ExecutionPolicy", @@ -2682,7 +2683,7 @@ async function installWindowsUpdateInBackground( options.openAfterInstall ? "1" : "0", resolvePreparedRequiredUpdatePath(), options.expectedVersion ?? "" - ]), "utf8"); + ])); await appendFile(logPath, `[${new Date().toISOString()}] queued Memmy Windows update helper "${helperPath}"\n`).catch(() => undefined); const helper = spawn("wscript.exe", [launcherPath], { @@ -2701,36 +2702,6 @@ async function installWindowsUpdateInBackground( return { filePath, opened: false, willQuit: options.quitCurrentApp, background: true }; } -/** - * Creates the VBS script that launches the Windows update helper hidden. - * - * @param command The PowerShell helper launch command and arguments. - * @returns The VBS script content. - */ -function createWindowsUpdateLauncherScript(command: string[]): string { - const shellCommand = command.map(quoteWindowsShellArgument).join(" "); - return `Set shell = CreateObject("WScript.Shell") -shell.Run "${escapeVbsString(shellCommand)}", 0, False -Set fso = CreateObject("Scripting.FileSystemObject") -On Error Resume Next -fso.DeleteFile WScript.ScriptFullName, True -`; -} - -/** - * Quotes a Windows shell command argument. - * - * @param value The argument value. - * @returns The argument ready to be spliced into the command line. - */ -function quoteWindowsShellArgument(value: string): string { - return `"${value.replace(/"/g, "\\\"")}"`; -} - -function escapeVbsString(value: string): string { - return value.replace(/"/g, "\"\""); -} - function createWindowsUpdateInstallScript(): string { return `param( [string]$Installer, diff --git a/App/shell/desktop/src/main/windows-update-launcher.ts b/App/shell/desktop/src/main/windows-update-launcher.ts new file mode 100644 index 000000000..9784aeedc --- /dev/null +++ b/App/shell/desktop/src/main/windows-update-launcher.ts @@ -0,0 +1,34 @@ +/** + * Creates the VBS script that launches the Windows update helper hidden. + * + * @param command The PowerShell helper launch command and arguments. + * @returns The VBS script content. + */ +const createWindowsUpdateLauncherScript = (command: string[]): string => { + const shellCommand = command.map(quoteWindowsShellArgument).join(" "); + return `Set shell = CreateObject("WScript.Shell") +shell.Run "${escapeVbsString(shellCommand)}", 0, False +Set fso = CreateObject("Scripting.FileSystemObject") +On Error Resume Next +fso.DeleteFile WScript.ScriptFullName, True +`; +}; + +/** + * Creates a Windows Script Host compatible launcher file. + * + * Windows Script Host may decode a BOM-less UTF-8 VBS file with the active + * system code page, corrupting non-ASCII update paths before PowerShell starts. + */ +export const createWindowsUpdateLauncherFile = (command: string[]): Buffer => { + const script = createWindowsUpdateLauncherScript(command); + return Buffer.from(`\uFEFF${script}`, "utf16le"); +}; + +const quoteWindowsShellArgument = (value: string): string => { + return `"${value.replace(/"/g, "\\\"")}"`; +}; + +const escapeVbsString = (value: string): string => { + return value.replace(/"/g, "\"\""); +}; diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index 98a99f938..144fd0750 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -1146,7 +1146,8 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain("launch-win-update-${Date.now()}.vbs"); expect(mainSource).toContain("install-win-update-${Date.now()}.ps1"); expect(mainSource).toContain('const helper = spawn("wscript.exe"'); - expect(mainSource).toContain("function createWindowsUpdateLauncherScript"); + expect(mainSource).toContain('import { createWindowsUpdateLauncherFile } from "./windows-update-launcher.js"'); + expect(mainSource).toContain("await writeFile(launcherPath, createWindowsUpdateLauncherFile(["); expect(mainSource).toContain("$arguments = @('/S', '--updated', '/currentuser', ('/D=' + $appDir))"); expect(mainSource).toContain("CURRENT_APP_PID"); expect(mainSource).toContain("OPEN_AFTER_INSTALL"); diff --git a/App/shell/desktop/tests/windows-update-launcher.test.ts b/App/shell/desktop/tests/windows-update-launcher.test.ts new file mode 100644 index 000000000..35945bc2f --- /dev/null +++ b/App/shell/desktop/tests/windows-update-launcher.test.ts @@ -0,0 +1,93 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { describe, expect, it } from "vitest"; +import { createWindowsUpdateLauncherFile } from "../src/main/windows-update-launcher.js"; + +describe("Windows update launcher", () => { + it("encodes the VBS launcher as UTF-16LE with BOM without losing Chinese paths or arguments", () => { + const helperPath = "D:\\测试路径\\Memmy\\data\\Memmy\\updates\\install-win-update.ps1"; + const installerPath = "D:\\测试路径\\Memmy\\data\\Memmy\\updates\\Memmy-1.1.0.exe"; + const appPath = "D:\\测试路径\\Memmy\\Memmy.exe"; + const logPath = "D:\\测试路径\\Memmy\\data\\Memmy\\updates\\win-update-install.log"; + const markerPath = "D:\\测试路径\\Memmy\\data\\Memmy\\prepared-required-update.json"; + const command = [ + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-File", + helperPath, + installerPath, + appPath, + logPath, + "4242", + "1", + markerPath, + "1.1.0" + ]; + + const launcherFile = createWindowsUpdateLauncherFile(command); + + expect([...launcherFile.subarray(0, 2)]).toEqual([0xff, 0xfe]); + const decoded = launcherFile.toString("utf16le"); + expect(decoded.startsWith("\uFEFF")).toBe(true); + expect(decoded).toContain('Set shell = CreateObject("WScript.Shell")'); + expect(decoded).toContain('shell.Run """powershell.exe""'); + expect(decoded).toContain(', 0, False'); + expect(decoded).toContain('Set fso = CreateObject("Scripting.FileSystemObject")'); + expect(decoded).toContain("fso.DeleteFile WScript.ScriptFullName, True"); + for (const argument of command) { + expect(decoded).toContain(argument); + } + }); + + it.runIf(process.platform === "win32")( + "launches a PowerShell helper from a Chinese path through cscript", + async () => { + const root = await mkdtemp(join(tmpdir(), "memmy-vbs-launcher-")); + const chineseRoot = join(root, "中文路径"); + const helperPath = join(chineseRoot, "probe.ps1"); + const markerPath = join(chineseRoot, "marker.txt"); + const launcherPath = join(root, "launcher.vbs"); + try { + await mkdir(chineseRoot, { recursive: true }); + await writeFile( + helperPath, + 'param([string]$Marker)\n[System.IO.File]::WriteAllBytes($Marker, [System.Text.Encoding]::UTF8.GetBytes($PSCommandPath))\n', + "utf8" + ); + await writeFile(launcherPath, createWindowsUpdateLauncherFile([ + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-File", + helperPath, + markerPath + ])); + + const cscriptPath = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cscript.exe"); + const result = spawnSync(cscriptPath, ["//B", "//Nologo", launcherPath], { encoding: "utf8" }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + + let markerContent: string | undefined; + for (let attempt = 0; attempt < 100; attempt += 1) { + markerContent = await readFile(markerPath, "utf8").catch(() => undefined); + if (markerContent) break; + await delay(50); + } + expect(markerContent).toBe(helperPath); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + ); +}); diff --git a/Memory/package.json b/Memory/package.json index 064a16827..fabbf4960 100644 --- a/Memory/package.json +++ b/Memory/package.json @@ -1,6 +1,6 @@ { "name": "@memmy/memory", - "version": "1.1.0", + "version": "1.1.1", "private": true, "type": "module", "main": "./dist/src/index.js", diff --git a/Memory/src/cli/commands.ts b/Memory/src/cli/commands.ts index f7a162011..4475aa631 100644 --- a/Memory/src/cli/commands.ts +++ b/Memory/src/cli/commands.ts @@ -471,6 +471,7 @@ function setupOptions(parsed: ParsedArgs): { agentRoot?: string; assetRoot?: string; skipAgentSkills?: boolean; + generateTokenIfMissing?: boolean; } { return { home: optionString(parsed.options, "home"), @@ -485,7 +486,8 @@ function setupOptions(parsed: ParsedArgs): { agents: optionValues(parsed.options, "agent"), agentRoot: optionString(parsed.options, "agent-root"), assetRoot: optionString(parsed.options, "asset-root"), - skipAgentSkills: optionBoolean(parsed.options, "skip-agent-skills") + skipAgentSkills: optionBoolean(parsed.options, "skip-agent-skills"), + generateTokenIfMissing: optionBoolean(parsed.options, "generate-token-if-missing") }; } diff --git a/Memory/src/cli/npm/package.json b/Memory/src/cli/npm/package.json index 5d67e59a1..f39269921 100644 --- a/Memory/src/cli/npm/package.json +++ b/Memory/src/cli/npm/package.json @@ -1,6 +1,6 @@ { "name": "@memtensor/memmy-memory-cli", - "version": "1.1.0", + "version": "1.1.1", "description": "Memmy Memory CLI for local agent memory.", "type": "module", "bin": { diff --git a/Memory/src/cli/setup.ts b/Memory/src/cli/setup.ts index e69dd3628..939669106 100644 --- a/Memory/src/cli/setup.ts +++ b/Memory/src/cli/setup.ts @@ -7,6 +7,7 @@ import { symlinkSync, unlinkSync } from "node:fs"; +import crypto from "node:crypto"; import { dirname, join, resolve } from "node:path"; import { asRecord, expandHome, optionalString } from "./config.js"; import { @@ -29,6 +30,7 @@ export interface MemoryCliSetupOptions { agentRoot?: string; assetRoot?: string; skipAgentSkills?: boolean; + generateTokenIfMissing?: boolean; } export async function initMemoryCli(options: MemoryCliSetupOptions = {}): Promise> { @@ -41,7 +43,12 @@ export async function initMemoryCli(options: MemoryCliSetupOptions = {}): Promis mkdirSync(home, { recursive: true }); mkdirSync(dirname(configPath), { recursive: true }); await mutateRuntimeConfig(configPath, (config) => { - setupMemoryConfig(config, { dbPath, endpoint, token: options.token }); + setupMemoryConfig(config, { + dbPath, + endpoint, + token: options.token, + generateTokenIfMissing: options.generateTokenIfMissing, + }); }); } @@ -57,6 +64,7 @@ export async function initMemoryCli(options: MemoryCliSetupOptions = {}): Promis agentInstallations = await installMemmyMemorySkillForAgents(requestedAgents, { agentRoot: options.agents?.length ? options.agentRoot : undefined, assetRoot: options.assetRoot, + memmyConfigPath: configPath, dryRun: options.dryRun, skipUnavailable: !options.agents?.length }); @@ -122,6 +130,7 @@ function setupMemoryConfig( dbPath: string; endpoint: string; token?: string; + generateTokenIfMissing?: boolean; } ): void { const app = asRecord(config.app); @@ -131,7 +140,8 @@ function setupMemoryConfig( appUserId, dbPath: options.dbPath, endpoint: options.endpoint, - token: options.token + token: options.token, + generateTokenIfMissing: options.generateTokenIfMissing, }); } @@ -142,12 +152,17 @@ function setupMemmyMemoryConfig( dbPath: string; endpoint: string; token?: string; + generateTokenIfMissing?: boolean; } ): Record { const roleRouting = asRecord(existing.roleRouting); const embedding = asRecord(existing.embedding); const storage = asRecord(existing.storage); const algorithm = asRecord(existing.algorithm); + const existingToken = optionalString(storage.token); + const token = options.token + ?? existingToken + ?? (options.generateTokenIfMissing ? crypto.randomBytes(32).toString("hex") : undefined); validateEmbeddingForSetup(embedding); const memmyMemory: Record = { ...existing, @@ -164,7 +179,7 @@ function setupMemmyMemoryConfig( backend: "sqlite", sqlitePath: options.dbPath, endpoint: options.endpoint, - ...(options.token !== undefined ? { token: options.token } : {}) + ...(token !== undefined ? { token } : {}) }, algorithm: { ...algorithm, diff --git a/Memory/src/cli/skill-writer/index.ts b/Memory/src/cli/skill-writer/index.ts index 96996a14b..8ab396f8e 100644 --- a/Memory/src/cli/skill-writer/index.ts +++ b/Memory/src/cli/skill-writer/index.ts @@ -1,7 +1,7 @@ import { cp, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; export const SUPPORTED_MEMMY_AGENT_IDS = ["codex", "cursor", "claude", "opencode", "openclaw", "hermes"] as const; export type MemmyAgentId = typeof SUPPORTED_MEMMY_AGENT_IDS[number]; @@ -14,6 +14,7 @@ export interface AgentSkillInstallOptions { export interface AgentSkillBatchInstallOptions extends AgentSkillInstallOptions { skipUnavailable?: boolean; + memmyConfigPath?: string; } export interface AgentSkillInstallResult { @@ -66,6 +67,11 @@ export async function installMemmyMemorySkillForAgents( agents: string[], options: AgentSkillBatchInstallOptions = {} ): Promise { + if (process.env.MEMMY_AGENT_INTEGRATION_ROOT?.trim() && options.agentRoot && !options.dryRun) { + throw new Error( + "--agent-root cannot be combined with packaged Hook/plugin integration; use the agent's configured home", + ); + } const results = await Promise.all( normalizeAgentIds(agents).map(async (agent) => { if (options.skipUnavailable && !(await isExistingDirectory(targetForAgent(agent, options.agentRoot).root))) { @@ -74,7 +80,52 @@ export async function installMemmyMemorySkillForAgents( return installMemmyMemorySkillForAgent(agent, options); }) ); - return results.filter((result): result is AgentSkillInstallResult => result !== null); + const installed = results.filter((result): result is AgentSkillInstallResult => result !== null); + await installPackagedAgentIntegrations(installed, options); + return installed; +} + +type PackagedIntegrationTarget = { + installPlugin?: (targetId: string) => Promise; +}; + +type PackagedIntegrationRegistry = { + get: (targetId: string) => PackagedIntegrationTarget | undefined; +}; + +type PackagedIntegrationModule = { + createBuiltinSkillTargetRegistry: (memmyConfigPath?: string) => PackagedIntegrationRegistry; +}; + +async function installPackagedAgentIntegrations( + installed: AgentSkillInstallResult[], + options: AgentSkillBatchInstallOptions, +): Promise { + const integrationRoot = process.env.MEMMY_AGENT_INTEGRATION_ROOT?.trim(); + if (!integrationRoot || options.dryRun || installed.length === 0) return; + + const modulePath = join( + resolve(expandHome(integrationRoot)), + "services", + "builtin-skill-target-registry.js", + ); + if (!(await pathExists(modulePath))) { + throw new Error(`packaged agent integration registry not found: ${modulePath}`); + } + + const integrationModule = await import(pathToFileURL(modulePath).href) as PackagedIntegrationModule; + if (typeof integrationModule.createBuiltinSkillTargetRegistry !== "function") { + throw new Error(`invalid packaged agent integration registry: ${modulePath}`); + } + const registry = integrationModule.createBuiltinSkillTargetRegistry(options.memmyConfigPath); + for (const result of installed) { + const targetId = result.agent === "claude" ? "claude_code" : result.agent; + const target = registry.get(targetId); + if (!target?.installPlugin) { + throw new Error(`packaged Hook/plugin integration is unavailable for ${result.agent}`); + } + await target.installPlugin(targetId); + } } export async function installMemmyMemorySkillForAgent( diff --git a/Memory/src/config/index.ts b/Memory/src/config/index.ts index 737431f28..becf2f5d5 100644 --- a/Memory/src/config/index.ts +++ b/Memory/src/config/index.ts @@ -806,9 +806,13 @@ function resolveAssignedEmbedding( embedding.mode, DEFAULT_MEMMY_CONFIG.embedding.mode ); + const rawAssignedPreset = mode + ? asRecord(asRecord(rootConfig.modelAssignments)[mode]).embedding + : undefined; + const hasExplicitAssignment = rawAssignedPreset !== undefined && rawAssignedPreset !== null; const resolved = resolveMemoryAssignment(rootConfig, mode, "embedding"); if (!resolved.ok) { - if (embeddingMode !== "local") { + if (hasExplicitAssignment || (mode !== "byok" && embeddingMode !== "local")) { return { ...embedding, provider: "openai_compatible", @@ -818,9 +822,16 @@ function resolveAssignedEmbedding( } return { ...embedding, - mode: embeddingMode, + mode: "local", provider: "local", - sourceProvider: "local" + sourceProvider: "local", + endpoint: undefined, + model: DEFAULT_MEMMY_CONFIG.embedding.model, + apiKey: undefined, + extraHeaders: undefined, + extraBody: undefined, + actualModelContext: undefined, + selectionError: undefined }; } if (!embeddingProtocolSupported(resolved.context.protocol)) { diff --git a/Memory/src/server/http.ts b/Memory/src/server/http.ts index bb0074ea4..a3369ba97 100644 --- a/Memory/src/server/http.ts +++ b/Memory/src/server/http.ts @@ -509,6 +509,7 @@ async function routeRequest( sessionId: request.sessionId, query: request.query, turnId: request.turnId, + layers: normalizeLayerSelection(request.layers), contextHints: request.contextHints, contextBudget: request.contextBudget }; @@ -1396,6 +1397,23 @@ function normalizeLayers(value: unknown): MemoryLayer[] | undefined { return layers.length > 0 ? layers : undefined; } +function normalizeLayerSelection(value: unknown): MemoryLayer[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const layers = value.map((item) => { + const layer = parseLayerValue(item); + if (!layer) { + throw new MemoryServiceError( + "invalid_argument", + "turn.start layers must contain only L1, L2, L3, or Skill" + ); + } + return layer; + }); + return [...new Set(layers)]; +} + function parseStatus(value: string | null): "activated" | "resolving" | "archived" | "deleted" | undefined { if (value === "activated" || value === "resolving" || value === "archived" || value === "deleted") { return value; diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts index 2442a2655..1822121e2 100644 --- a/Memory/src/service/memory-service.ts +++ b/Memory/src/service/memory-service.ts @@ -2185,6 +2185,10 @@ export class MemoryService { ): ReturnType { const turnId = request.turnId ?? newId("turn"); const contextHints = turnStartContextHints(request); + const defaultLayers: MemoryLayer[] = ["Skill", "L2", "L1", "L3"]; + const requestedLayers = request.layers === undefined + ? defaultLayers + : defaultLayers.filter((layer) => request.layers?.includes(layer)); const search = await this.search({ requestId: request.requestId, adapterId: request.adapterId, @@ -2192,7 +2196,7 @@ export class MemoryService { sessionId: request.sessionId, turnId, query: buildSearchQuery({ ...request, contextHints }, this.config.domain), - layers: ["Skill", "L2", "L1", "L3"], + layers: requestedLayers, limit: this.turnStartRetrievalLimit(), contextBudget: typeof request.contextBudget === "number" ? request.contextBudget : undefined, includeInjectedContext: true, diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts index 3fa49c86b..7f4fb54c6 100644 --- a/Memory/src/service/session/session-turn-service.ts +++ b/Memory/src/service/session/session-turn-service.ts @@ -1150,6 +1150,10 @@ export class SessionTurnService { endTopicDecision ); const contextHints = turnStartContextHints(request); + const intentLayers = this.deps.memoryLayersForIntent(intentDecision.kind); + const requestedLayers = request.layers === undefined + ? intentLayers + : intentLayers.filter((layer: MemoryLayer) => request.layers?.includes(layer)); const searchPromise = this.deps.search({ requestId: request.requestId, adapterId: request.adapterId, @@ -1160,7 +1164,7 @@ export class SessionTurnService { query: buildSearchQuery({ ...request, contextHints }, this.deps.config.domain), layers: endTopicDecision ? [] - : this.deps.memoryLayersForIntent(intentDecision.kind), + : requestedLayers, limit: this.deps.turnStartRetrievalLimit(), contextBudget: typeof request.contextBudget === "number" ? request.contextBudget : undefined, includeInjectedContext: true, diff --git a/Memory/src/types.ts b/Memory/src/types.ts index 79790fce6..3ad4770c8 100644 --- a/Memory/src/types.ts +++ b/Memory/src/types.ts @@ -328,6 +328,7 @@ export interface TurnStartRequest extends RequestEnvelope { sessionId: string; query: string; turnId?: string; + layers?: MemoryLayer[]; contextHints?: Record; contextBudget?: number; } diff --git a/Memory/tests/cli-setup.test.ts b/Memory/tests/cli-setup.test.ts index 18678bb57..7fd5ab8d3 100644 --- a/Memory/tests/cli-setup.test.ts +++ b/Memory/tests/cli-setup.test.ts @@ -76,6 +76,118 @@ describe("memmy-memory CLI setup commands", () => { expect(existsSync(dbPath)).toBe(false); }); + it("generates an authentication token only when explicitly requested", async () => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + createAllAgentRoots(root); + setEnv("HOME", root); + + await runCommand({ + argv: [ + "init", + "--home", root, + "--config", configPath, + "--db", join(root, "memory.sqlite"), + "--skip-agent-skills", + "--generate-token-if-missing" + ] + }); + + const saved = YAML.parse(readFileSync(configPath, "utf8")); + expect(saved.memmyMemory.storage.token).toMatch(/^[a-f0-9]{64}$/); + expect(existsSync(join(root, ".codex", "skills", "memmy-memory"))).toBe(false); + }); + + it("preserves an existing authentication token when initialization is repeated", async () => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, [ + "memmyMemory:", + " storage:", + " token: keep-this-token", + "" + ].join("\n")); + createAllAgentRoots(root); + setEnv("HOME", root); + + await runCommand({ + argv: [ + "init", + "--home", root, + "--config", configPath, + "--db", join(root, "memory.sqlite"), + "--skip-agent-skills", + "--generate-token-if-missing" + ] + }); + + const saved = YAML.parse(readFileSync(configPath, "utf8")); + expect(saved.memmyMemory.storage.token).toBe("keep-this-token"); + }); + + it("uses packaged Hook/plugin integration only after an explicit agent init", async () => { + const root = tempRoot(); + const integrationRoot = join(root, "integration-runtime"); + const markerPath = join(root, "plugin-installed.json"); + mkdirSync(join(integrationRoot, "services"), { recursive: true }); + writeFileSync(join(integrationRoot, "package.json"), JSON.stringify({ type: "module" })); + writeFileSync( + join(integrationRoot, "services", "builtin-skill-target-registry.js"), + [ + "import { writeFile } from 'node:fs/promises';", + "export function createBuiltinSkillTargetRegistry(configPath) {", + " return {", + " get(targetId) {", + " return {", + " async installPlugin(installedTargetId) {", + " await writeFile(process.env.MEMMY_TEST_PLUGIN_MARKER, JSON.stringify({ configPath, targetId, installedTargetId }));", + " }", + " };", + " },", + " };", + "}", + "", + ].join("\n"), + ); + createAllAgentRoots(root); + setEnv("HOME", root); + setEnv("MEMMY_AGENT_INTEGRATION_ROOT", integrationRoot); + setEnv("MEMMY_TEST_PLUGIN_MARKER", markerPath); + const configPath = join(root, "config.yaml"); + + await runCommand({ + argv: [ + "init", + "--home", root, + "--config", configPath, + "--db", join(root, "memory.sqlite"), + "--agent", "codex", + ], + }); + + expect(JSON.parse(readFileSync(markerPath, "utf8"))).toEqual({ + configPath, + targetId: "codex", + installedTargetId: "codex", + }); + }); + + it("does not load packaged agent integrations during installer-style initialization", async () => { + const root = tempRoot(); + setEnv("HOME", root); + setEnv("MEMMY_AGENT_INTEGRATION_ROOT", join(root, "missing-integration-runtime")); + + await expect(runCommand({ + argv: [ + "init", + "--home", root, + "--config", join(root, "config.yaml"), + "--db", join(root, "memory.sqlite"), + "--skip-agent-skills", + ], + })).resolves.toMatchObject({ ok: true }); + }); + it("renders init results as a human-friendly success message", async () => { const root = tempRoot(); createAllAgentRoots(root); @@ -599,6 +711,8 @@ function createCliAssets(assetRoot: string): void { } function createAllAgentRoots(root: string): void { + setEnv("OPENCODE_CONFIG_DIR", ""); + setEnv("XDG_CONFIG_HOME", join(root, ".config")); mkdirSync(join(root, ".codex"), { recursive: true }); mkdirSync(join(root, ".cursor"), { recursive: true }); mkdirSync(join(root, ".claude"), { recursive: true }); diff --git a/Memory/tests/config.test.ts b/Memory/tests/config.test.ts index db51b0b51..ee44cafad 100644 --- a/Memory/tests/config.test.ts +++ b/Memory/tests/config.test.ts @@ -341,6 +341,88 @@ describe("memmy memory config", () => { expect(config.evolution.thinkingBudget).toBeUndefined(); }); + it("uses local embedding for an absent BYOK assignment despite stale custom mode", () => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + providers: {}, + modelPresets: {}, + modelAssignments: { + byok: { embedding: null }, + account: {} + }, + app: { userMode: "byok" }, + memmyMemory: { + embedding: { + mode: "custom", + endpoint: "https://embedding.example.com/v1", + model: "text-embedding-3-small", + apiKey: "sk-stale", + extraHeaders: { "X-Stale": "true" }, + extraBody: { dimensions: 1024 } + } + } + })); + + const { config } = loadMemmyConfig(configPath); + + expect(config.embedding).toMatchObject({ + mode: "local", + provider: "local", + sourceProvider: "local", + model: "Xenova/all-MiniLM-L6-v2" + }); + expect(config.embedding.endpoint).toBeUndefined(); + expect(config.embedding.apiKey).toBeUndefined(); + expect(config.embedding.extraHeaders).toBeUndefined(); + expect(config.embedding.extraBody).toBeUndefined(); + expect(config.embedding.selectionError).toBeUndefined(); + }); + + it("does not fall back locally for an explicit invalid BYOK embedding assignment", () => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + providers: {}, + modelPresets: {}, + modelAssignments: { + byok: { embedding: "missing-embedding-preset" }, + account: {} + }, + app: { userMode: "byok" }, + memmyMemory: { embedding: { mode: "local" } } + })); + + const { config } = loadMemmyConfig(configPath); + + expect(config.embedding.provider).not.toBe("local"); + expect(config.embedding.selectionError).toBe("model_selection_unavailable"); + }); + + it.each([ + ["blank string", " "], + ["number", 42], + ["object", { presetId: "missing" }] + ])("does not treat an explicit invalid %s assignment as absent", (_label, embeddingAssignment) => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + providers: {}, + modelPresets: {}, + modelAssignments: { + byok: { embedding: embeddingAssignment }, + account: {} + }, + app: { userMode: "byok" }, + memmyMemory: { embedding: { mode: "local" } } + })); + + const { config } = loadMemmyConfig(configPath); + + expect(config.embedding.provider).not.toBe("local"); + expect(config.embedding.selectionError).toBe("model_selection_unavailable"); + }); + it("rejects a legacy fixed BYOK evolution connection before runtime use", () => { const root = tempRoot(); const configPath = join(root, "config.yaml"); diff --git a/Memory/tests/contract/memory-rest-service.test.ts b/Memory/tests/contract/memory-rest-service.test.ts index 34a74f6aa..fddffc105 100644 --- a/Memory/tests/contract/memory-rest-service.test.ts +++ b/Memory/tests/contract/memory-rest-service.test.ts @@ -316,6 +316,7 @@ describe("MemoryService / REST contract", () => { sessionId: opened.sessionId, turnId: "cursor-http-turn", query: "Continue the hook lifecycle repair", + layers: ["L2"], contextHints: { agentIdentity: "cursor-agent", hostProvider: "cursor" @@ -361,6 +362,9 @@ describe("MemoryService / REST contract", () => { tool_name: "memory_search", retrieval_mode: "turn_start" }); + expect(db.db.prepare( + "SELECT layers_json FROM recall_events WHERE id = ?" + ).get(started.searchEventId)).toEqual({ layers_json: '["L2"]' }); const duplicateStartResponse = await fetch(baseUrl + "/turns/start", { method: "POST", headers: { "content-type": "application/json" }, diff --git a/Memory/tests/service/session/turn-capture.test.ts b/Memory/tests/service/session/turn-capture.test.ts index b3c151fc4..772fd2959 100644 --- a/Memory/tests/service/session/turn-capture.test.ts +++ b/Memory/tests/service/session/turn-capture.test.ts @@ -17,6 +17,30 @@ const { afterEach(cleanup); describe("MemoryService / session / turn capture", () => { + it("preserves an explicit empty turn-start layer selection for evaluation ablations", async () => { + const { db, service } = createTestService(); + const session = service.openSession({ + namespace: { + source: "memmy-agent", + profileId: "layer-ablation", + userId: "layer-ablation-user" + } + }); + + const started = await service.startTurn({ + sessionId: session.sessionId, + turnId: "turn-layer-ablation-none", + query: "Fix the failing SWE test without retrieved memory.", + layers: [] + }); + + expect(started.sourceMemoryIds).toEqual([]); + expect(db.db.prepare( + "SELECT layers_json FROM recall_events WHERE id = ?" + ).get(started.searchEventId)).toEqual({ layers_json: "[]" }); + db.close(); + }); + it("records only recall audit at turn.start and commits episode, RawTurn, and L1 at turn.complete", async () => { const { db, service } = createTestService(); const session = service.openSession({ diff --git a/README.md b/README.md index f255c87a0..7ade8441e 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,24 @@ Compared with "personal AI Agents" like Hermes and OpenClaw, what sets ### Option 2: `memmy` CLI (Agent Runtime) +On Linux x64 or arm64 with Node.js 22 or newer and an available systemd user session: + +```bash +curl -fsSL https://raw.githubusercontent.com/MemTensor/memmy-agent/main/scripts/install.sh | bash +memmy +``` + +The installer enables the local Memory Service immediately as `memmy-memory.service`. The first bare `memmy` invocation opens the model setup wizard when needed, then enables `memmy-gateway.service`, waits for it to become ready, and enters the TUI. Both are `systemd --user` services bound to localhost and remain available after the TUI or terminal exits. They start again on later logins; the installer does not enable linger. Only the installer launcher activates this service management, so source-built Linux CLIs keep their existing behavior. + +Before starting or reconnecting to the Gateway, `memmy` refreshes a private `~/.memmy/systemd/gateway.env` file (mode `0600`) with configuration-referenced environment variables, common Provider credentials, and the terminal `PATH`. If those values change, the next bare `memmy` invocation restarts the user service with the new environment. + +```bash +systemctl --user status memmy-memory.service +systemctl --user status memmy-gateway.service +``` + +The installer initializes Memory without changing Codex, Claude Code, Cursor, or other agents. Run `memmy-memory init` (all detected agents) or `memmy-memory init --agent ` when you explicitly want to install the Memory Skill and the supported Hook/plugin for an agent. + ```bash memmy onboard # Initialize ~/.memmy/config.yaml and the workspace memmy status # Check config, workspace, model, and provider status diff --git a/README.zh-CN.md b/README.zh-CN.md index 8c8258b0a..ddf63cc30 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -139,6 +139,24 @@ Memmy 不只是一个聊天界面,而是一套运行在本地的 AI Agent  ### 方式二:`memmy` CLI(Agent Runtime) +Linux x64 或 arm64(需 Node.js 22 或更高版本,并且 systemd 用户会话可用)可一行安装: + +```bash +curl -fsSL https://raw.githubusercontent.com/MemTensor/memmy-agent/main/scripts/install.sh | bash +memmy +``` + +安装器会立即启用本地 Memory Service(`memmy-memory.service`)。首次裸执行 `memmy` 时,如尚未配置模型,会在当前终端进入配置向导;保存后启用 `memmy-gateway.service`,等待就绪并进入 TUI。二者均为只绑定本机地址的 `systemd --user` 服务,退出 TUI 或关闭终端不会停止;以后登录时会自动启动。安装器不会启用 linger。只有安装器生成的 launcher 会启用这套服务管理,源码构建的 Linux CLI 保持原有行为。 + +启动或重新连接 Gateway 前,`memmy` 会把配置引用的环境变量、常用 Provider 凭据和终端 `PATH` 刷新到权限为 `0600` 的私有文件 `~/.memmy/systemd/gateway.env`。这些值发生变化后,下次裸执行 `memmy` 会使用新环境重启用户服务。 + +```bash +systemctl --user status memmy-memory.service +systemctl --user status memmy-gateway.service +``` + +安装阶段只初始化 Memory 基础配置,不会修改 Codex、Claude Code、Cursor 等外部 Agent。需要接入时,由用户明确执行 `memmy-memory init`(接入检测到的 Agent)或 `memmy-memory init --agent ` 安装对应的 Memory Skill 及受支持的 Hook/插件。 + ```bash memmy onboard # 初始化 ~/.memmy/config.yaml 和 workspace memmy status # 查看配置、workspace、模型、provider 状态 diff --git a/docs/cn/start/getting-started.mdx b/docs/cn/start/getting-started.mdx index 97c7b285f..f58663d23 100644 --- a/docs/cn/start/getting-started.mdx +++ b/docs/cn/start/getting-started.mdx @@ -16,6 +16,17 @@ icon: Rocket ## 快速设置 +Linux x64 或 arm64 的纯终端用户需先安装 Node.js 22 或更高版本,并确认 systemd 用户会话可用,然后执行: + +```bash +curl -fsSL https://raw.githubusercontent.com/MemTensor/memmy-agent/main/scripts/install.sh | bash +memmy +``` + +安装器会以 `memmy-memory.service` 启动本地 Memory Service。如果尚未配置可用模型,首次裸执行 `memmy` 会打开现有配置向导;保存后启用 `memmy-gateway.service`,等待就绪并进入 TUI。两个 `systemd --user` 服务都不会因退出 TUI 或关闭终端而停止,并会在以后登录时自动启动;安装器不启用 linger。安装器 launcher 还会刷新权限为 `0600` 的私有文件 `~/.memmy/systemd/gateway.env`,确保配置引用的凭据和终端 `PATH` 对常驻 Gateway 可用。 + +可用 `systemctl --user status memmy-memory.service` 和 `systemctl --user status memmy-gateway.service` 查看状态。安装器不会修改外部 Agent;只有用户明确执行 `memmy-memory init` 或 `memmy-memory init --agent ` 时,才安装对应的 Memory 集成。 + 下载并安装 Memmy 桌面应用。首次启动会自动完成: 1. 设置 `MEMMY_HOME` 和 `MEMMY_CONFIG`。 diff --git a/docs/en/start/getting-started.mdx b/docs/en/start/getting-started.mdx index 530a88542..c9a3d8b20 100644 --- a/docs/en/start/getting-started.mdx +++ b/docs/en/start/getting-started.mdx @@ -16,6 +16,17 @@ icon: Rocket ## Quick Setup +For a terminal-only installation on Linux x64 or arm64, install Node.js 22 or newer and make sure a systemd user session is available, then run: + +```bash +curl -fsSL https://raw.githubusercontent.com/MemTensor/memmy-agent/main/scripts/install.sh | bash +memmy +``` + +The installer starts the local Memory Service as `memmy-memory.service`. If no usable model is configured, the first bare `memmy` command opens the existing wizard. After saving, it enables `memmy-gateway.service`, waits for readiness, and continues into the TUI. Both `systemd --user` services stay running when the TUI or terminal exits and start again on later logins; linger is not enabled. The installer launcher also refreshes a private `~/.memmy/systemd/gateway.env` file (mode `0600`) so configuration-referenced credentials and the terminal `PATH` remain available to the persistent Gateway. + +Use `systemctl --user status memmy-memory.service` and `systemctl --user status memmy-gateway.service` to inspect them. The installer does not modify external agents. Run `memmy-memory init` or `memmy-memory init --agent ` explicitly to install Memory integration for an agent. + Download and install the Memmy desktop app. On first launch it automatically: 1. Sets `MEMMY_HOME` and `MEMMY_CONFIG`. diff --git a/package-lock.json b/package-lock.json index d8a858654..16a9dfa9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memmy-agent", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memmy-agent", - "version": "1.1.0", + "version": "1.1.1", "workspaces": [ "Migrations", "Memory", @@ -576,7 +576,7 @@ }, "App/shell/desktop": { "name": "@memmy/desktop", - "version": "1.1.0", + "version": "1.1.1", "dependencies": { "@memmy/backend": "0.0.0", "@memmy/desktop-interface": "0.0.0", @@ -713,7 +713,7 @@ }, "Memory": { "name": "@memmy/memory", - "version": "1.1.0", + "version": "1.1.1", "dependencies": { "@huggingface/transformers": "^3.8.0", "@memmy/local-api-contracts": "0.0.0", diff --git a/package.json b/package.json index 59fb1b4d2..0956d3015 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "memmy-agent", - "version": "1.1.0", + "version": "1.1.1", "private": true, "type": "module", "description": "Local-first agent memory substrate with desktop and CLI surfaces.", @@ -27,6 +27,7 @@ "test": "npm run test:release-workflow && npm run test:packaging-guards && npm run memory:test && npm run workspace:test && npm run agent:test:tui-cursor", "test:release-workflow": "vitest run tests/release-workflow.test.ts tests/oss-crc64.test.mjs", "test:packaging-guards": "vitest run tests/package-version-guard.test.mjs tests/packaged-runtime-config.test.mjs tests/package-logging.test.mjs", + "test:linux-cli": "vitest run tests/linux-cli-packaging.test.mjs && npm --prefix App/memmy-agent exec vitest run tests/entrypoints/cli/linux-systemd-gateway.test.ts tests/entrypoints/cli/gateway-lifecycle.test.ts tests/entrypoints/cli/root-terminal-options.test.ts tests/entrypoints/cli/terminal-target.test.ts && npm --prefix Memory test -- tests/cli-setup.test.ts", "serve": "npm run memory:serve", "serve:local": "npm run memory:serve:local", "serve:dev": "npm run memory:serve:dev", diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 000000000..f69a055a7 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,431 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPOSITORY="${MEMMY_GITHUB_REPOSITORY:-MemTensor/memmy-agent}" +ARCHIVE_NAME="memmy-agent-linux-cli.tar.gz" +CHECKSUM_NAME="$ARCHIVE_NAME.sha256" +INSTALL_ROOT="${MEMMY_INSTALL_ROOT:-$HOME/.local/share/memmy-agent}" +BIN_DIR="${MEMMY_BIN_DIR:-$HOME/.local/bin}" +MEMMY_HOME_DIR="${MEMMY_HOME:-$HOME/.memmy}" +CONFIG_PATH="${MEMMY_CONFIG:-$MEMMY_HOME_DIR/config.yaml}" +WORKSPACE_DIR="${MEMMY_AGENT_WORKSPACE:-$MEMMY_HOME_DIR/workspace}" +MEMORY_DB_PATH="${MEMMY_MEMORY_DB:-$MEMMY_HOME_DIR/memory-service/memory.sqlite}" +SYSTEMD_USER_DIR="${MEMMY_SYSTEMD_USER_DIR:-$HOME/.config/systemd/user}" +MEMORY_UNIT="$SYSTEMD_USER_DIR/memmy-memory.service" +GATEWAY_UNIT="$SYSTEMD_USER_DIR/memmy-gateway.service" +GATEWAY_ENV_FILE="$MEMMY_HOME_DIR/systemd/gateway.env" + +fail() { + printf 'memmy installer: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +if [ "$(uname -s)" != "Linux" ]; then + fail "this installer supports Linux only" +fi + +case "$(uname -m)" in + x86_64|amd64) + PLATFORM_ARCH="x64" + ;; + aarch64|arm64) + PLATFORM_ARCH="arm64" + ;; + *) + fail "unsupported Linux architecture: $(uname -m) (expected x86_64 or arm64)" + ;; +esac + +for command_name in node npm curl tar systemctl; do + require_command "$command_name" +done +if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then + fail "required SHA-256 tool not found (install sha256sum or shasum)" +fi + +NODE_MAJOR="$(node -p 'Number(process.versions.node.split(".")[0])')" \ + || fail "could not determine Node.js version" +if [ "$NODE_MAJOR" -lt 22 ]; then + fail "Node.js 22 or newer is required (found $(node --version))" +fi +NODE_BIN="$(command -v node)" +case "$NODE_BIN" in + /*) ;; + *) NODE_BIN="$(cd "$(dirname "$NODE_BIN")" && pwd)/$(basename "$NODE_BIN")" ;; +esac + +if ! systemctl --user show-environment >/dev/null 2>&1; then + fail "systemd --user is required and no user service manager is available" +fi + +VERSION="${MEMMY_VERSION:-}" +if [ -z "$VERSION" ]; then + LATEST_URL="$(curl --fail --silent --show-error --location \ + --output /dev/null --write-out '%{url_effective}' \ + "https://github.com/$REPOSITORY/releases/latest")" \ + || fail "could not resolve the latest GitHub Release" + VERSION="${LATEST_URL##*/}" +fi +VERSION="${VERSION#v}" +if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + fail "invalid release version: $VERSION" +fi + +if [ -n "${MEMMY_RELEASE_BASE_URL:-}" ]; then + ASSET_BASE="${MEMMY_RELEASE_BASE_URL%/}" +else + ASSET_BASE="https://github.com/$REPOSITORY/releases/download/v$VERSION" +fi + +mkdir -p "$INSTALL_ROOT/releases" "$BIN_DIR" +WORK_DIR="$(mktemp -d "$INSTALL_ROOT/.install.XXXXXX")" +CONFIG_EXISTED="false" +CONFIG_BACKUP="$WORK_DIR/config.before-install" +if [ -f "$CONFIG_PATH" ]; then + cp -p "$CONFIG_PATH" "$CONFIG_BACKUP" + CONFIG_EXISTED="true" +fi +restore_config() { + if [ "$CONFIG_EXISTED" = "true" ]; then + mkdir -p "$(dirname "$CONFIG_PATH")" + cp -p "$CONFIG_BACKUP" "$CONFIG_PATH" + else + rm -f "$CONFIG_PATH" + fi +} +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +ARCHIVE_PATH="$WORK_DIR/$ARCHIVE_NAME" +CHECKSUM_PATH="$WORK_DIR/$CHECKSUM_NAME" +printf 'Downloading Memmy Agent %s for Linux %s...\n' "$VERSION" "$PLATFORM_ARCH" +curl --fail --silent --show-error --location --retry 3 --retry-all-errors \ + --output "$ARCHIVE_PATH" "$ASSET_BASE/$ARCHIVE_NAME" \ + || fail "download failed: $ASSET_BASE/$ARCHIVE_NAME" +curl --fail --silent --show-error --location --retry 3 --retry-all-errors \ + --output "$CHECKSUM_PATH" "$ASSET_BASE/$CHECKSUM_NAME" \ + || fail "checksum download failed: $ASSET_BASE/$CHECKSUM_NAME" + +if command -v sha256sum >/dev/null 2>&1; then + (cd "$WORK_DIR" && sha256sum --check "$CHECKSUM_NAME") \ + || fail "SHA-256 verification failed" +else + EXPECTED_SHA256="$(awk '{print $1; exit}' "$CHECKSUM_PATH")" + ACTUAL_SHA256="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')" + [ "$EXPECTED_SHA256" = "$ACTUAL_SHA256" ] || fail "SHA-256 verification failed" +fi + +while IFS= read -r archive_entry; do + case "$archive_entry" in + /*|../*|*/../*|*/..) + fail "archive contains an unsafe path: $archive_entry" + ;; + esac +done < <(tar -tzf "$ARCHIVE_PATH") + +PAYLOAD_DIR="$WORK_DIR/payload" +mkdir -p "$PAYLOAD_DIR" +tar -xzf "$ARCHIVE_PATH" --no-same-owner --no-same-permissions -C "$PAYLOAD_DIR" + +AGENT_DIR="$PAYLOAD_DIR/App/memmy-agent" +[ -f "$PAYLOAD_DIR/package.json" ] || fail "archive is missing package.json" +[ -f "$PAYLOAD_DIR/package-lock.json" ] || fail "archive is missing package-lock.json" +[ -f "$AGENT_DIR/package.json" ] || fail "archive is missing App/memmy-agent/package.json" +[ -f "$AGENT_DIR/package-lock.json" ] || fail "archive is missing App/memmy-agent/package-lock.json" +[ -f "$AGENT_DIR/dist/main.js" ] || fail "archive is missing the Memmy CLI entrypoint" +[ -f "$PAYLOAD_DIR/Memory/dist/src/server/index.js" ] \ + || fail "archive is missing the Memory service entrypoint" +[ -f "$PAYLOAD_DIR/Memory/dist/src/cli/index.js" ] \ + || fail "archive is missing the memmy-memory CLI entrypoint" +[ -f "$PAYLOAD_DIR/App/backend/dist/src/services/builtin-skill-target-registry.js" ] \ + || fail "archive is missing the existing agent Hook/plugin integration registry" +[ -f "$PAYLOAD_DIR/App/backend/dist/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.js" ] \ + || fail "archive is missing the existing agent Hook template" +[ -f "$PAYLOAD_DIR/App/backend/dist/src/adapters/outbound/skill-writer/templates/memmy-opencode-plugin.js" ] \ + || fail "archive is missing the existing native plugin template" +[ -f "$PAYLOAD_DIR/resources/embedding-models/Xenova/all-MiniLM-L6-v2/onnx/model_quantized.onnx" ] \ + || fail "archive is missing the bundled embedding model" + +printf 'Installing Memory production dependencies for this Linux machine...\n' +(cd "$PAYLOAD_DIR" && npm ci --omit=dev --workspace @memmy/memory \ + --include-workspace-root=false --no-audit --no-fund) \ + || fail "Memory dependency installation failed; the previous Memmy installation is unchanged" +printf 'Installing Agent production dependencies for this Linux machine...\n' +(cd "$AGENT_DIR" && npm ci --omit=dev --no-audit --no-fund) \ + || fail "Agent dependency installation failed; the previous Memmy installation is unchanged" + +mkdir -p "$MEMMY_HOME_DIR" "$(dirname "$CONFIG_PATH")" "$(dirname "$MEMORY_DB_PATH")" "$WORKSPACE_DIR" +chmod 0700 "$MEMMY_HOME_DIR" "$(dirname "$MEMORY_DB_PATH")" "$WORKSPACE_DIR" + +MEMORY_INIT_ARGS=( + init + --home "$MEMMY_HOME_DIR" + --config "$CONFIG_PATH" + --endpoint "http://127.0.0.1:18960" + --db "$MEMORY_DB_PATH" + --skip-agent-skills + --generate-token-if-missing +) +if ! "$NODE_BIN" "$PAYLOAD_DIR/Memory/dist/src/cli/index.js" "${MEMORY_INIT_ARGS[@]}" >/dev/null; then + restore_config + fail "could not initialize the local Memory configuration" +fi +chmod 0600 "$CONFIG_PATH" + +RELEASE_DIR="$INSTALL_ROOT/releases/$VERSION-$(date +%s)-$$" +mv "$PAYLOAD_DIR" "$RELEASE_DIR" + +PREVIOUS_RELEASE="" +if [ -L "$INSTALL_ROOT/current" ]; then + PREVIOUS_RELEASE="$(readlink "$INSTALL_ROOT/current")" +fi +MEMORY_WAS_ACTIVE="false" +if systemctl --user is-active --quiet memmy-memory.service >/dev/null 2>&1; then + MEMORY_WAS_ACTIVE="true" +fi +GATEWAY_WAS_ACTIVE="false" +if systemctl --user is-active --quiet memmy-gateway.service >/dev/null 2>&1; then + GATEWAY_WAS_ACTIVE="true" +fi +CURRENT_LINK="$INSTALL_ROOT/.current.$$" +ln -s "$RELEASE_DIR" "$CURRENT_LINK" +mv -Tf "$CURRENT_LINK" "$INSTALL_ROOT/current" + +LAUNCHER_TEMP="$BIN_DIR/.memmy.$$" +{ + printf '#!/usr/bin/env bash\n' + printf 'set -euo pipefail\n' + printf 'MEMMY_INSTALL_ROOT=%q\n' "$INSTALL_ROOT" + printf 'export MEMMY_HOME=%q\n' "$MEMMY_HOME_DIR" + printf 'export MEMMY_CONFIG=%q\n' "$CONFIG_PATH" + printf 'export MEMMY_AGENT_WORKSPACE=%q\n' "$WORKSPACE_DIR" + printf 'export MEMMY_GATEWAY_ENV_FILE=%q\n' "$GATEWAY_ENV_FILE" + printf 'export MEMMY_LINUX_SYSTEMD_GATEWAY=1\n' + printf 'exec %q "$MEMMY_INSTALL_ROOT/current/App/memmy-agent/dist/main.js" "$@"\n' "$NODE_BIN" +} > "$LAUNCHER_TEMP" +chmod 0755 "$LAUNCHER_TEMP" +mv -f "$LAUNCHER_TEMP" "$BIN_DIR/memmy" + +MEMORY_LAUNCHER_TEMP="$BIN_DIR/.memmy-memory.$$" +{ + printf '#!/usr/bin/env bash\n' + printf 'set -euo pipefail\n' + printf 'MEMMY_INSTALL_ROOT=%q\n' "$INSTALL_ROOT" + printf 'export MEMMY_HOME=%q\n' "$MEMMY_HOME_DIR" + printf 'export MEMMY_CONFIG=%q\n' "$CONFIG_PATH" + printf 'export MEMMY_AGENT_INTEGRATION_ROOT="$MEMMY_INSTALL_ROOT/current/App/backend/dist/src"\n' + printf 'exec %q "$MEMMY_INSTALL_ROOT/current/Memory/dist/src/cli/index.js" "$@"\n' "$NODE_BIN" +} > "$MEMORY_LAUNCHER_TEMP" +chmod 0755 "$MEMORY_LAUNCHER_TEMP" +mv -f "$MEMORY_LAUNCHER_TEMP" "$BIN_DIR/memmy-memory" + +systemd_quote() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//%/%%}" + printf '"%s"' "$value" +} + +systemd_environment_file_path() { + local value="$1" + case "$value" in + *$'\n'*|*$'\r'*) + fail "Gateway environment file path contains a newline" + ;; + esac + # EnvironmentFile parses its path as a raw value rather than a quoted word. + # Preserve spaces, but escape '%' so it is not expanded as a specifier. + value="${value//%/%%}" + printf '%s' "$value" +} + +mkdir -p "$SYSTEMD_USER_DIR" + +MEMORY_UNIT_TEMP="$SYSTEMD_USER_DIR/.memmy-memory.service.$$" +{ + printf '[Unit]\n' + printf 'Description=Memmy Memory Service\n\n' + printf '[Service]\n' + printf 'Type=simple\n' + printf 'Environment=%s\n' "$(systemd_quote "MEMMY_HOME=$MEMMY_HOME_DIR")" + printf 'Environment=%s\n' "$(systemd_quote "MEMMY_CONFIG=$CONFIG_PATH")" + printf 'Environment=%s\n' "$(systemd_quote "MEMMY_EMBEDDING_MODEL_ROOT=$INSTALL_ROOT/current/resources/embedding-models")" + printf 'ExecStart=%s %s --config %s --host 127.0.0.1 --port 18960 --db %s\n' \ + "$(systemd_quote "$NODE_BIN")" \ + "$(systemd_quote "$INSTALL_ROOT/current/Memory/dist/src/server/index.js")" \ + "$(systemd_quote "$CONFIG_PATH")" \ + "$(systemd_quote "$MEMORY_DB_PATH")" + printf 'Restart=on-failure\n' + printf 'RestartSec=3s\n' + printf 'TimeoutStopSec=15s\n' + printf 'UMask=0077\n\n' + printf '[Install]\n' + printf 'WantedBy=default.target\n' +} > "$MEMORY_UNIT_TEMP" +chmod 0644 "$MEMORY_UNIT_TEMP" +mv -f "$MEMORY_UNIT_TEMP" "$MEMORY_UNIT" + +GATEWAY_UNIT_TEMP="$SYSTEMD_USER_DIR/.memmy-gateway.service.$$" +{ + printf '[Unit]\n' + printf 'Description=Memmy Agent Gateway\n' + printf 'Wants=memmy-memory.service\n' + printf 'After=memmy-memory.service\n' + printf 'StartLimitIntervalSec=60s\n' + printf 'StartLimitBurst=5\n\n' + printf '[Service]\n' + printf 'Type=simple\n' + printf 'Environment=%s\n' "$(systemd_quote "MEMMY_HOME=$MEMMY_HOME_DIR")" + printf 'Environment=%s\n' "$(systemd_quote "MEMMY_CONFIG=$CONFIG_PATH")" + printf 'Environment=%s\n' "$(systemd_quote "MEMMY_AGENT_WORKSPACE=$WORKSPACE_DIR")" + printf 'Environment=%s\n' "$(systemd_quote "MEMMY_GATEWAY_ENV_FILE=$GATEWAY_ENV_FILE")" + # EnvironmentFile does not unquote paths like Environment=/ExecStart= do. The + # optional-file prefix and absolute path must therefore both remain unquoted. + printf 'EnvironmentFile=-%s\n' "$(systemd_environment_file_path "$GATEWAY_ENV_FILE")" + printf 'Environment=MEMMY_MEMORY_URL=http://127.0.0.1:18960\n' + printf 'Environment=MEMORY_SERVICE_URL=http://127.0.0.1:18960\n' + printf 'ExecStart=%s %s gateway --config %s --workspace %s\n' \ + "$(systemd_quote "$NODE_BIN")" \ + "$(systemd_quote "$INSTALL_ROOT/current/App/memmy-agent/dist/main.js")" \ + "$(systemd_quote "$CONFIG_PATH")" \ + "$(systemd_quote "$WORKSPACE_DIR")" + printf 'Restart=on-failure\n' + printf 'RestartSec=3s\n' + printf 'TimeoutStopSec=15s\n' + printf 'UMask=0077\n\n' + printf '[Install]\n' + printf 'WantedBy=default.target\n' +} > "$GATEWAY_UNIT_TEMP" +chmod 0644 "$GATEWAY_UNIT_TEMP" +mv -f "$GATEWAY_UNIT_TEMP" "$GATEWAY_UNIT" + +rollback_current_release() { + restore_config + if [ -n "$PREVIOUS_RELEASE" ]; then + local rollback_link="$INSTALL_ROOT/.rollback.$$" + ln -s "$PREVIOUS_RELEASE" "$rollback_link" + mv -Tf "$rollback_link" "$INSTALL_ROOT/current" + systemctl --user restart memmy-memory.service >/dev/null 2>&1 || true + if [ "$GATEWAY_WAS_ACTIVE" = "true" ]; then + systemctl --user restart memmy-gateway.service >/dev/null 2>&1 || true + fi + else + systemctl --user disable --now memmy-memory.service >/dev/null 2>&1 || true + unlink "$INSTALL_ROOT/current" >/dev/null 2>&1 || true + fi +} + +if ! systemctl --user daemon-reload; then + rollback_current_release + fail "could not reload the systemd user manager" +fi +if ! systemctl --user enable --now memmy-memory.service; then + rollback_current_release + fail "could not enable and start memmy-memory.service" +fi +if [ "$MEMORY_WAS_ACTIVE" = "true" ]; then + if ! systemctl --user restart memmy-memory.service; then + rollback_current_release + fail "could not restart memmy-memory.service after update" + fi +fi +MEMORY_READY="false" +for ((_attempt = 1; _attempt <= 200; _attempt++)); do + MEMORY_PID_BEFORE="$(systemctl --user show memmy-memory.service --property=MainPID --value 2>/dev/null || true)" + if [[ "$MEMORY_PID_BEFORE" =~ ^[1-9][0-9]*$ ]] \ + && [ "$MEMORY_PID_BEFORE" -gt 1 ] \ + && kill -0 "$MEMORY_PID_BEFORE" >/dev/null 2>&1 \ + && "$BIN_DIR/memmy-memory" health >/dev/null 2>&1; then + sleep 0.25 + MEMORY_PID_AFTER="$(systemctl --user show memmy-memory.service --property=MainPID --value 2>/dev/null || true)" + if [ "$MEMORY_PID_AFTER" = "$MEMORY_PID_BEFORE" ] \ + && kill -0 "$MEMORY_PID_AFTER" >/dev/null 2>&1; then + MEMORY_READY="true" + break + fi + fi + sleep 0.15 +done +if [ "$MEMORY_READY" != "true" ]; then + rollback_current_release + fail "memmy-memory.service did not become healthy; check systemctl --user status memmy-memory.service" +fi +if [ "$GATEWAY_WAS_ACTIVE" = "true" ] && [ -f "$GATEWAY_ENV_FILE" ]; then + if ! systemctl --user restart memmy-gateway.service; then + rollback_current_release + fail "could not restart memmy-gateway.service after update" + fi + GATEWAY_READY="false" + GATEWAY_LAST_PID="" + GATEWAY_STABLE_SAMPLES=0 + for ((_attempt = 1; _attempt <= 200; _attempt++)); do + GATEWAY_PID="$(systemctl --user show memmy-gateway.service --property=MainPID --value 2>/dev/null || true)" + if systemctl --user is-active --quiet memmy-gateway.service >/dev/null 2>&1 \ + && [[ "$GATEWAY_PID" =~ ^[1-9][0-9]*$ ]] \ + && [ "$GATEWAY_PID" -gt 1 ] \ + && kill -0 "$GATEWAY_PID" >/dev/null 2>&1; then + if [ "$GATEWAY_PID" = "$GATEWAY_LAST_PID" ]; then + GATEWAY_STABLE_SAMPLES=$((GATEWAY_STABLE_SAMPLES + 1)) + else + GATEWAY_LAST_PID="$GATEWAY_PID" + GATEWAY_STABLE_SAMPLES=0 + fi + if [ "$GATEWAY_STABLE_SAMPLES" -ge 14 ]; then + GATEWAY_READY="true" + break + fi + else + GATEWAY_LAST_PID="" + GATEWAY_STABLE_SAMPLES=0 + fi + sleep 0.15 + done + if [ "$GATEWAY_READY" != "true" ]; then + rollback_current_release + fail "memmy-gateway.service did not become stable after update; check systemctl --user status memmy-gateway.service" + fi +fi + +PATH_UPDATED="false" +case ":$PATH:" in + *":$BIN_DIR:"*) + ;; + *) + if [ "$BIN_DIR" = "$HOME/.local/bin" ]; then + PROFILE_PATH="$HOME/.profile" + PATH_LINE='export PATH="$HOME/.local/bin:$PATH"' + if [ ! -f "$PROFILE_PATH" ] || ! grep -Fqx "$PATH_LINE" "$PROFILE_PATH"; then + printf '\n%s\n' "$PATH_LINE" >> "$PROFILE_PATH" + fi + PATH_UPDATED="true" + fi + ;; +esac + +trap - EXIT +rm -rf "$WORK_DIR" + +# Keep the current release and at most one previous release for manual rollback. +for old_release in "$INSTALL_ROOT"/releases/*; do + [ -d "$old_release" ] || continue + if [ "$old_release" != "$RELEASE_DIR" ] && [ "$old_release" != "$PREVIOUS_RELEASE" ]; then + rm -rf "$old_release" + fi +done + +printf 'Memmy Agent %s installed successfully.\n' "$VERSION" +if [ "$PATH_UPDATED" = "true" ]; then + printf 'Open a new terminal, or run: export PATH="$HOME/.local/bin:$PATH"\n' +elif ! command -v memmy >/dev/null 2>&1; then + printf 'Add %s to PATH, then run memmy.\n' "$BIN_DIR" +fi +printf 'Memory service: systemctl --user status memmy-memory.service\n' +printf 'Gateway service: systemctl --user status memmy-gateway.service\n' +printf 'Start Memmy with: memmy\n' diff --git a/scripts/internal/linux/build-cli-archive.sh b/scripts/internal/linux/build-cli-archive.sh new file mode 100755 index 000000000..b03d88014 --- /dev/null +++ b/scripts/internal/linux/build-cli-archive.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +VERSION="$(node -p "require('$REPO_ROOT/package.json').version")" +OUTPUT_DIR="$REPO_ROOT/release-assets" +ARCHIVE_NAME="memmy-agent-linux-cli.tar.gz" + +usage() { + printf '%s\n' \ + "Usage: build-cli-archive.sh [--version X.Y.Z] [--output DIR]" \ + "" \ + "Builds the architecture-neutral Memmy Agent Linux CLI archive." +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || { echo "--version requires a value" >&2; exit 1; } + VERSION="$2" + shift 2 + ;; + --output) + [ "$#" -ge 2 ] || { echo "--output requires a directory" >&2; exit 1; } + OUTPUT_DIR="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +ROOT_VERSION="$(node -p "require('$REPO_ROOT/package.json').version")" +if [ "$VERSION" != "$ROOT_VERSION" ]; then + echo "Requested version $VERSION does not match repository version $ROOT_VERSION" >&2 + exit 1 +fi + +if [ "${MEMMY_LINUX_CLI_SKIP_BUILD:-0}" != "1" ]; then + rm -rf \ + "$REPO_ROOT/App/memmy-agent/dist" \ + "$REPO_ROOT/App/backend/dist" \ + "$REPO_ROOT/Memory/dist" \ + "$REPO_ROOT/Migrations/dist" \ + "$REPO_ROOT/App/backend/local-api-contracts/dist" + npm --prefix "$REPO_ROOT/Migrations" run build + npm --prefix "$REPO_ROOT/App/backend/local-api-contracts" run build + npm --prefix "$REPO_ROOT/App/backend" run build + npm --prefix "$REPO_ROOT/Memory" run build + npm --prefix "$REPO_ROOT/App/memmy-agent" run build +fi + +for required in \ + "$REPO_ROOT/App/memmy-agent/dist/main.js" \ + "$REPO_ROOT/Memory/dist/src/server/index.js" \ + "$REPO_ROOT/Memory/dist/src/cli/index.js" \ + "$REPO_ROOT/App/backend/dist/src/analytics/analytics-transport.js" \ + "$REPO_ROOT/App/backend/dist/src/services/builtin-skill-target-registry.js" \ + "$REPO_ROOT/Migrations/dist/index.js" \ + "$REPO_ROOT/App/backend/local-api-contracts/dist/index.js"; do + if [ ! -f "$required" ]; then + echo "Required build output is missing: $required" >&2 + exit 1 + fi +done + +BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memmy-linux-cli.XXXXXX")" +cleanup() { + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +PAYLOAD_DIR="$BUILD_DIR/payload" +mkdir -p \ + "$PAYLOAD_DIR/App/memmy-agent" \ + "$PAYLOAD_DIR/App/backend/dist/src/analytics" \ + "$PAYLOAD_DIR/App/backend/dist/src/adapters/outbound" \ + "$PAYLOAD_DIR/App/backend/dist/src/services" \ + "$PAYLOAD_DIR/App/backend/local-api-contracts" \ + "$PAYLOAD_DIR/Memory" \ + "$PAYLOAD_DIR/Migrations" \ + "$OUTPUT_DIR" + +cp "$REPO_ROOT/package.json" "$PAYLOAD_DIR/package.json" +cp "$REPO_ROOT/package-lock.json" "$PAYLOAD_DIR/package-lock.json" +cp "$REPO_ROOT/App/memmy-agent/package.json" "$PAYLOAD_DIR/App/memmy-agent/package.json" +cp "$REPO_ROOT/App/memmy-agent/package-lock.json" "$PAYLOAD_DIR/App/memmy-agent/package-lock.json" +cp -R "$REPO_ROOT/App/memmy-agent/dist" "$PAYLOAD_DIR/App/memmy-agent/dist" +cp "$REPO_ROOT/App/backend/package.json" "$PAYLOAD_DIR/App/backend/package.json" +cp -R "$REPO_ROOT/App/backend/dist/src/adapters/outbound/skill-writer" \ + "$PAYLOAD_DIR/App/backend/dist/src/adapters/outbound/skill-writer" +cp "$REPO_ROOT/App/backend/dist/src/adapters/outbound/agent-paths.js" \ + "$PAYLOAD_DIR/App/backend/dist/src/adapters/outbound/agent-paths.js" +cp "$REPO_ROOT/App/backend/dist/src/project-version.js" \ + "$PAYLOAD_DIR/App/backend/dist/src/project-version.js" +cp "$REPO_ROOT/App/backend/dist/src/analytics/analytics-transport.js" \ + "$PAYLOAD_DIR/App/backend/dist/src/analytics/analytics-transport.js" +cp "$REPO_ROOT/App/backend/dist/src/services/builtin-skill-target-registry.js" \ + "$PAYLOAD_DIR/App/backend/dist/src/services/builtin-skill-target-registry.js" +cp "$REPO_ROOT/Memory/package.json" "$PAYLOAD_DIR/Memory/package.json" +cp -R "$REPO_ROOT/Memory/dist" "$PAYLOAD_DIR/Memory/dist" +cp "$REPO_ROOT/Migrations/package.json" "$PAYLOAD_DIR/Migrations/package.json" +cp -R "$REPO_ROOT/Migrations/dist" "$PAYLOAD_DIR/Migrations/dist" +cp "$REPO_ROOT/App/backend/local-api-contracts/package.json" \ + "$PAYLOAD_DIR/App/backend/local-api-contracts/package.json" +cp -R "$REPO_ROOT/App/backend/local-api-contracts/dist" \ + "$PAYLOAD_DIR/App/backend/local-api-contracts/dist" + +node --input-type=module - "$PAYLOAD_DIR/package.json" <<'NODE' +import { readFileSync, writeFileSync } from "node:fs"; + +const manifestPath = process.argv[2]; +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); +manifest.workspaces = [ + "Memory", + "Migrations", + "App/backend/local-api-contracts" +]; +writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +NODE + +node "$REPO_ROOT/scripts/internal/shared/prepare-embedding-model.mjs" \ + "$PAYLOAD_DIR/resources/embedding-models" + +find "$PAYLOAD_DIR" -type f \( \ + -name '*.d.ts' -o \ + -name '*.d.ts.map' -o \ + -name '*.js.map' \ +\) -delete + +ARCHIVE_PATH="$OUTPUT_DIR/$ARCHIVE_NAME" +# macOS tar otherwise records Apple extended attributes as LIBARCHIVE pax +# headers, which produce thousands of warnings when GNU tar extracts on Linux. +COPYFILE_DISABLE=1 tar --no-xattrs -czf "$ARCHIVE_PATH" -C "$PAYLOAD_DIR" . + +if command -v sha256sum >/dev/null 2>&1; then + ARCHIVE_SHA256="$(sha256sum "$ARCHIVE_PATH" | awk '{print $1}')" +elif command -v shasum >/dev/null 2>&1; then + ARCHIVE_SHA256="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')" +else + echo "Neither sha256sum nor shasum is available" >&2 + exit 1 +fi +printf '%s %s\n' "$ARCHIVE_SHA256" "$ARCHIVE_NAME" > "$ARCHIVE_PATH.sha256" +cp "$REPO_ROOT/scripts/install.sh" "$OUTPUT_DIR/install.sh" + +printf 'Linux CLI archive: %s\n' "$ARCHIVE_PATH" +printf 'SHA-256: %s\n' "$ARCHIVE_PATH.sha256" diff --git a/tests/linux-cli-packaging.test.mjs b/tests/linux-cli-packaging.test.mjs new file mode 100644 index 000000000..25f8d48ae --- /dev/null +++ b/tests/linux-cli-packaging.test.mjs @@ -0,0 +1,465 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const installerPath = path.join(repoRoot, "scripts", "install.sh"); +const builderPath = path.join(repoRoot, "scripts", "internal", "linux", "build-cli-archive.sh"); +const linuxWorkflowPath = path.join(repoRoot, ".github", "workflows", "linux-cli-installer.yml"); +const desktopReleaseWorkflowPath = path.join( + repoRoot, + ".github", + "workflows", + "github-draft-release-v2.yml", +); +const temporaryRoots = []; + +function temporaryRoot(prefix) { + const root = mkdtempSync(path.join(tmpdir(), prefix)); + temporaryRoots.push(root); + return root; +} + +function sha256(target) { + return createHash("sha256").update(readFileSync(target)).digest("hex"); +} + +function cleanNpmLifecycleEnv(overrides = {}) { + const entries = Object.entries(process.env).filter(([key]) => ( + !key.toLowerCase().startsWith("npm_") && key !== "INIT_CWD" + )); + return { ...Object.fromEntries(entries), ...overrides }; +} + +function makeInstallerFixture(root) { + const release = path.join(root, "release"); + const payload = path.join(root, "payload"); + const agent = path.join(payload, "App", "memmy-agent"); + const backend = path.join(payload, "App", "backend"); + const memory = path.join(payload, "Memory"); + const migrations = path.join(payload, "Migrations"); + const contracts = path.join(payload, "App", "backend", "local-api-contracts"); + const model = path.join( + payload, + "resources", + "embedding-models", + "Xenova", + "all-MiniLM-L6-v2", + ); + const archive = path.join(release, "memmy-agent-linux-cli.tar.gz"); + mkdirSync(path.join(agent, "dist"), { recursive: true }); + mkdirSync(path.join(backend, "dist", "src", "services"), { recursive: true }); + mkdirSync( + path.join(backend, "dist", "src", "adapters", "outbound", "skill-writer", "templates"), + { recursive: true }, + ); + mkdirSync(path.join(memory, "dist", "src", "server"), { recursive: true }); + mkdirSync(path.join(memory, "dist", "src", "cli"), { recursive: true }); + mkdirSync(path.join(migrations, "dist"), { recursive: true }); + mkdirSync(path.join(contracts, "dist"), { recursive: true }); + mkdirSync(path.join(model, "onnx"), { recursive: true }); + mkdirSync(release, { recursive: true }); + writeFileSync(path.join(payload, "package.json"), JSON.stringify({ + name: "memmy-linux-fixture", + version: "9.9.9", + private: true, + workspaces: ["Memory", "Migrations", "App/backend/local-api-contracts"], + }, null, 2)); + writeFileSync(path.join(agent, "package.json"), JSON.stringify({ + name: "memmy-agent", + version: "9.9.9", + type: "module", + bin: { memmy: "dist/main.js" }, + }, null, 2)); + writeFileSync(path.join(backend, "package.json"), JSON.stringify({ + name: "@memmy/backend", + version: "0.0.0", + type: "module", + }, null, 2)); + writeFileSync( + path.join(backend, "dist", "src", "services", "builtin-skill-target-registry.js"), + "export function createBuiltinSkillTargetRegistry() { return { get() { return undefined; } }; }\n", + ); + writeFileSync( + path.join(backend, "dist", "src", "adapters", "outbound", "skill-writer", "templates", "memmy-resume-hook.js"), + "export const fixture = true;\n", + ); + writeFileSync( + path.join(backend, "dist", "src", "adapters", "outbound", "skill-writer", "templates", "memmy-opencode-plugin.js"), + "export const fixture = true;\n", + ); + const lock = spawnSync("npm", [ + "install", + "--package-lock-only", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ], { cwd: agent, encoding: "utf8", env: cleanNpmLifecycleEnv() }); + expect(lock.status, lock.stderr).toBe(0); + writeFileSync( + path.join(agent, "dist", "main.js"), + "if (process.argv.includes('--version')) console.log('9.9.9');\n", + ); + writeFileSync(path.join(memory, "package.json"), JSON.stringify({ + name: "@memmy/memory", + version: "9.9.9", + type: "module", + }, null, 2)); + writeFileSync(path.join(migrations, "package.json"), JSON.stringify({ + name: "@memmy/migrations", + version: "0.0.0", + type: "module", + }, null, 2)); + writeFileSync(path.join(contracts, "package.json"), JSON.stringify({ + name: "@memmy/local-api-contracts", + version: "0.0.0", + type: "module", + }, null, 2)); + const rootLock = spawnSync("npm", [ + "install", + "--package-lock-only", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ], { cwd: payload, encoding: "utf8", env: cleanNpmLifecycleEnv() }); + expect(rootLock.status, rootLock.stderr).toBe(0); + writeFileSync(path.join(memory, "dist", "src", "server", "index.js"), "setInterval(() => {}, 1000);\n"); + writeFileSync(path.join(memory, "dist", "src", "cli", "index.js"), [ + "import fs from 'node:fs';", + "const args = process.argv.slice(2);", + "if (args[0] === 'init') {", + " const index = args.indexOf('--config');", + " if (index >= 0) fs.writeFileSync(args[index + 1], 'memmyMemory:\\n storage:\\n token: fixture-token\\n');", + "}", + "if (args.includes('--version')) console.log('9.9.9');", + "", + ].join("\n")); + writeFileSync(path.join(migrations, "dist", "index.js"), "export {};\n"); + writeFileSync(path.join(contracts, "dist", "index.js"), "export {};\n"); + writeFileSync(path.join(model, "config.json"), "{}\n"); + writeFileSync(path.join(model, "tokenizer.json"), "{}\n"); + writeFileSync(path.join(model, "tokenizer_config.json"), "{}\n"); + writeFileSync(path.join(model, "onnx", "model_quantized.onnx"), "fixture\n"); + const tar = spawnSync("tar", ["-czf", archive, "-C", payload, "."], { encoding: "utf8" }); + expect(tar.status, tar.stderr).toBe(0); + writeFileSync(`${archive}.sha256`, `${sha256(archive)} ${path.basename(archive)}\n`); + return release; +} + +function fakeLinuxTools(root) { + const tools = path.join(root, "tools"); + mkdirSync(tools, { recursive: true }); + const uname = path.join(tools, "uname"); + writeFileSync(uname, [ + "#!/usr/bin/env bash", + "case \"$1\" in", + " -s) printf 'Linux\\n' ;;", + " -m) printf 'x86_64\\n' ;;", + " *) printf 'Linux\\n' ;;", + "esac", + "", + ].join("\n")); + chmodSync(uname, 0o755); + const npm = path.join(tools, "npm"); + writeFileSync(npm, [ + "#!/usr/bin/env bash", + "if [ \"${MEMMY_FIXTURE_NPM_FAIL:-0}\" = \"1\" ]; then exit 42; fi", + "exit 0", + "", + ].join("\n")); + chmodSync(npm, 0o755); + const systemctl = path.join(tools, "systemctl"); + writeFileSync(systemctl, [ + "#!/usr/bin/env bash", + "if [ -n \"${MEMMY_FIXTURE_SYSTEMCTL_LOG:-}\" ]; then printf '%s\\n' \"$*\" >> \"$MEMMY_FIXTURE_SYSTEMCTL_LOG\"; fi", + "if [ \"${MEMMY_FIXTURE_SYSTEMCTL_FAIL:-0}\" = \"1\" ] && [ \"${1:-}\" = \"--user\" ] && [ \"${2:-}\" = \"daemon-reload\" ]; then exit 43; fi", + "if [ \"${1:-}\" = \"--user\" ] && [ \"${2:-}\" = \"show\" ] && [[ \"$*\" == *\"MainPID\"* ]]; then printf '%s\\n' \"$MEMMY_FIXTURE_MAIN_PID\"; exit 0; fi", + "if [ \"${1:-}\" = \"--user\" ] && [ \"${2:-}\" = \"is-enabled\" ]; then exit 1; fi", + "if [ \"${1:-}\" = \"--user\" ] && [ \"${2:-}\" = \"is-active\" ]; then exit 1; fi", + "exit 0", + "", + ].join("\n")); + chmodSync(systemctl, 0o755); + const mv = path.join(tools, "mv"); + writeFileSync(mv, [ + "#!/usr/bin/env bash", + "if [ \"${1:-}\" = \"-Tf\" ]; then", + " /bin/rm -f \"$3\"", + " exec /bin/mv -f \"$2\" \"$3\"", + "fi", + "exec /bin/mv \"$@\"", + "", + ].join("\n")); + chmodSync(mv, 0o755); + return tools; +} + +function runInstaller(home, release, tools, overrides = {}) { + return spawnSync("bash", [installerPath], { + encoding: "utf8", + env: cleanNpmLifecycleEnv({ + HOME: home, + MEMMY_VERSION: "9.9.9", + MEMMY_RELEASE_BASE_URL: pathToFileURL(release).href.replace(/\/$/, ""), + MEMMY_FIXTURE_MAIN_PID: String(process.pid), + PATH: `${tools}${path.delimiter}${process.env.PATH ?? ""}`, + ...overrides, + }), + }); +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Linux CLI package boundary", () => { + it("keeps the archive runtime-only and the installer architecture-neutral", () => { + const builder = readFileSync(builderPath, "utf8"); + const installer = readFileSync(installerPath, "utf8"); + + expect(builder).toContain("App/memmy-agent/dist/main.js"); + expect(builder).toContain("Memory/dist/src/server/index.js"); + expect(builder).toContain("Memory/dist/src/cli/index.js"); + expect(builder).toContain("builtin-skill-target-registry.js"); + expect(builder).toContain("analytics-transport.js"); + expect(builder).toContain("skill-writer"); + expect(builder).toContain("prepare-embedding-model.mjs"); + expect(builder).toContain("COPYFILE_DISABLE=1 tar --no-xattrs -czf"); + expect(builder).toContain("Migrations/dist/index.js"); + expect(builder).toContain("local-api-contracts/dist/index.js"); + expect(builder).not.toContain("App/shell/desktop"); + expect(builder).not.toContain("App/frontend/desktop"); + expect(installer).toContain('(cd "$AGENT_DIR" && npm ci --omit=dev'); + expect(installer).toContain("npm ci --omit=dev --workspace @memmy/memory"); + expect(installer).toContain('--home "$MEMMY_HOME_DIR"'); + expect(installer).toContain("--generate-token-if-missing"); + expect(installer).toContain("systemctl --user enable --now memmy-memory.service"); + expect(installer).toContain("systemctl --user restart memmy-memory.service"); + expect(installer).toContain("memmy-gateway.service did not become stable after update"); + expect(installer).toContain("memmy-gateway.service"); + expect(installer).toContain("MEMMY_LINUX_SYSTEMD_GATEWAY=1"); + expect(installer).toContain("MEMMY_GATEWAY_ENV_FILE="); + expect(installer).toContain("EnvironmentFile="); + expect(installer).toContain("gateway --config %s --workspace %s"); + expect(installer).toContain("x86_64|amd64"); + expect(installer).toContain("aarch64|arm64"); + expect(installer).toContain("Node.js 22 or newer is required"); + expect(installer).not.toMatch(/nohup|disown|pkill|killall|enable-linger/); + }); + + it("installs standalone Agent dependencies before Linux archive contract tests", () => { + const linuxWorkflow = readFileSync(linuxWorkflowPath, "utf8"); + const agentInstall = "run: npm ci --prefix App/memmy-agent"; + const contractTests = "run: npm run test:linux-cli"; + + expect(linuxWorkflow).toContain(agentInstall); + expect(linuxWorkflow.indexOf(agentInstall)).toBeLessThan( + linuxWorkflow.indexOf(contractTests), + ); + }); + + it("builds an archive with compiled CLI packages and no desktop payload", () => { + const output = temporaryRoot("memmy-linux-archive-"); + const modelSource = path.join(output, "model-source", "Xenova", "all-MiniLM-L6-v2"); + mkdirSync(path.join(modelSource, "onnx"), { recursive: true }); + for (const file of ["config.json", "tokenizer.json", "tokenizer_config.json"]) { + writeFileSync(path.join(modelSource, file), "{}\n"); + } + writeFileSync(path.join(modelSource, "onnx", "model_quantized.onnx"), "fixture\n"); + const result = spawnSync("bash", [builderPath, "--output", output], { + cwd: repoRoot, + encoding: "utf8", + env: cleanNpmLifecycleEnv({ MEMMY_EMBEDDING_MODEL_SOURCE_DIR: path.dirname(path.dirname(modelSource)) }), + }); + expect(result.status, result.stderr).toBe(0); + + const archive = path.join(output, "memmy-agent-linux-cli.tar.gz"); + const listing = spawnSync("tar", ["-tzf", archive], { encoding: "utf8" }); + expect(listing.status, listing.stderr).toBe(0); + expect(listing.stdout).toContain("App/memmy-agent/dist/main.js"); + expect(listing.stdout).toContain("Memory/dist/src/server/index.js"); + expect(listing.stdout).toContain("Memory/dist/src/cli/index.js"); + expect(listing.stdout).toContain("App/backend/dist/src/services/builtin-skill-target-registry.js"); + expect(listing.stdout).toContain("App/backend/dist/src/analytics/analytics-transport.js"); + expect(listing.stdout).toContain("App/backend/dist/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.js"); + expect(listing.stdout).toContain("App/backend/dist/src/adapters/outbound/skill-writer/templates/memmy-opencode-plugin.js"); + expect(listing.stdout).toContain("resources/embedding-models/Xenova/all-MiniLM-L6-v2/onnx/model_quantized.onnx"); + expect(listing.stdout).toContain("Migrations/dist/index.js"); + expect(listing.stdout).toContain("App/backend/local-api-contracts/dist/index.js"); + expect(listing.stdout).not.toMatch(/electron|\.dmg|\.exe|App\/frontend|App\/shell/i); + expect(listing.stdout).not.toMatch(/node_modules|App\/memmy-agent\/src\/|Memory\/src\/(?!server|cli)/); + expect(existsSync(`${archive}.sha256`)).toBe(true); + expect(existsSync(path.join(output, "install.sh"))).toBe(true); + + const extracted = path.join(output, "extracted"); + mkdirSync(extracted); + const extract = spawnSync("tar", ["-xzf", archive, "-C", extracted], { encoding: "utf8" }); + expect(extract.status, extract.stderr).toBe(0); + const installDryRun = spawnSync("npm", [ + "ci", + "--omit=dev", + "--dry-run", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ], { + cwd: path.join(extracted, "App", "memmy-agent"), + encoding: "utf8", + env: cleanNpmLifecycleEnv(), + }); + expect(installDryRun.status, installDryRun.stderr).toBe(0); + const memoryInstallDryRun = spawnSync("npm", [ + "ci", + "--omit=dev", + "--workspace", + "@memmy/memory", + "--include-workspace-root=false", + "--dry-run", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ], { + cwd: extracted, + encoding: "utf8", + env: cleanNpmLifecycleEnv(), + }); + expect(memoryInstallDryRun.status, memoryInstallDryRun.stderr).toBe(0); + + rmSync(path.join(extracted, "node_modules"), { recursive: true, force: true }); + symlinkSync(path.join(repoRoot, "node_modules"), path.join(extracted, "node_modules")); + const integrationModuleUrl = pathToFileURL(path.join( + extracted, + "App", + "backend", + "dist", + "src", + "services", + "builtin-skill-target-registry.js", + )).href; + const integrationImport = spawnSync("node", [ + "--input-type=module", + "--eval", + [ + `const module = await import(${JSON.stringify(integrationModuleUrl)});`, + `const registry = module.createBuiltinSkillTargetRegistry(${JSON.stringify(path.join(extracted, "config.yaml"))});`, + "if (typeof registry.get('codex')?.installPlugin !== 'function') process.exit(2);", + "if (typeof registry.get('opencode')?.installPlugin !== 'function') process.exit(3);", + ].join("\n"), + ], { cwd: extracted, encoding: "utf8" }); + expect(integrationImport.status, integrationImport.stderr).toBe(0); + }, 120_000); + + it("keeps Linux publication isolated from the desktop Draft Release", () => { + const linuxWorkflow = readFileSync(linuxWorkflowPath, "utf8"); + const desktopWorkflow = readFileSync(desktopReleaseWorkflowPath, "utf8"); + + expect(linuxWorkflow).toContain("ubuntu-24.04-arm"); + expect(linuxWorkflow).toContain("types: [published]"); + expect(linuxWorkflow).toContain("needs: [build, install-smoke]"); + expect(linuxWorkflow).toContain("gh release upload"); + expect(linuxWorkflow).toContain("MEMMY_SMOKE_SECRET"); + expect(linuxWorkflow).toContain("systemctl --user is-active --quiet memmy-gateway.service"); + expect(linuxWorkflow).not.toMatch(/gh release (create|delete)/); + expect(desktopWorkflow).not.toContain("memmy-agent-linux-cli"); + expect(desktopWorkflow).not.toContain("scripts/install.sh"); + }); +}); + +describe("Linux one-line installer transaction", () => { + it("installs, repeats PATH setup idempotently, and preserves the prior version on checksum failure", () => { + const root = temporaryRoot("memmy-linux-installer-"); + const home = path.join(root, "home"); + mkdirSync(home, { recursive: true }); + const release = makeInstallerFixture(root); + const tools = fakeLinuxTools(root); + const systemctlLog = path.join(root, "systemctl.log"); + + const first = runInstaller(home, release, tools, { MEMMY_FIXTURE_SYSTEMCTL_LOG: systemctlLog }); + expect(first.status, first.stderr).toBe(0); + const launcher = path.join(home, ".local", "bin", "memmy"); + expect(existsSync(launcher)).toBe(true); + const version = spawnSync(launcher, ["--version"], { encoding: "utf8" }); + expect(version.status, version.stderr).toBe(0); + expect(version.stdout.trim()).toBe("9.9.9"); + const memoryLauncher = path.join(home, ".local", "bin", "memmy-memory"); + expect(existsSync(memoryLauncher)).toBe(true); + expect(readFileSync(memoryLauncher, "utf8")).toContain("MEMMY_AGENT_INTEGRATION_ROOT="); + expect(spawnSync(memoryLauncher, ["health"], { encoding: "utf8" }).status).toBe(0); + const memoryUnit = readFileSync( + path.join(home, ".config", "systemd", "user", "memmy-memory.service"), + "utf8", + ); + expect(memoryUnit).toContain("ExecStart="); + expect(memoryUnit).toContain("/current/Memory/dist/src/server/index.js"); + expect(memoryUnit).toContain("Restart=on-failure"); + expect(memoryUnit).toContain("UMask=0077"); + expect(memoryUnit).toContain("WantedBy=default.target"); + const gatewayUnit = readFileSync( + path.join(home, ".config", "systemd", "user", "memmy-gateway.service"), + "utf8", + ); + expect(gatewayUnit).toContain("Wants=memmy-memory.service"); + expect(gatewayUnit).toContain("/current/App/memmy-agent/dist/main.js"); + expect(gatewayUnit).toContain("After=memmy-memory.service"); + expect(gatewayUnit).toContain( + `EnvironmentFile=-${path.join(home, ".memmy", "systemd", "gateway.env")}`, + ); + expect(gatewayUnit).not.toMatch(/^EnvironmentFile=.*"/m); + expect(gatewayUnit).toContain("Restart=on-failure"); + expect(gatewayUnit).toContain("StartLimitIntervalSec=60s"); + expect(gatewayUnit).toContain("StartLimitBurst=5"); + expect(gatewayUnit).toContain("UMask=0077"); + expect(readFileSync(systemctlLog, "utf8")).toContain("--user enable --now memmy-memory.service"); + expect(readFileSync(systemctlLog, "utf8")).not.toContain("--user enable --now memmy-gateway.service"); + expect(readFileSync(launcher, "utf8")).toContain("MEMMY_LINUX_SYSTEMD_GATEWAY=1"); + expect(readFileSync(launcher, "utf8")).toContain("MEMMY_GATEWAY_ENV_FILE="); + + const second = runInstaller(home, release, tools, { MEMMY_FIXTURE_SYSTEMCTL_LOG: systemctlLog }); + expect(second.status, second.stderr).toBe(0); + const profileLines = readFileSync(path.join(home, ".profile"), "utf8") + .split(/\r?\n/) + .filter((line) => line === 'export PATH="$HOME/.local/bin:$PATH"'); + expect(profileLines).toHaveLength(1); + + const current = path.join(home, ".local", "share", "memmy-agent", "current"); + const beforeFailure = readlinkSync(current); + const npmFailed = runInstaller(home, release, tools, { MEMMY_FIXTURE_NPM_FAIL: "1" }); + expect(npmFailed.status).not.toBe(0); + expect(npmFailed.stderr).toContain("Memory dependency installation failed"); + expect(readlinkSync(current)).toBe(beforeFailure); + + const configPath = path.join(home, ".memmy", "config.yaml"); + const configBeforeSystemdFailure = "sentinel: preserve-on-rollback\n"; + writeFileSync(configPath, configBeforeSystemdFailure); + const systemdFailed = runInstaller(home, release, tools, { + MEMMY_FIXTURE_SYSTEMCTL_FAIL: "1", + }); + expect(systemdFailed.status).not.toBe(0); + expect(systemdFailed.stderr).toContain("could not reload the systemd user manager"); + expect(readFileSync(configPath, "utf8")).toBe(configBeforeSystemdFailure); + expect(readlinkSync(current)).toBe(beforeFailure); + + writeFileSync( + path.join(release, "memmy-agent-linux-cli.tar.gz.sha256"), + `0000000000000000000000000000000000000000000000000000000000000000 memmy-agent-linux-cli.tar.gz\n`, + ); + const failed = runInstaller(home, release, tools); + expect(failed.status).not.toBe(0); + expect(failed.stderr).toContain("SHA-256 verification failed"); + expect(readlinkSync(current)).toBe(beforeFailure); + }, 60_000); +});