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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ Then refresh the viewer: the dashboard fills with browsable sessions, memory, an

- **Codex sessions (local):** the daemon scans your Codex session directories (`~/.codex/sessions` and `~/.codex/archived_sessions`) on startup and on an interval. Toggle with `AGENTMEMORY_CODEX_AUTOSCAN=false`; tune the cadence with `AGENTMEMORY_CODEX_SCAN_INTERVAL_MS` (default 5 min). You can also import a transcript on demand from a source checkout with `node dist/cli.mjs import-jsonl <path>` (installed package binary: `agentmemory-lab import-jsonl <path>`).
- **Browser AI conversations:** load the browser extension under [`browser-extension/`](browser-extension/); it captures supported AI sites and posts them to the local daemon, which extracts todos into the same queue.
- **LangExtract extraction (optional):** install Python deps with `python3 -m pip install -r requirements-langextract.txt` (add `socksio` if your network uses a SOCKS proxy). Configure it during first-run setup (`node dist/cli.mjs --reset` from source, or `agentmemory-lab --reset` when installed), from the viewer Settings panel, or in `~/.agentmemory/.env`: `AGENTMEMORY_TODO_EXTRACTOR=langextract`, `LANGEXTRACT_PYTHON=/path/to/python`, `LANGEXTRACT_PROVIDER=openai`, `LANGEXTRACT_MODEL=deepseek/deepseek-v4-flash`, `LANGEXTRACT_BASE_URL=https://api.novita.ai/openai/v1`, `LANGEXTRACT_API_KEY=<runtime secret>`, and optionally `AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS=120000`. Trigger it from the To-Do tab or with `POST /agentmemory/todo-extract/generate`.
- **LangExtract extraction (optional):** install Python deps with `python3 -m pip install -r requirements-langextract.txt` (includes SOCKS proxy support). Configure it during first-run setup (`node dist/cli.mjs --reset` from source, or `agentmemory-lab --reset` when installed), from the viewer Settings panel, or in `~/.agentmemory/.env`: `AGENTMEMORY_TODO_EXTRACTOR=langextract`, `LANGEXTRACT_PYTHON=/path/to/python`, `LANGEXTRACT_PROVIDER=openai`, `LANGEXTRACT_MODEL=deepseek/deepseek-v4-flash`, `LANGEXTRACT_BASE_URL=https://api.novita.ai/openai/v1`, `LANGEXTRACT_API_KEY=<runtime secret>`, and optionally `AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS=120000`. Trigger it from the To-Do tab or with `POST /agentmemory/todo-extract/generate`.
- **Isolated data roots:** set `AGENTMEMORY_HOME=/path/to/data` to keep AI Todo runtime files (`.env`, preferences, pidfiles, snapshots) separate from your normal `~/.agentmemory`. This does not change the Codex session source; local Codex history is still read from `~/.codex/sessions` and `~/.codex/archived_sessions`.
- **First-run setup:** the first-run CLI model choice configures To-Do extraction — it seeds the `LANGEXTRACT_*` settings above for the model you pick (or keeps the rules extractor if you skip). The legacy memory compression/consolidation/embeddings provider is an advanced `.env`-only setting.

