diff --git a/README.md b/README.md index 47d26d22..d24ca529 100644 --- a/README.md +++ b/README.md @@ -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 ` (installed package binary: `agentmemory-lab import-jsonl `). - **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=`, 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=`, 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). diff --git a/requirements-langextract.txt b/requirements-langextract.txt index 3fd9e994..08571763 100644 --- a/requirements-langextract.txt +++ b/requirements-langextract.txt @@ -1 +1,2 @@ langextract[openai] +socksio diff --git a/src/cli.ts b/src/cli.ts index aa00dd85..dc6cb5be 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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); @@ -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 = @@ -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 { @@ -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, @@ -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 {}; @@ -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( diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts index d2e35b6f..82ffe29b 100644 --- a/src/cli/onboarding.ts +++ b/src/cli/onboarding.ts @@ -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 `◇`. @@ -139,7 +135,7 @@ function findEnvExample(): string | null { } async function seedEnvFile(): Promise { - const target = join(homeDir(), ".agentmemory", ".env"); + const target = getUserEnvPath(); const dir = dirname(target); await mkdir(dir, { recursive: true }); @@ -275,7 +271,7 @@ export async function runOnboarding(): Promise { 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}`); diff --git a/src/cli/preferences.ts b/src/cli/preferences.ts index ccebd1a3..b1701955 100644 --- a/src/cli/preferences.ts +++ b/src/cli/preferences.ts @@ -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 @@ -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; @@ -72,7 +72,7 @@ const DEFAULTS: Prefs = { }; export function prefsDir(): string { - return join(homedir(), ".agentmemory"); + return getAgentMemoryDataDir(); } export function prefsPath(): string { diff --git a/src/config.ts b/src/config.ts index ac94a421..3688b666 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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"; @@ -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"), }; } @@ -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") ); } diff --git a/src/index.ts b/src/index.ts index 3aa47834..5c6fec79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { isDropStaleIndexEnabled, isLarkReplyLoopEnabled, getLarkConfig, + getAgentMemoryDataDir, } from "./config.js"; import { createProvider, @@ -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, @@ -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 { diff --git a/src/viewer/server.ts b/src/viewer/server.ts index 93a6566f..76a493a3 100644 --- a/src/viewer/server.ts +++ b/src/viewer/server.ts @@ -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'`. @@ -966,6 +978,41 @@ async function handleReviewFallback( return true; } +async function handleInboxFallback( + req: IncomingMessage, + res: ServerResponse, + method: string, + qs: string, + kv: ViewerKv, +): Promise { + 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>(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 { + 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, @@ -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; @@ -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; diff --git a/test/cli-onboarding.test.ts b/test/cli-onboarding.test.ts index b6fe74de..9cc8ba20 100644 --- a/test/cli-onboarding.test.ts +++ b/test/cli-onboarding.test.ts @@ -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"); @@ -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(); }); @@ -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 }); }); @@ -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", + ); + }); }); diff --git a/test/env-loader.test.ts b/test/env-loader.test.ts index 17ff6a8e..3c037648 100644 --- a/test/env-loader.test.ts +++ b/test/env-loader.test.ts @@ -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; @@ -30,6 +31,7 @@ describe("loadEnvFile", () => { delete process.env["GRAPH_EXTRACTION_ENABLED"]; delete process.env["TOKEN"]; delete process.env["HASHVAL"]; + delete process.env["AGENTMEMORY_HOME"]; }); afterEach(() => { @@ -37,9 +39,22 @@ describe("loadEnvFile", () => { 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( [ diff --git a/test/stop-worker-pidfile.test.ts b/test/stop-worker-pidfile.test.ts index 46ce2fc1..f4550c69 100644 --- a/test/stop-worker-pidfile.test.ts +++ b/test/stop-worker-pidfile.test.ts @@ -32,6 +32,15 @@ describe("stop reaps the worker process (#640, #474)", () => { expect(indexSrc).toMatch(/\.agentmemory["'].*worker\.pid|"worker\.pid"/); expect(cliSrc).toMatch(/\.agentmemory["'].*worker\.pid|"worker\.pid"/); }); + + it("centralizes runtime data under AGENTMEMORY_HOME when set", () => { + const configSrc = readFileSync("src/config.ts", "utf-8"); + const indexSrc = readFileSync("src/index.ts", "utf-8"); + const cliSrc = readFileSync("src/cli.ts", "utf-8"); + expect(configSrc).toMatch(/AGENTMEMORY_HOME/); + expect(indexSrc).toMatch(/getAgentMemoryDataDir\(\)/); + expect(cliSrc).toMatch(/getAgentMemoryDataDir\(\)/); + }); }); describe("new user startup guardrails", () => { @@ -43,4 +52,8 @@ describe("new user startup guardrails", () => { const source = readFileSync("src/cli.ts", "utf-8"); expect(source).toContain('["--no-update-check", "--config", configPath]'); }); + + it("installs SOCKS support for OpenAI-compatible LangExtract behind proxies", () => { + expect(readFileSync("requirements-langextract.txt", "utf-8")).toMatch(/socksio|httpx\[socks\]/); + }); }); diff --git a/test/viewer-security.test.ts b/test/viewer-security.test.ts index ee13fe0a..adbb37e9 100644 --- a/test/viewer-security.test.ts +++ b/test/viewer-security.test.ts @@ -290,6 +290,25 @@ describe("viewer request handler DNS rebinding defence (e2e)", () => { expect(JSON.parse(frontier.body).frontier[0].action.title).toBe("整理验收截图"); }); + it("serves config flags and inbox from viewer KV fallback when REST proxy misses", async () => { + const inboxItem = { + id: "inbox_1", + kind: "question", + status: "awaiting", + body: "需要确认吗?", + createdAt: "2026-06-17T12:00:00Z", + }; + const { port } = await spinUpViewer({ [KV.inbox]: { [inboxItem.id]: inboxItem } }); + + const flags = await request(port, `localhost:${port}`, "/agentmemory/config/flags"); + const inbox = await request(port, `localhost:${port}`, "/agentmemory/inbox?status=awaiting&limit=50"); + + expect(flags.status).toBe(200); + expect(JSON.parse(flags.body).flags.map((f: { key: string }) => f.key)).toContain("GRAPH_EXTRACTION_ENABLED"); + expect(inbox.status).toBe(200); + expect(JSON.parse(inbox.body).items).toHaveLength(1); + }); + it("expires stale todo extraction running status from viewer KV fallback", async () => { const previousTimeout = process.env.AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS; process.env.AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS = "1000";