Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"agentmemory": "dist/cli.mjs"
},
"scripts": {
"build": "tsdown && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/",
"build": "tsdown && node scripts/copy-assets.mjs",
"dev": "tsx src/index.ts",
"start": "node dist/cli.mjs",
"migrate": "node dist/functions/migrate.js",
Expand Down
28 changes: 28 additions & 0 deletions scripts/copy-assets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { copyFile, mkdir } from "node:fs/promises";
import { resolve, join } from "node:path";

const root = process.cwd();
const dist = resolve(root, "dist");

const optionalFiles = [
"iii-config.yaml",
"iii-config.docker.yaml",
"docker-compose.yml",
".env.example",
];

await mkdir(join(dist, "viewer"), { recursive: true });

for (const file of optionalFiles) {
try {
await copyFile(join(root, file), join(dist, file));
} catch (err) {
// These files are optional; ignore if they don't exist.
if (err.code !== "ENOENT") {
throw err;
}
}
}

await copyFile(join(root, "src/viewer/index.html"), join(dist, "viewer", "index.html"));
await copyFile(join(root, "src/viewer/favicon.svg"), join(dist, "viewer", "favicon.svg"));
8 changes: 5 additions & 3 deletions test/cli-remove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ function ctx(overrides: Partial<RemoveContext> = {}): RemoveContext {
};
}

const III_BIN = process.platform === "win32" ? "iii.exe" : "iii";