Everything stays on your machine — see [Privacy](#privacy).
Expand Down
1 change: 1 addition & 0 deletions requirements-langextract.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
langextract[openai]
socksio
16 changes: 8 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { isFirstRun, readPrefs, resetPrefs, writePrefs } from "./cli/preferences
import { runOnboarding } from "./cli/onboarding.js";
import { setBootVerbose } from "./logger.js";
import { VERSION } from "./version.js";
import { getTodoExtractorUserConfig, getUserEnvPath, writeUserEnv, WRITABLE_TODO_EXTRACT_KEYS } from "./config.js";
import { getAgentMemoryDataDir, getTodoExtractorUserConfig, getUserEnvPath, writeUserEnv, WRITABLE_TODO_EXTRACT_KEYS } from "./config.js";

const __dirname = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
Expand Down Expand Up @@ -406,11 +406,11 @@ function enforceEngineVersionPin(iiiBinPath: string | null | undefined): void {
}

function enginePidfilePath(): string {
return join(homedir(), ".agentmemory", "iii.pid");
return join(getAgentMemoryDataDir(), "iii.pid");
}

function engineStatePath(): string {
return join(homedir(), ".agentmemory", "engine-state.json");
return join(getAgentMemoryDataDir(), "engine-state.json");
}

type EngineState =
Expand Down Expand Up @@ -451,7 +451,7 @@ function clearEnginePidfile(): void {
// engine and shows up as a duplicate registration. We write the worker
// pid from src/index.ts on boot so stop can find and reap it.
function workerPidfilePath(): string {
return join(homedir(), ".agentmemory", "worker.pid");
return join(getAgentMemoryDataDir(), "worker.pid");
}

function readWorkerPidfile(): number | null {
Expand Down Expand Up @@ -1364,7 +1364,7 @@ function buildDoctorContext(): DoctorContext {
return {
baseUrl: getBaseUrl(),
viewerUrl: getViewerUrl(),
envPath: join(homedir(), ".agentmemory", ".env"),
envPath: getUserEnvPath(),
pidfilePath: enginePidfilePath(),
enginePath: engineStatePath(),
pinnedVersion: IIPINNED_VERSION,
Expand All @@ -1373,11 +1373,11 @@ function buildDoctorContext(): DoctorContext {

function buildDoctorEffects(): DoctorEffects {
return {
envFileExists: () => existsSync(join(homedir(), ".agentmemory", ".env")),
envFileExists: () => existsSync(getUserEnvPath()),
readEnvFile: () => {
try {
return parseEnvFile(
readFileSync(join(homedir(), ".agentmemory", ".env"), "utf-8"),
readFileSync(getUserEnvPath(), "utf-8"),
);
} catch {
return {};
Expand Down Expand Up @@ -1939,7 +1939,7 @@ function findEnvExample(): string | null {

async function runInit() {
p.intro("agentmemory-lab init");
const target = join(homedir(), ".agentmemory", ".env");
const target = getUserEnvPath();
const template = findEnvExample();
if (!template) {
p.log.error(
Expand Down
16 changes: 6 additions & 10 deletions src/cli/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,22 @@
// rules extractor (no LLM key needed). The legacy memory-compression
// provider is NOT asked here — it's an advanced `.env`-only setting.
//
// We then write `~/.agentmemory/preferences.json` and ensure
// `~/.agentmemory/.env` exists (seeding the chosen extractor model's
// `LANGEXTRACT_*` defaults). The user adds `LANGEXTRACT_API_KEY` after.
// We then write preferences and `.env` under `~/.agentmemory` by default
// (or `AGENTMEMORY_HOME` when set), seeding the chosen extractor model's
// `LANGEXTRACT_*` defaults. The user adds `LANGEXTRACT_API_KEY` after.

import { copyFile, mkdir } from "node:fs/promises";
import { constants as fsConstants, existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as p from "@clack/prompts";
import { writePrefs } from "./preferences.js";
import { resolveAdapter, runAdapter } from "./connect/index.js";
import type { ConnectResult } from "./connect/types.js";
import { getAgentMemoryDataDir, getUserEnvPath } from "../config.js";

const __dirname = dirname(fileURLToPath(import.meta.url));

function homeDir(): string {
return process.env["HOME"] || process.env["USERPROFILE"] || homedir();
}

// Native plugin row — these agents ship an agentmemory plugin or
// first-party integration. Glyphs match SkillKit's published set
// where they overlap; the rest fall back to the generic `◇`.
Expand Down Expand Up @@ -139,7 +135,7 @@ function findEnvExample(): string | null {
}

async function seedEnvFile(): Promise<string | null> {
const target = join(homeDir(), ".agentmemory", ".env");
const target = getUserEnvPath();
const dir = dirname(target);
await mkdir(dir, { recursive: true });

Expand Down Expand Up @@ -275,7 +271,7 @@ export async function runOnboarding(): Promise<OnboardingResult> {
firstRunAt: new Date().toISOString(),
});

const prefsLocation = join(homeDir(), ".agentmemory", "preferences.json");
const prefsLocation = join(getAgentMemoryDataDir(), "preferences.json");
const lines = [`✓ Saved preferences to ${prefsLocation}`];
if (envPath) {
lines.push(`✓ Wrote ${envPath}`);
Expand Down
12 changes: 6 additions & 6 deletions src/cli/preferences.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// JSON-backed CLI preferences.
//
// Lives at `~/.agentmemory/preferences.json`. The agentmemory daemon
// already owns `~/.agentmemory/.env`, `iii.pid`, `engine-state.json` —
// adding one more sibling here keeps the install-state surface in one
// place.
// Lives at `~/.agentmemory/preferences.json` by default, or under
// `AGENTMEMORY_HOME` when set. The agentmemory daemon already owns
// `.env`, `iii.pid`, `engine-state.json` there — adding one more sibling
// keeps the install-state surface in one place.
//
// All functions are synchronous, mirroring the pidfile / engine-state
// helpers in src/cli.ts. We never throw: read failures collapse to
Expand All @@ -26,8 +26,8 @@ import {
unlinkSync,
writeSync,
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { getAgentMemoryDataDir } from "../config.js";

export interface Prefs {
schemaVersion: 1;
Expand Down Expand Up @@ -72,7 +72,7 @@ const DEFAULTS: Prefs = {
};

export function prefsDir(): string {
return join(homedir(), ".agentmemory");
return getAgentMemoryDataDir();
}

export function prefsPath(): string {
Expand Down
10 changes: 7 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ function safeParseInt(value: string | undefined, fallback: number): number {
return Number.isNaN(parsed) ? fallback : parsed;
}

const DATA_DIR = join(homedir(), ".agentmemory");
export function getAgentMemoryDataDir(): string {
return process.env["AGENTMEMORY_HOME"] || join(homedir(), ".agentmemory");
}

const DATA_DIR = getAgentMemoryDataDir();
const ENV_FILE = join(DATA_DIR, ".env");
export const DEFAULT_LANGEXTRACT_MODEL = "deepseek/deepseek-v4-flash";
export const DEFAULT_LANGEXTRACT_PROVIDER = "openai";
Expand Down Expand Up @@ -442,7 +446,7 @@ export function loadSnapshotConfig(): {
return {
enabled: env["SNAPSHOT_ENABLED"] === "true",
interval: safeParseInt(env["SNAPSHOT_INTERVAL"], 3600),
dir: env["SNAPSHOT_DIR"] || join(homedir(), ".agentmemory", "snapshots"),
dir: env["SNAPSHOT_DIR"] || join(getAgentMemoryDataDir(), "snapshots"),
};
}

Expand Down Expand Up @@ -515,7 +519,7 @@ export function getStandalonePersistPath(): string {
const env = getMergedEnv();
return (
env["STANDALONE_PERSIST_PATH"] ||
join(homedir(), ".agentmemory", "standalone.json")
join(getAgentMemoryDataDir(), "standalone.json")
);
}

Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isDropStaleIndexEnabled,
isLarkReplyLoopEnabled,
getLarkConfig,
getAgentMemoryDataDir,
} from "./config.js";
import {
createProvider,
Expand Down Expand Up @@ -109,7 +110,6 @@ import { VERSION } from "./version.js";
import { bootLog } from "./logger.js";
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import { dirname, join } from "node:path";
import { homedir } from "node:os";

// #640 + #474: the worker process (this file) is spawned by iii-exec
// inside the engine. When `agentmemory-lab stop` kills only the engine pid,
Expand All @@ -118,7 +118,7 @@ import { homedir } from "node:os";
// a duplicate worker. Write the worker pid alongside iii.pid so
// `agentmemory-lab stop` can reap us too.
function workerPidfilePath(): string {
return join(homedir(), ".agentmemory", "worker.pid");
return join(getAgentMemoryDataDir(), "worker.pid");
}
function writeWorkerPidfile(): void {
try {
Expand Down
64 changes: 63 additions & 1 deletion src/viewer/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,19 @@ import { renderViewerDocument } from "./document.js";
import type { Action, CompressedObservation, Memory, ReviewQueueItem, Session } from "../types.js";
import { KV, fingerprintId } from "../state/schema.js";
import { generateTodosFromSessions, refreshTodoAction, updateChangedTodoCards } from "../functions/todo-extract.js";
import { getTodoExtractorUserConfig, getUserEnvPath, writeUserEnv, WRITABLE_TODO_EXTRACT_KEYS } from "../config.js";
import {
detectEmbeddingProvider,
detectLlmProviderKind,
getTodoExtractorUserConfig,
getUserEnvPath,
isAutoCompressEnabled,
isConsolidationEnabled,
isContextInjectionEnabled,
isGraphExtractionEnabled,
writeUserEnv,
WRITABLE_TODO_EXTRACT_KEYS,
} from "../config.js";
import { VERSION } from "../version.js";

// Self-host the viewer favicon at /favicon.svg instead of an inline
// data: URI so the viewer CSP can stay tight at `img-src 'self'`.
Expand Down Expand Up @@ -966,6 +978,41 @@ async function handleReviewFallback(
return true;
}

async function handleInboxFallback(
req: IncomingMessage,
res: ServerResponse,
method: string,
qs: string,
kv: ViewerKv,
): Promise<boolean> {
if (method !== "GET") return false;
const params = parseViewerQuery(qs);
const status = params.status || "";
const kind = params.kind || "";
const limit = Math.max(1, Math.min(200, parseInt(params.limit || "50", 10) || 50));
const items = (await kv.list<Record<string, unknown>>(KV.inbox))
.filter((item) => !status || item.status === status)
.filter((item) => !kind || item.kind === kind)
.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")))
.slice(0, limit);
json(res, 200, { success: true, items }, req);
return true;
}

function viewerFlagsFallback(): Record<string, unknown> {
return {
version: VERSION,
provider: detectLlmProviderKind(),
embeddingProvider: detectEmbeddingProvider() ? "embeddings" : "none",
flags: [
{ key: "GRAPH_EXTRACTION_ENABLED", label: "Knowledge graph extraction", enabled: isGraphExtractionEnabled(), default: false },
{ key: "CONSOLIDATION_ENABLED", label: "Memory consolidation", enabled: isConsolidationEnabled(), default: false },
{ key: "AGENTMEMORY_AUTO_COMPRESS", label: "LLM-powered observation compression", enabled: isAutoCompressEnabled(), default: false },
{ key: "AGENTMEMORY_INJECT_CONTEXT", label: "In-conversation context injection", enabled: isContextInjectionEnabled(), default: false },
],
};
}

async function handleReviewApproveFallback(
req: IncomingMessage,
res: ServerResponse,
Expand Down Expand Up @@ -1350,6 +1397,11 @@ export function startViewerServer(
return;
}

if (method === "GET" && pathname === "/agentmemory/config/flags") {
json(res, 200, viewerFlagsFallback(), req);
return;
}

if (pathname === "/agentmemory/review") {
try {
if (await handleReviewFallback(req, res, method, qs, kv as ViewerKv)) return;
Expand All @@ -1360,6 +1412,16 @@ export function startViewerServer(
}
}

if (pathname === "/agentmemory/inbox") {
try {
if (await handleInboxFallback(req, res, method, qs, kv as ViewerKv)) return;
} catch (err) {
console.error(`[viewer] inbox fallback error:`, err);
json(res, 500, { error: "inbox fallback error" }, req);
return;
}
}

if (pathname === "/agentmemory/review/approve" && method === "POST") {
try {
if (await handleReviewApproveFallback(req, res, kv as ViewerKv)) return;
Expand Down
24 changes: 24 additions & 0 deletions test/cli-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ vi.mock("../src/cli/connect/index.js", () => ({
const ORIGINAL_HOME = process.env["HOME"];
const ORIGINAL_USERPROFILE = process.env["USERPROFILE"];
const ORIGINAL_CI = process.env["CI"];
const ORIGINAL_AGENTMEMORY_HOME = process.env["AGENTMEMORY_HOME"];
const stdinTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");

Expand Down Expand Up @@ -62,6 +63,7 @@ describe("cli onboarding", () => {
process.env["HOME"] = sandboxHome;
process.env["USERPROFILE"] = sandboxHome;
process.env["CI"] = "0";
delete process.env["AGENTMEMORY_HOME"];
setTTY(false);
vi.clearAllMocks();
});
Expand All @@ -74,6 +76,8 @@ describe("cli onboarding", () => {
else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE;
if (ORIGINAL_CI === undefined) delete process.env["CI"];
else process.env["CI"] = ORIGINAL_CI;
if (ORIGINAL_AGENTMEMORY_HOME === undefined) delete process.env["AGENTMEMORY_HOME"];
else process.env["AGENTMEMORY_HOME"] = ORIGINAL_AGENTMEMORY_HOME;
rmSync(sandboxHome, { recursive: true, force: true });
});

Expand Down Expand Up @@ -130,4 +134,24 @@ describe("cli onboarding", () => {
expect(lines).toContain("LANGEXTRACT_BASE_URL=https://api.novita.ai/openai/v1");
expect(lines.some((line) => line.startsWith("LANGEXTRACT_API_KEY="))).toBe(false);
});

it("uses AGENTMEMORY_HOME for first-run preferences and env without changing Codex home", async () => {
const dataHome = join(sandboxHome, "isolated-agentmemory");
process.env["AGENTMEMORY_HOME"] = dataHome;
setTTY(true);
prompts.multiselect.mockResolvedValueOnce([]);
prompts.select.mockResolvedValueOnce("novita");
const { runOnboarding } = await freshOnboarding();

await runOnboarding();

const preferencesPath = join(dataHome, "preferences.json");
const envPath = join(dataHome, ".env");
expect(existsSync(preferencesPath)).toBe(true);
expect(existsSync(envPath)).toBe(true);
expect(existsSync(join(sandboxHome, ".agentmemory", "preferences.json"))).toBe(false);
expect(activeEnvLines(readFileSync(envPath, "utf-8"))).toContain(
"LANGEXTRACT_MODEL=deepseek/deepseek-v4-flash",
);
});
});
15 changes: 15 additions & 0 deletions test/env-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from "node:path";

const ORIGINAL_HOME = process.env["HOME"];
const ORIGINAL_USERPROFILE = process.env["USERPROFILE"];
const ORIGINAL_AGENTMEMORY_HOME = process.env["AGENTMEMORY_HOME"];

let sandboxHome: string;

Expand All @@ -30,16 +31,30 @@ describe("loadEnvFile", () => {
delete process.env["GRAPH_EXTRACTION_ENABLED"];
delete process.env["TOKEN"];
delete process.env["HASHVAL"];
delete process.env["AGENTMEMORY_HOME"];
});

afterEach(() => {
if (ORIGINAL_HOME === undefined) delete process.env["HOME"];
else process.env["HOME"] = ORIGINAL_HOME;
if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"];
else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE;
if (ORIGINAL_AGENTMEMORY_HOME === undefined) delete process.env["AGENTMEMORY_HOME"];
else process.env["AGENTMEMORY_HOME"] = ORIGINAL_AGENTMEMORY_HOME;
rmSync(sandboxHome, { recursive: true, force: true });
});

it("honors AGENTMEMORY_HOME without changing the user's Codex home", async () => {
const dataHome = join(sandboxHome, "isolated-agentmemory");
process.env["AGENTMEMORY_HOME"] = dataHome;
mkdirSync(dataHome, { recursive: true });
writeFileSync(join(dataHome, ".env"), "TOKEN=from-data-home");

const cfg = await freshConfig();
expect(cfg.getUserEnvPath()).toBe(join(dataHome, ".env"));
expect(cfg.getEnvVar("TOKEN")).toBe("from-data-home");
});

it("strips trailing inline # comments on unquoted values", async () => {
writeEnv(
[
Expand Down
Loading
Loading