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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,9 @@ function createFakeAgentSourceService(): AgentSourceService {
async uninstallPlugin() {
return undefined;
},
async isInstalledHookCurrent() {
return true;
},
async detectMemoryPluginConflicts() {
return [];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { createNodeHookCommand } from "../hook-command.js";
import { isInstalledMemmyHookCurrent, memmyHookRevisionField } from "../hook-revision.js";
import { readMemmyMemoryServiceConfig } from "../memmy-runtime-config.js";
import { removeMemmySkillDirectory, replaceMemmySkillDirectory } from "../skill-directory.js";
import { renderMemmyPluginSkillManifest } from "../templates/memmy-plugin.js";
Expand Down Expand Up @@ -85,6 +86,19 @@ export function createClaudeCodeSkillTarget(deps: CreateClaudeCodeSkillTargetDep
return (await readTextFile(join(root, TARGET_FILE_NAME))).includes(START_MARKER);
},

async isInstalledHookCurrent() {
const root = await this.resolveRootDirectory();
if (!root) return false;
const hookDirectory = join(root, HOOK_DIRECTORY_NAME);
return isInstalledMemmyHookCurrent({
source: CLAUDE_CODE_TARGET_ID,
mode: "claude-code",
hookScriptPath: join(hookDirectory, HOOK_SCRIPT_FILE_NAME),
bridgePath: join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME),
configPath: join(hookDirectory, HOOK_CONFIG_FILE_NAME)
});
},

async installPlugin(_targetId) {
const root = await this.resolveRootDirectory();
if (!root) {
Expand All @@ -93,11 +107,13 @@ export function createClaudeCodeSkillTarget(deps: CreateClaudeCodeSkillTargetDep

const hookDirectory = join(root, HOOK_DIRECTORY_NAME);
const hookScriptPath = join(hookDirectory, HOOK_SCRIPT_FILE_NAME);
const hookConfigPath = join(hookDirectory, HOOK_CONFIG_FILE_NAME);
const hookConfig = {
memmy_config_path: memmyConfigPath,
...(await readMemmyMemoryServiceConfig(memmyConfigPath))
};
await mkdir(hookDirectory, { recursive: true });
await writeFileAtomically(
join(hookDirectory, HOOK_CONFIG_FILE_NAME),
`${JSON.stringify({ memmy_config_path: memmyConfigPath, ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) }, null, 2)}\n`
);
await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`);
await writeFileAtomically(
hookScriptPath,
renderMemmyResumeHookScript({ source: CLAUDE_CODE_TARGET_ID, mode: "claude-code" })
Expand All @@ -118,6 +134,10 @@ export function createClaudeCodeSkillTarget(deps: CreateClaudeCodeSkillTargetDep
upsertMarkerBlock(await readTextFile(filePath), renderMemmySkillBootstrapManifest(manifest))
);
await replaceMemmySkillDirectory(root, manifest);
await writeFileAtomically(
hookConfigPath,
`${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CLAUDE_CODE_TARGET_ID, "claude-code")) }, null, 2)}\n`
);
},

async uninstallPlugin(_targetId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,30 @@ describe("claude code skill target", () => {
}
});

it("marks a hook without a stored revision as outdated and refreshes it in place", async () => {
const { rootDirectory, memmyConfigPath } = createFixture();
const target = createClaudeCodeSkillTarget({ rootDirectory, memmyConfigPath });
const configPath = join(rootDirectory, "hooks", "memmy-memory-config.json");

await target.installPlugin?.("claude_code");
const installed = JSON.parse(readFileSync(configPath, "utf8")) as { hook_revision?: string };
expect(installed.hook_revision).toEqual(expect.stringMatching(/^[a-f0-9]{64}$/));
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true);

writeFileSync(configPath, `${JSON.stringify({ ...installed, hook_revision: "stale" }, null, 2)}\n`, "utf8");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false);

writeFileSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs"), "old hook\n", "utf8");
writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false);
rmSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs"));
writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false);

await target.installPlugin?.("claude_code");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true);
});

it("installs a UserPromptSubmit hook that blocks resume commands with top L1 candidates", async () => {
const { rootDirectory, memmyConfigPath } = createFixture();
let requestBody: Record<string, unknown> | undefined;
Expand Down
28 changes: 24 additions & 4 deletions App/backend/src/adapters/outbound/skill-writer/codex/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { resolveCodexHomeDirectory } from "../../agent-paths.js";
import { createNodeHookCommand } from "../hook-command.js";
import { isInstalledMemmyHookCurrent, memmyHookRevisionField } from "../hook-revision.js";
import { readMemmyMemoryServiceConfig } from "../memmy-runtime-config.js";
import {
removeMemmyResumeSkillDirectory,
Expand Down Expand Up @@ -91,6 +92,19 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S
return (await readTextFile(join(root, TARGET_FILE_NAME))).includes(START_MARKER);
},

async isInstalledHookCurrent() {
const root = await this.resolveRootDirectory();
if (!root) return false;
const hookDirectory = join(root, HOOK_DIRECTORY_NAME);
return isInstalledMemmyHookCurrent({
source: CODEX_TARGET_ID,
mode: "codex",
hookScriptPath: join(hookDirectory, HOOK_SCRIPT_FILE_NAME),
bridgePath: join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME),
configPath: join(hookDirectory, HOOK_CONFIG_FILE_NAME)
});
},

async installPlugin(_targetId) {
const root = await this.resolveRootDirectory();
if (!root) {
Expand All @@ -99,11 +113,13 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S

const hookDirectory = join(root, HOOK_DIRECTORY_NAME);
const hookScriptPath = join(hookDirectory, HOOK_SCRIPT_FILE_NAME);
const hookConfigPath = join(hookDirectory, HOOK_CONFIG_FILE_NAME);
const hookConfig = {
memmy_config_path: memmyConfigPath,
...(await readMemmyMemoryServiceConfig(memmyConfigPath))
};
await mkdir(hookDirectory, { recursive: true });
await writeFileAtomically(
join(hookDirectory, HOOK_CONFIG_FILE_NAME),
`${JSON.stringify({ memmy_config_path: memmyConfigPath, ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) }, null, 2)}\n`
);
await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`);
await writeFileAtomically(hookScriptPath, renderMemmyResumeHookScript({ source: CODEX_TARGET_ID, mode: "codex" }));
await writeFileAtomically(
join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME),
Expand All @@ -127,6 +143,10 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S
hooksFilePath,
hookCommand
});
await writeFileAtomically(
hookConfigPath,
`${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CODEX_TARGET_ID, "codex")) }, null, 2)}\n`
);
},

