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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,12 @@ New-Item -ItemType Directory -Force $HOME\.agentmemory
notepad $HOME\.agentmemory\.env
```

#### Engine config (iii-config.yaml)

On first start, agentmemory materializes `~/.agentmemory/config/iii-config.yaml` from the bundled template, rewriting the engine's data paths to absolute locations under `~/.agentmemory/data/`. This keeps the KV state and stream store in a single global location regardless of which directory the CLI is invoked from (without this, the bundled `./data/...` relative paths would land wherever the engine's working directory pointed). The subdir placement also keeps iii's parent-directory config watcher from reloading on every write to sibling files like `preferences.json` or `iii.pid`.

Edit `~/.agentmemory/config/iii-config.yaml` freely — agentmemory only writes it if it doesn't already exist. The resolution order remains: `AGENTMEMORY_III_CONFIG=<path>` env var → `<cwd>/iii-config.yaml` → `~/.agentmemory/config/iii-config.yaml` → legacy `~/.agentmemory/iii-config.yaml` → bundled.

To test with a Claude Code Pro/Max subscription instead of an API key, opt in explicitly:

```env
Expand Down
56 changes: 50 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ import {
type RemoveOptions,
} from "./cli/remove-plan.js";
import { renderSplash } from "./cli/splash.js";
import { isFirstRun, readPrefs, resetPrefs, writePrefs } from "./cli/preferences.js";
import { isFirstRun, prefsDir, readPrefs, resetPrefs, writePrefs } from "./cli/preferences.js";
import { runOnboarding } from "./cli/onboarding.js";
import { materializeUserIiiConfig } from "./cli/iii-config.js";
import { setBootVerbose } from "./logger.js";
import { VERSION } from "./version.js";

