Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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.");
});
});
3 changes: 2 additions & 1 deletion test/copilot-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFileSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { resolveProject } from "../src/hooks/_project.js";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const repoRoot = resolve(__dirname, "..");
const pluginRoot = join(repoRoot, "plugin");
Expand Down Expand Up @@ -295,7 +296,7 @@ describe("Copilot hook scripts", () => {
expect(result.requests[0]?.path).toBe("/agentmemory/session/start");
expect(result.requests[0]?.body).toMatchObject({
sessionId: "copilot-session",
project: "C:\\repo",
project: resolveProject("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