async uninstallPlugin(_targetId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,27 @@ describe("codex skill target", () => {
}
});

it("marks a hook without a stored revision as outdated and refreshes it in place", async () => {
const { rootDirectory, memmyConfigPath } = createFixture();
const target = createCodexSkillTarget({ rootDirectory, memmyConfigPath, trustHooks: noOpTrustHooks });
const configPath = join(rootDirectory, "hooks", "memmy-memory-config.json");

await target.installPlugin?.("codex");
const installed = JSON.parse(readFileSync(configPath, "utf8")) as { hook_revision?: string };
expect(installed.hook_revision).toEqual(expect.stringMatching(/^[a-f0-9]{64}$/));
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true);

writeFileSync(configPath, `${JSON.stringify({ ...installed, hook_revision: "stale" }, null, 2)}\n`, "utf8");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false);

writeFileSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs"), "old hook\n", "utf8");
writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false);

await target.installPlugin?.("codex");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true);
});

it("persists trust for the installed user-level hooks before installation completes", async () => {
const { rootDirectory, memmyConfigPath } = createFixture();
let trustOptions: TrustMemmyCodexHooksOptions | undefined;
Expand Down
26 changes: 22 additions & 4 deletions App/backend/src/adapters/outbound/skill-writer/cursor/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { createNodeHookCommand } from "../hook-command.js";
import { isInstalledMemmyHookCurrent, memmyHookRevisionField } from "../hook-revision.js";
import { readMemmyMemoryServiceConfig } from "../memmy-runtime-config.js";
import {
removeMemmyResumeSkillDirectory,
Expand Down Expand Up @@ -59,16 +60,29 @@ export function createCursorSkillTarget(deps: CreateCursorSkillTargetDeps = {}):
return (await readTextFile(join(cursorRootDirectory, "skills", "memmy-memory", "SKILL.md"))).includes("name: memmy-memory");
},

async isInstalledHookCurrent() {
const hookDirectory = join(cursorRootDirectory, HOOK_DIRECTORY_NAME);
return isInstalledMemmyHookCurrent({
source: CURSOR_TARGET_ID,
mode: "cursor",
hookScriptPath: join(hookDirectory, HOOK_SCRIPT_FILE_NAME),
bridgePath: join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME),
configPath: join(hookDirectory, HOOK_CONFIG_FILE_NAME)
});
},

async installPlugin(_targetId) {
await mkdir(cursorRootDirectory, { recursive: true });

const hookDirectory = join(cursorRootDirectory, HOOK_DIRECTORY_NAME);
const hookScriptPath = join(hookDirectory, HOOK_SCRIPT_FILE_NAME);
const hookConfigPath = join(hookDirectory, HOOK_CONFIG_FILE_NAME);
const hookConfig = {
memmy_config_path: memmyConfigPath,
...(await readMemmyMemoryServiceConfig(memmyConfigPath))
};
await mkdir(hookDirectory, { recursive: true });
await writeFileAtomically(
join(hookDirectory, HOOK_CONFIG_FILE_NAME),
`${JSON.stringify({ memmy_config_path: memmyConfigPath, ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) }, null, 2)}\n`
);
await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`);
await writeFileAtomically(
hookScriptPath,
renderMemmyResumeHookScript({ source: CURSOR_TARGET_ID, mode: "cursor" })
Expand All @@ -83,6 +97,10 @@ export function createCursorSkillTarget(deps: CreateCursorSkillTargetDeps = {}):
const manifest = renderMemmyPluginSkillManifest(_targetId);
await replaceMemmySkillDirectory(cursorRootDirectory, manifest);
await replaceMemmyResumeSkillDirectory(cursorRootDirectory, CURSOR_TARGET_ID);
await writeFileAtomically(
hookConfigPath,
`${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CURSOR_TARGET_ID, "cursor")) }, null, 2)}\n`
);
},

async uninstallPlugin(_targetId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,31 @@ describe("cursor skill target", () => {
}
});

it("marks a hook without a stored revision as outdated and refreshes it in place", async () => {
const { rootDirectory, memmyConfigPath } = createFixture();
const target = createCursorSkillTarget({ rootDirectory, memmyConfigPath });
const configPath = join(rootDirectory, "hooks", "memmy-memory-config.json");

await target.installPlugin?.("cursor");
const installed = JSON.parse(readFileSync(configPath, "utf8")) as { hook_revision?: string };
expect(installed.hook_revision).toEqual(expect.stringMatching(/^[a-f0-9]{64}$/));
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true);

writeFileSync(configPath, `${JSON.stringify({ ...installed, hook_revision: "stale" }, null, 2)}\n`, "utf8");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false);

writeFileSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs"), "old hook\n", "utf8");
writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false);

delete installed.hook_revision;
writeFileSync(configPath, `${JSON.stringify(installed, null, 2)}\n`, "utf8");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(false);

await target.installPlugin?.("cursor");
await expect(target.isInstalledHookCurrent?.()).resolves.toBe(true);
});

it("installs a resume Skill and reads the current memmyMemory storage instead of legacy storage", async () => {
const { rootDirectory, memmyConfigPath } = createFixture();
let requestBody: Record<string, unknown> | undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/** Hook revision tests. */
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { currentMemmyHookRevision, hookRevision, isInstalledMemmyHookCurrent } from "./hook-revision.js";

let tempDir: string | undefined;

afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = undefined;
}
});

describe("hook revision", () => {
it("changes when the hook script or workspace bridge changes", () => {
const current = hookRevision("hook script", "workspace bridge");

expect(hookRevision("hook script", "workspace bridge")).toBe(current);
expect(hookRevision("updated hook script", "workspace bridge")).not.toBe(current);
expect(hookRevision("hook script", "updated workspace bridge")).not.toBe(current);
});

it("treats a current fingerprint with leftover hook files as outdated", async () => {
tempDir = mkdtempSync(join(tmpdir(), "memmy-hook-revision-"));
const revision = await currentMemmyHookRevision("cursor", "cursor");
const hookScriptPath = join(tempDir, "memmy-resume-hook.mjs");
const bridgePath = join(tempDir, "memmy-workspace-bridge.mjs");
const configPath = join(tempDir, "memmy-memory-config.json");
writeFileSync(hookScriptPath, "old hook\n", "utf8");
writeFileSync(bridgePath, "old bridge\n", "utf8");
writeFileSync(configPath, `${JSON.stringify({ hook_revision: revision }, null, 2)}\n`, "utf8");

await expect(isInstalledMemmyHookCurrent({
source: "cursor",
mode: "cursor",
hookScriptPath,
bridgePath,
configPath
})).resolves.toBe(false);
});
});
Loading
Loading