Expand Down Expand Up @@ -304,15 +305,22 @@ async function isAgentmemoryReady(): Promise<boolean> {

function findIiiConfig(): string {
// Precedence (user-overridable wins): explicit env > project cwd >
// ~/.agentmemory/ > bundled. The bundled config used to win
// unconditionally, so users hitting the observability log-feedback
// loop (#519) had no way to drop a tamer config in place without
// editing node_modules.
// ~/.agentmemory/config/ (materialized) > ~/.agentmemory/ (legacy) >
// bundled. The bundled config used to win unconditionally, so users
// hitting the observability log-feedback loop (#519) had no way to
// drop a tamer config in place without editing node_modules.
//
// ~/.agentmemory/config/iii-config.yaml is the new materialized
// location written by ensureUserIiiConfig(). It anchors data paths
// to ~/.agentmemory/data/ regardless of cwd, and the subdir keeps
// iii's parent-dir inotify watcher from reloading on every write to
// sibling files like preferences.json / iii.pid / worker.pid.
const envPath = process.env["AGENTMEMORY_III_CONFIG"];
const candidates = [
...(envPath ? [envPath] : []),
join(process.cwd(), "iii-config.yaml"),
join(homedir(), ".agentmemory", "iii-config.yaml"),
join(prefsDir(), "config", "iii-config.yaml"),
join(prefsDir(), "iii-config.yaml"),
join(__dirname, "iii-config.yaml"),
join(__dirname, "..", "iii-config.yaml"),
];
Expand All @@ -322,6 +330,27 @@ function findIiiConfig(): string {
return "";
}

// Locate the bundled iii-config.yaml that ships in the npm package.
// `dist/iii-config.yaml` lives next to dist/cli.mjs after the build
// script's `cp iii-config.yaml dist/` step; the `..` candidate covers
// a source checkout where the file sits at the repo root.
function findBundledIiiConfig(): string | null {
const candidates = [
join(__dirname, "iii-config.yaml"),
join(__dirname, "..", "iii-config.yaml"),
];
return candidates.find(existsSync) ?? null;
}

function ensureUserIiiConfig(): string | null {
return materializeUserIiiConfig({
targetDir: join(prefsDir(), "config"),
dataDir: join(prefsDir(), "data"),
findBundled: findBundledIiiConfig,
onWarn: (msg) => vlog(msg),
});
}

function whichBinary(name: string): string | null {
const cmd = IS_WINDOWS ? "where" : "which";
try {
Expand Down Expand Up @@ -763,6 +792,12 @@ function startIiiBin(iiiBin: string, configPath: string): boolean {
}

async function startEngine(): Promise<boolean> {
// Materialize ~/.agentmemory/config/iii-config.yaml on first start
// so the engine's data paths anchor to ~/.agentmemory/data/ no
// matter where the CLI was invoked from. findIiiConfig() picks up
// the materialized file via its candidate list. Errors are
// swallowed inside the helper.
ensureUserIiiConfig();
const configPath = findIiiConfig();
let iiiBin = whichBinary("iii");
vlog(`iii binary: ${iiiBin ?? "(not on PATH)"}, config: ${configPath || "(not found)"}`);
Expand Down Expand Up @@ -1908,6 +1943,15 @@ async function runInit() {
process.exit(1);
}
p.log.success(`Wrote ${target}`);

// Materialize the user-anchored iii-config alongside .env so the
// engine's data paths land in ~/.agentmemory/data/ from the very
// first start, regardless of which cwd the operator runs from.
const iiiConfigPath = ensureUserIiiConfig();
if (iiiConfigPath) {
p.log.success(`Wrote ${iiiConfigPath}`);
}

p.note(
[
"All keys are commented out by default. Uncomment the ones you want.",
Expand Down
124 changes: 124 additions & 0 deletions src/cli/iii-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// First-start materialization of the user-anchored iii-engine config.
//
// Two problems this fixes:
// 1. Bundled `iii-config.yaml` declares `file_path: ./data/...`
// (relative). When `iii --config <path>` runs with a different
// cwd, the KV state and stream store land wherever cwd points —
// users end up with `~/Tara/data`, `~/data`, etc. instead of a
// single global location. Rewriting to absolute paths under
// ~/.agentmemory/data/ makes the engine location-independent.
// 2. iii's config watcher (Rust `notify` crate, NonRecursive)
// watches the PARENT directory of the config file. With the
// config at ~/.agentmemory/iii-config.yaml, every write to
// sibling files (preferences.json, iii.pid, worker.pid,
// engine-state.json) triggers a config reload, which drops the
// worker's HTTP function registrations → 404 on every
// /agentmemory/* route → blank viewer. Putting the materialized
// config in a quiet ~/.agentmemory/config/ subdir sidesteps
// this.
//
// The implementation is intentionally small and pure: it takes the
// target dir, the data dir to anchor paths to, and a `findBundled`
// resolver — no implicit dependency on prefsDir() / __dirname. The
// thin caller in cli.ts wires those values; tests inject their own.
//
// Behavior contract:
// - Idempotent: if `<targetDir>/iii-config.yaml` already exists,
// return its path without touching the file.
// - Shape-guarded rewrite: only rewrite paths if the recognizable
// bundled markers are present. Otherwise copy verbatim — never
// silently corrupt a user-supplied template.
// - Atomic write: tmp + fsync + rename, mirroring writePrefs() in
// `./preferences.ts`. Parallel callers either both succeed (with
// identical content) or one wins; never a half-written file.
// - Best-effort: any failure (read-only $HOME, missing bundled,
// permission denied) calls onWarn and returns null. Callers
// fall through to legacy candidates and the engine still starts.

import {
closeSync,
existsSync,
fsyncSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
writeSync,
} from "node:fs";
import { join } from "node:path";

export interface MaterializeOptions {
// Where to write the materialized config, e.g. ~/.agentmemory/config
targetDir: string;
// Absolute data dir to substitute for relative `./data/` paths,
// e.g. ~/.agentmemory/data
dataDir: string;
// Resolver for the bundled template path. Returning null disables
// materialization (caller falls through to legacy candidates).
findBundled: () => string | null;
// Best-effort log sink for non-fatal failures.
onWarn?: (msg: string) => void;
}

const STATE_STORE_MARKER = "file_path: ./data/state_store.db";
const STREAM_STORE_MARKER = "file_path: ./data/stream_store";
const RELATIVE_DATA_PATH = /file_path:\s*\.\/data\//g;

export function materializeUserIiiConfig(
opts: MaterializeOptions,
): string | null {
const { targetDir, dataDir, findBundled, onWarn } = opts;
const warn = onWarn ?? (() => {});

try {
const userPath = join(targetDir, "iii-config.yaml");
if (existsSync(userPath)) return userPath;

const bundled = findBundled();
if (!bundled) return null;

const raw = readFileSync(bundled, "utf-8");

const recognized =
raw.includes(STATE_STORE_MARKER) && raw.includes(STREAM_STORE_MARKER);

const body = recognized
? raw.replace(RELATIVE_DATA_PATH, `file_path: ${dataDir}/`)
: raw;
if (!recognized) {
warn(
`materializeUserIiiConfig: unrecognized bundled shape at ${bundled}, copying verbatim`,
);
}

const header =
`# Generated by agentmemory on first start. Edit freely;\n` +
`# agentmemory will not overwrite unless this file is deleted.\n` +
`# Source: ${bundled}\n`;

mkdirSync(targetDir, { recursive: true });
const tmp = `${userPath}.${process.pid}.tmp`;
const fd = openSync(tmp, "w", 0o600);
try {
writeSync(fd, header + body);
try {
fsyncSync(fd);
} catch {
// fsync isn't available on every filesystem (some Docker
// overlays). The rename below is still atomic; we just
// can't guarantee durability against a power loss.
}
} finally {
closeSync(fd);
}
renameSync(tmp, userPath);
return userPath;
} catch (err) {
warn(
`materializeUserIiiConfig: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
Loading