function touch(relPath: string, content = ""): void {
const full = join(sandbox, relPath);
mkdirSync(join(full, ".."), { recursive: true });
Expand Down Expand Up @@ -110,7 +112,7 @@ describe("buildRemovePlan", () => {
});

it("local-bin/iii is alwaysAsk when version does not match", () => {
touch(".local/bin/iii", "fakebin");
touch(`.local/bin/${III_BIN}`, "fakebin");
const plan = buildRemovePlan(
ctx({ localBinIiiVersion: "9.9.9" }),
{ force: false, keepData: false },
Expand All @@ -121,7 +123,7 @@ describe("buildRemovePlan", () => {
});

it("local-bin/iii is auto-fixable when version matches pinned", () => {
touch(".local/bin/iii", "fakebin");
touch(`.local/bin/${III_BIN}`, "fakebin");
const plan = buildRemovePlan(
ctx({ localBinIiiVersion: "0.11.2" }),
{ force: false, keepData: false },
Expand All @@ -139,7 +141,7 @@ describe("buildRemovePlan", () => {
});

it("private ~/.agentmemory/bin/iii is removed without prompt", () => {
touch(".agentmemory/bin/iii", "fakebin");
touch(`.agentmemory/bin/${III_BIN}`, "fakebin");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const item = plan.find((p) => p.id === "private-bin-iii")!;
expect(item).toBeDefined();
Expand Down
55 changes: 31 additions & 24 deletions test/compress-file.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolve, dirname, basename, join } from "node:path";

vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
Expand Down Expand Up @@ -87,6 +88,16 @@ function mockSdk() {
};
}

const notesPath = resolve("/tmp/notes.md");
const notesBackupPath = join(dirname(notesPath), `${basename(notesPath, ".md")}.original.md`);
const notesOriginalPath = resolve("/tmp/notes.original.md");
const notesOriginalBackupPath = join(
dirname(notesOriginalPath),
`${basename(notesOriginalPath, ".md")}.backup.md`,
);
const guidePath = resolve("/tmp/guide.md");
const guideBackupPath = join(dirname(guidePath), `${basename(guidePath, ".md")}.original.md`);

describe("mem::compress-file", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
Expand All @@ -107,9 +118,9 @@ describe("mem::compress-file", () => {
});

it("rejects symlinks", async () => {
symlinkPaths.add("/tmp/notes.md");
symlinkPaths.add(notesPath);
const result = (await sdk.trigger("mem::compress-file", {
filePath: "/tmp/notes.md",
filePath: notesPath,
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("symlink");
Expand All @@ -118,43 +129,41 @@ describe("mem::compress-file", () => {
});

it("rejects TOCTOU symlink swap at write time via O_NOFOLLOW", async () => {
const path = "/tmp/notes.md";
fileStore.set(
path,
notesPath,
"# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nContent.",
);
summarize.mockResolvedValue(
"# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nShort.",
);
openEloopPaths.add(path);
openEloopPaths.add(notesPath);

const result = (await sdk.trigger("mem::compress-file", {
filePath: path,
filePath: notesPath,
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("symlink");
});

it("rejects non-markdown paths", async () => {
const result = (await sdk.trigger("mem::compress-file", {
filePath: "/tmp/readme.txt",
filePath: resolve("/tmp/readme.txt"),
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain(".md");
});

it("returns file not found for missing paths", async () => {
const result = (await sdk.trigger("mem::compress-file", {
filePath: "/tmp/nonexistent.md",
filePath: resolve("/tmp/nonexistent.md"),
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("not found");
});

it("compresses markdown and writes .original.md backup", async () => {
const path = "/tmp/notes.md";
fileStore.set(
path,
notesPath,
"# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nSome long explanation.",
);

Expand All @@ -163,7 +172,7 @@ describe("mem::compress-file", () => {
);

const result = (await sdk.trigger("mem::compress-file", {
filePath: path,
filePath: notesPath,
})) as {
success: boolean;
backupPath: string;
Expand All @@ -172,41 +181,39 @@ describe("mem::compress-file", () => {
};

expect(result.success).toBe(true);
expect(result.backupPath).toBe("/tmp/notes.original.md");
expect(fileStore.get("/tmp/notes.original.md")).toContain("Some long explanation.");
expect(fileStore.get(path)).toContain("Short explanation.");
expect(result.backupPath).toBe(notesBackupPath);
expect(fileStore.get(notesBackupPath)).toContain("Some long explanation.");
expect(fileStore.get(notesPath)).toContain("Short explanation.");
expect(result.compressedChars).toBeLessThan(result.originalChars);
});

it("fails validation when URLs change", async () => {
const path = "/tmp/guide.md";
fileStore.set(path, "# Guide\n\nhttps://example.com\n");
fileStore.set(guidePath, "# Guide\n\nhttps://example.com\n");
summarize.mockResolvedValue("# Guide\n\nhttps://different.example.com\n");

const result = (await sdk.trigger("mem::compress-file", {
filePath: path,
filePath: guidePath,
})) as { success: boolean; error: string; details: string[] };

expect(result.success).toBe(false);
expect(result.error).toContain("validation");
expect(result.details.some((d) => d.includes("url"))).toBe(true);
expect(fileStore.get("/tmp/guide.original.md")).toBeUndefined();
expect(fileStore.get(guideBackupPath)).toBeUndefined();
});

it("uses a distinct backup path for *.original.md inputs", async () => {
const path = "/tmp/notes.original.md";
fileStore.set(path, "# Title\n\nLong original body.");
fileStore.set(notesOriginalPath, "# Title\n\nLong original body.");
summarize.mockResolvedValue("# Title\n\nShort body.");

const result = (await sdk.trigger("mem::compress-file", {
filePath: path,
filePath: notesOriginalPath,
})) as { success: boolean; backupPath: string };

expect(result.success).toBe(true);
expect(result.backupPath).toBe("/tmp/notes.original.backup.md");
expect(fileStore.get("/tmp/notes.original.backup.md")).toBe(
expect(result.backupPath).toBe(notesOriginalBackupPath);
expect(fileStore.get(notesOriginalBackupPath)).toBe(
"# Title\n\nLong original body.",
);
expect(fileStore.get(path)).toBe("# Title\n\nShort body.");
expect(fileStore.get(notesOriginalPath)).toBe("# Title\n\nShort body.");
});
});
4 changes: 2 additions & 2 deletions test/copilot-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,14 +288,14 @@ describe("Copilot hook scripts", () => {
const result = await runHook(
"scripts/session-start.mjs",
{ sessionId: "copilot-session", cwd: "C:\\repo" },
{ AGENTMEMORY_INJECT_CONTEXT: "true" },
{ AGENTMEMORY_INJECT_CONTEXT: "true", AGENTMEMORY_PROJECT_NAME: "" },
);

expect(result.stdout).toBe("remembered context");
expect(result.requests[0]?.path).toBe("/agentmemory/session/start");
expect(result.requests[0]?.body).toMatchObject({
sessionId: "copilot-session",
project: "C:\\repo",
project: process.platform === "win32" ? "repo" : "C:\\repo",
cwd: "C:\\repo",
});
});
Expand Down
27 changes: 15 additions & 12 deletions test/obsidian-export.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { tmpdir } from "node:os";
import { join } from "node:path";

vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
Expand Down Expand Up @@ -121,7 +123,7 @@ function makeSession(id: string): Session {
describe("Obsidian Export", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
const exportRoot = "/tmp/agentmemory-export-root";
const exportRoot = join(tmpdir(), "agentmemory-export-root");

beforeEach(() => {
process.env.AGENTMEMORY_EXPORT_ROOT = exportRoot;
Expand Down Expand Up @@ -164,7 +166,7 @@ describe("Obsidian Export", () => {
expect(result.exported.memories).toBe(1);

const memFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("memories/mem_001.md"),
k.includes(join("memories", "mem_001.md")),
);
expect(memFile).toBeDefined();
const content = memFile![1];
Expand All @@ -186,7 +188,7 @@ describe("Obsidian Export", () => {
expect(result.exported.lessons).toBe(1);

const lsnFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("lessons/lsn_001.md"),
k.includes(join("lessons", "lsn_001.md")),
);
expect(lsnFile).toBeDefined();
const content = lsnFile![1];
Expand All @@ -202,7 +204,7 @@ describe("Obsidian Export", () => {
await sdk.trigger("mem::obsidian-export", {});

const crysFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("crystals/crys_001.md"),
k.includes(join("crystals", "crys_001.md")),
);
expect(crysFile).toBeDefined();
expect(crysFile![1]).toContain("[[act_1]]");
Expand All @@ -222,19 +224,20 @@ describe("Obsidian Export", () => {
});

it("respects custom vaultDir", async () => {
const customVaultDir = join(exportRoot, "test-vault");
await sdk.trigger("mem::obsidian-export", {
vaultDir: "/tmp/agentmemory-export-root/test-vault",
vaultDir: customVaultDir,
});

const hasCustomPath = [...createdDirs].some((d) =>
d.startsWith("/tmp/agentmemory-export-root/test-vault"),
d.startsWith(customVaultDir),
);
expect(hasCustomPath).toBe(true);
});

it("rejects vaultDir outside the export root", async () => {
const result = (await sdk.trigger("mem::obsidian-export", {
vaultDir: "/tmp/outside-root",
vaultDir: join(tmpdir(), "outside-root"),
})) as { success: boolean; error: string };

expect(result.success).toBe(false);
Expand Down Expand Up @@ -323,7 +326,7 @@ describe("Obsidian Export", () => {
expect(result.exported.sessions).toBe(1);
expect(result.errors).toBeUndefined();
expect([...writtenFiles.keys()].some((path) => path.includes("undefined.md"))).toBe(false);
expect([...writtenFiles.keys()].some((path) => path.includes("sessions/ses_valid.md"))).toBe(true);
expect([...writtenFiles.keys()].some((path) => path.includes(join("sessions", "ses_valid.md")))).toBe(true);
});

it("tolerates malformed startedAt timestamps when sorting sessions", async () => {
Expand Down Expand Up @@ -359,7 +362,7 @@ describe("Obsidian Export", () => {
expect(result.exported.memories).toBe(1);

const memFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("memories/mem_incomplete.md"),
k.includes(join("memories", "mem_incomplete.md")),
);
expect(memFile).toBeDefined();
const content = memFile![1];
Expand Down Expand Up @@ -394,17 +397,17 @@ describe("Obsidian Export", () => {
expect(result.exported.crystals).toBe(1);

const memFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("memories/mem_no_title.md"),
k.includes(join("memories", "mem_no_title.md")),
);
expect(memFile![1]).toContain("# mem_no_title");

const lsnFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("lessons/lsn_no_content.md"),
k.includes(join("lessons", "lsn_no_content.md")),
);
expect(lsnFile![1]).toContain("# Lesson: lsn_no_content");

const crysFile = [...writtenFiles.entries()].find(([k]) =>
k.includes("crystals/crys_no_narr.md"),
k.includes(join("crystals", "crys_no_narr.md")),
);
expect(crysFile![1]).toContain("# Crystal: crys_no_narr");
});
Expand Down
12 changes: 12 additions & 0 deletions test/slots-flag-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,26 @@ import { join } from "node:path";
describe("isSlotsEnabled — reads merged env (#678)", () => {
let home: string;
let ORIG_HOME: string | undefined;
let ORIG_USERPROFILE: string | undefined;
let ORIG_FLAG: string | undefined;

beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "am-slots-flag-"));
mkdirSync(join(home, ".agentmemory"), { recursive: true });
ORIG_HOME = process.env["HOME"];
ORIG_USERPROFILE = process.env["USERPROFILE"];
ORIG_FLAG = process.env["AGENTMEMORY_SLOTS"];
process.env["HOME"] = home;
process.env["USERPROFILE"] = home;
delete process.env["AGENTMEMORY_SLOTS"];
vi.resetModules();
});

afterEach(() => {
if (ORIG_HOME !== undefined) process.env["HOME"] = ORIG_HOME;
else delete process.env["HOME"];
if (ORIG_USERPROFILE !== undefined) process.env["USERPROFILE"] = ORIG_USERPROFILE;
else delete process.env["USERPROFILE"];
if (ORIG_FLAG !== undefined) process.env["AGENTMEMORY_SLOTS"] = ORIG_FLAG;
else delete process.env["AGENTMEMORY_SLOTS"];
rmSync(home, { recursive: true, force: true });
Expand Down Expand Up @@ -60,20 +66,26 @@ describe("isSlotsEnabled — reads merged env (#678)", () => {
describe("isReflectEnabled — reads merged env (#678)", () => {
let home: string;
let ORIG_HOME: string | undefined;
let ORIG_USERPROFILE: string | undefined;
let ORIG_FLAG: string | undefined;

beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "am-reflect-flag-"));
mkdirSync(join(home, ".agentmemory"), { recursive: true });
ORIG_HOME = process.env["HOME"];
ORIG_USERPROFILE = process.env["USERPROFILE"];
ORIG_FLAG = process.env["AGENTMEMORY_REFLECT"];
process.env["HOME"] = home;
process.env["USERPROFILE"] = home;
delete process.env["AGENTMEMORY_REFLECT"];
vi.resetModules();
});

afterEach(() => {
if (ORIG_HOME !== undefined) process.env["HOME"] = ORIG_HOME;
else delete process.env["HOME"];
if (ORIG_USERPROFILE !== undefined) process.env["USERPROFILE"] = ORIG_USERPROFILE;
else delete process.env["USERPROFILE"];
if (ORIG_FLAG !== undefined) process.env["AGENTMEMORY_REFLECT"] = ORIG_FLAG;
else delete process.env["AGENTMEMORY_REFLECT"];
rmSync(home, { recursive: true, force: true });
Expand Down