diff --git a/ci/r13-test-manifest.json b/ci/r13-test-manifest.json index 853e6e20f..2be21e881 100644 --- a/ci/r13-test-manifest.json +++ b/ci/r13-test-manifest.json @@ -1,5 +1,5 @@ { - "count": 159, - "sha256": "987f48c8beda0e67f96c4b16a60c5637cca072d2bb054493561a74fbf4e984b7", - "content_sha256": "5b4e61aba030ee6139c86f29cf7c2eccda3923cb959578b1aada8e67efee6184" + "count": 162, + "sha256": "76148dfe1d533bed066bc8ab709e3c526696d409782b6dd6adf22f3a8d3c2d43", + "content_sha256": "db2d31c696d808814410f893a0f6385cb4a80aad838d67f3edba92bdc9a6826c" } diff --git a/plugin/scripts/_auth-r09nwS46.mjs b/plugin/scripts/_auth-dmt9vymH.mjs similarity index 95% rename from plugin/scripts/_auth-r09nwS46.mjs rename to plugin/scripts/_auth-dmt9vymH.mjs index 5f9fc1490..1d4238f55 100644 --- a/plugin/scripts/_auth-r09nwS46.mjs +++ b/plugin/scripts/_auth-dmt9vymH.mjs @@ -9,275 +9,6 @@ import { execFileSync } from "node:child_process"; var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports); var __require = /* @__PURE__ */ createRequire(import.meta.url); //#endregion -//#region node_modules/dotenv/lib/main.js -var require_main = /* @__PURE__ */ __commonJSMin(((exports, module) => { - const fs = __require("fs"); - const path = __require("path"); - const os = __require("os"); - const crypto = __require("crypto"); - const TIPS = [ - "◈ encrypted .env [www.dotenvx.com]", - "◈ secrets for agents [www.dotenvx.com]", - "⌁ auth for agents [www.vestauth.com]", - "⌘ custom filepath { path: '/custom/path/.env' }", - "⌘ enable debugging { debug: true }", - "⌘ override existing { override: true }", - "⌘ suppress logs { quiet: true }", - "⌘ multiple files { path: ['.env.local', '.env'] }" - ]; - function _getRandomTip() { - return TIPS[Math.floor(Math.random() * TIPS.length)]; - } - function parseBoolean(value) { - if (typeof value === "string") return ![ - "false", - "0", - "no", - "off", - "" - ].includes(value.toLowerCase()); - return Boolean(value); - } - function supportsAnsi() { - return process.stdout.isTTY; - } - function dim(text) { - return supportsAnsi() ? `\x1b[2m${text}\x1b[0m` : text; - } - const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm; - function parse(src) { - const obj = {}; - let lines = src.toString(); - lines = lines.replace(/\r\n?/gm, "\n"); - let match; - while ((match = LINE.exec(lines)) != null) { - const key = match[1]; - let value = match[2] || ""; - value = value.trim(); - const maybeQuote = value[0]; - value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2"); - if (maybeQuote === "\"") { - value = value.replace(/\\n/g, "\n"); - value = value.replace(/\\r/g, "\r"); - } - obj[key] = value; - } - return obj; - } - function _parseVault(options) { - options = options || {}; - const vaultPath = _vaultPath(options); - options.path = vaultPath; - const result = DotenvModule.configDotenv(options); - if (!result.parsed) { - const err = /* @__PURE__ */ new Error(`MISSING_DATA: Cannot parse ${vaultPath} for an unknown reason`); - err.code = "MISSING_DATA"; - throw err; - } - const keys = _dotenvKey(options).split(","); - const length = keys.length; - let decrypted; - for (let i = 0; i < length; i++) try { - const attrs = _instructions(result, keys[i].trim()); - decrypted = DotenvModule.decrypt(attrs.ciphertext, attrs.key); - break; - } catch (error) { - if (i + 1 >= length) throw error; - } - return DotenvModule.parse(decrypted); - } - function _warn(message) { - console.error(`⚠ ${message}`); - } - function _debug(message) { - console.log(`┆ ${message}`); - } - function _log(message) { - console.log(`◇ ${message}`); - } - function _dotenvKey(options) { - if (options && options.DOTENV_KEY && options.DOTENV_KEY.length > 0) return options.DOTENV_KEY; - if (process.env.DOTENV_KEY && process.env.DOTENV_KEY.length > 0) return process.env.DOTENV_KEY; - return ""; - } - function _instructions(result, dotenvKey) { - let uri; - try { - uri = new URL(dotenvKey); - } catch (error) { - if (error.code === "ERR_INVALID_URL") { - const err = /* @__PURE__ */ new Error("INVALID_DOTENV_KEY: Wrong format. Must be in valid uri format like dotenv://:key_1234@dotenvx.com/vault/.env.vault?environment=development"); - err.code = "INVALID_DOTENV_KEY"; - throw err; - } - throw error; - } - const key = uri.password; - if (!key) { - const err = /* @__PURE__ */ new Error("INVALID_DOTENV_KEY: Missing key part"); - err.code = "INVALID_DOTENV_KEY"; - throw err; - } - const environment = uri.searchParams.get("environment"); - if (!environment) { - const err = /* @__PURE__ */ new Error("INVALID_DOTENV_KEY: Missing environment part"); - err.code = "INVALID_DOTENV_KEY"; - throw err; - } - const environmentKey = `DOTENV_VAULT_${environment.toUpperCase()}`; - const ciphertext = result.parsed[environmentKey]; - if (!ciphertext) { - const err = /* @__PURE__ */ new Error(`NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment ${environmentKey} in your .env.vault file.`); - err.code = "NOT_FOUND_DOTENV_ENVIRONMENT"; - throw err; - } - return { - ciphertext, - key - }; - } - function _vaultPath(options) { - let possibleVaultPath = null; - if (options && options.path && options.path.length > 0) if (Array.isArray(options.path)) { - for (const filepath of options.path) if (fs.existsSync(filepath)) possibleVaultPath = filepath.endsWith(".vault") ? filepath : `${filepath}.vault`; - } else possibleVaultPath = options.path.endsWith(".vault") ? options.path : `${options.path}.vault`; - else possibleVaultPath = path.resolve(process.cwd(), ".env.vault"); - if (fs.existsSync(possibleVaultPath)) return possibleVaultPath; - return null; - } - function _resolveHome(envPath) { - return envPath[0] === "~" ? path.join(os.homedir(), envPath.slice(1)) : envPath; - } - function _configVault(options) { - const debug = parseBoolean(process.env.DOTENV_CONFIG_DEBUG || options && options.debug); - const quiet = parseBoolean(process.env.DOTENV_CONFIG_QUIET || options && options.quiet); - if (debug || !quiet) _log("loading env from encrypted .env.vault"); - const parsed = DotenvModule._parseVault(options); - let processEnv = process.env; - if (options && options.processEnv != null) processEnv = options.processEnv; - DotenvModule.populate(processEnv, parsed, options); - return { parsed }; - } - function configDotenv(options) { - const dotenvPath = path.resolve(process.cwd(), ".env"); - let encoding = "utf8"; - let processEnv = process.env; - if (options && options.processEnv != null) processEnv = options.processEnv; - let debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || options && options.debug); - let quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || options && options.quiet); - if (options && options.encoding) encoding = options.encoding; - else if (debug) _debug("no encoding is specified (UTF-8 is used by default)"); - let optionPaths = [dotenvPath]; - if (options && options.path) if (!Array.isArray(options.path)) optionPaths = [_resolveHome(options.path)]; - else { - optionPaths = []; - for (const filepath of options.path) optionPaths.push(_resolveHome(filepath)); - } - let lastError; - const parsedAll = {}; - for (const path of optionPaths) try { - const parsed = DotenvModule.parse(fs.readFileSync(path, { encoding })); - DotenvModule.populate(parsedAll, parsed, options); - } catch (e) { - if (debug) _debug(`failed to load ${path} ${e.message}`); - lastError = e; - } - const populated = DotenvModule.populate(processEnv, parsedAll, options); - debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || debug); - quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || quiet); - if (debug || !quiet) { - const keysCount = Object.keys(populated).length; - const shortPaths = []; - for (const filePath of optionPaths) try { - const relative = path.relative(process.cwd(), filePath); - shortPaths.push(relative); - } catch (e) { - if (debug) _debug(`failed to load ${filePath} ${e.message}`); - lastError = e; - } - _log(`injected env (${keysCount}) from ${shortPaths.join(",")} ${dim(`// tip: ${_getRandomTip()}`)}`); - } - if (lastError) return { - parsed: parsedAll, - error: lastError - }; - else return { parsed: parsedAll }; - } - function config(options) { - if (_dotenvKey(options).length === 0) return DotenvModule.configDotenv(options); - const vaultPath = _vaultPath(options); - if (!vaultPath) { - _warn(`you set DOTENV_KEY but you are missing a .env.vault file at ${vaultPath}`); - return DotenvModule.configDotenv(options); - } - return DotenvModule._configVault(options); - } - function decrypt(encrypted, keyStr) { - const key = Buffer.from(keyStr.slice(-64), "hex"); - let ciphertext = Buffer.from(encrypted, "base64"); - const nonce = ciphertext.subarray(0, 12); - const authTag = ciphertext.subarray(-16); - ciphertext = ciphertext.subarray(12, -16); - try { - const aesgcm = crypto.createDecipheriv("aes-256-gcm", key, nonce); - aesgcm.setAuthTag(authTag); - return `${aesgcm.update(ciphertext)}${aesgcm.final()}`; - } catch (error) { - const isRange = error instanceof RangeError; - const invalidKeyLength = error.message === "Invalid key length"; - const decryptionFailed = error.message === "Unsupported state or unable to authenticate data"; - if (isRange || invalidKeyLength) { - const err = /* @__PURE__ */ new Error("INVALID_DOTENV_KEY: It must be 64 characters long (or more)"); - err.code = "INVALID_DOTENV_KEY"; - throw err; - } else if (decryptionFailed) { - const err = /* @__PURE__ */ new Error("DECRYPTION_FAILED: Please check your DOTENV_KEY"); - err.code = "DECRYPTION_FAILED"; - throw err; - } else throw error; - } - } - function populate(processEnv, parsed, options = {}) { - const debug = Boolean(options && options.debug); - const override = Boolean(options && options.override); - const populated = {}; - if (typeof parsed !== "object") { - const err = /* @__PURE__ */ new Error("OBJECT_REQUIRED: Please check the processEnv argument being passed to populate"); - err.code = "OBJECT_REQUIRED"; - throw err; - } - for (const key of Object.keys(parsed)) if (Object.prototype.hasOwnProperty.call(processEnv, key)) { - if (override === true) { - processEnv[key] = parsed[key]; - populated[key] = parsed[key]; - } - if (debug) if (override === true) _debug(`"${key}" is already defined and WAS overwritten`); - else _debug(`"${key}" is already defined and was NOT overwritten`); - } else { - processEnv[key] = parsed[key]; - populated[key] = parsed[key]; - } - return populated; - } - const DotenvModule = { - configDotenv, - _configVault, - _parseVault, - config, - decrypt, - parse, - populate - }; - module.exports.configDotenv = DotenvModule.configDotenv; - module.exports._configVault = DotenvModule._configVault; - module.exports._parseVault = DotenvModule._parseVault; - module.exports.config = DotenvModule.config; - module.exports.decrypt = DotenvModule.decrypt; - module.exports.parse = DotenvModule.parse; - module.exports.populate = DotenvModule.populate; - module.exports = DotenvModule; -})); -//#endregion //#region node_modules/yaml/dist/nodes/identity.js var require_identity = /* @__PURE__ */ __commonJSMin(((exports) => { const ALIAS = Symbol.for("yaml.alias"); @@ -6843,8 +6574,8 @@ var require_public_api = /* @__PURE__ */ __commonJSMin(((exports) => { exports.stringify = stringify; })); //#endregion -//#region node_modules/yaml/dist/index.js -var require_dist = /* @__PURE__ */ __commonJSMin(((exports) => { +//#region src/data-dir.ts +var import_dist = (/* @__PURE__ */ __commonJSMin(((exports) => { var composer = require_composer(); var Document = require_Document(); var Schema = require_Schema(); @@ -6889,11 +6620,90 @@ var require_dist = /* @__PURE__ */ __commonJSMin(((exports) => { exports.stringify = publicApi.stringify; exports.visit = visit.visit; exports.visitAsync = visit.visitAsync; -})); +})))(); +const DATA_DIR_FLAG = "--data-dir"; +const DATA_DIR_ENV = "AGENTMEMORY_DATA_DIR"; +function readDataDirFlag(argv) { + const equalsPrefix = `${DATA_DIR_FLAG}=`; + for (const arg of argv) if (arg.startsWith(equalsPrefix)) return arg.slice(equalsPrefix.length); + const idx = argv.indexOf(DATA_DIR_FLAG); + if (idx !== -1) return argv[idx + 1]; +} +function expandHomePath(pathValue, home) { + if (pathValue === "~") return home; + if (pathValue.startsWith("~/") || pathValue.startsWith("~\\")) return join(home, pathValue.slice(2)); + return pathValue; +} +function defaultDataDir(home = homedir()) { + return join(home, ".agentmemory"); +} +function toAbsoluteDataDir(raw, cwd, home) { + const expanded = expandHomePath(raw.trim(), home); + return isAbsolute(expanded) ? expanded : resolve(cwd, expanded); +} +function resolveDataDirDetailed(options = {}) { + const argv = options.argv ?? process.argv.slice(2); + const env = options.env ?? process.env; + const cwd = options.cwd ?? process.cwd(); + const home = options.home ?? homedir(); + const flagValue = readDataDirFlag(argv); + if (flagValue !== void 0 && flagValue.trim().length > 0) return { + dir: toAbsoluteDataDir(flagValue, cwd, home), + source: "flag" + }; + const envValue = env[DATA_DIR_ENV]; + if (envValue !== void 0 && envValue.trim().length > 0) return { + dir: toAbsoluteDataDir(envValue, cwd, home), + source: "env" + }; + return { + dir: defaultDataDir(home), + source: "default" + }; +} +function resolveDataDir(options = {}) { + return resolveDataDirDetailed(options).dir; +} +//#endregion +//#region src/config.ts +let envFileCache; +function envFilePath() { + return join(resolveDataDir(), ".env"); +} +function loadEnvFile() { + if (envFileCache) return envFileCache; + const envFile = envFilePath(); + if (!existsSync(envFile)) { + envFileCache = {}; + return envFileCache; + } + const content = readFileSync(envFile, "utf-8"); + const vars = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let val = trimmed.slice(eqIdx + 1).trim(); + const quoteChar = val[0] === "\"" || val[0] === "'" ? val[0] : ""; + if (quoteChar) { + const closeIdx = val.indexOf(quoteChar, 1); + if (closeIdx !== -1) val = val.slice(1, closeIdx); + } else { + const hashIdx = val.indexOf(" #"); + if (hashIdx !== -1) val = val.slice(0, hashIdx).trim(); + } + vars[key] = val; + } + envFileCache = vars; + return envFileCache; +} +function hydrateProcessEnvFromFile() { + for (const [k, v] of Object.entries(loadEnvFile())) if (process.env[k] === void 0) process.env[k] = v; +} //#endregion //#region src/project-config.ts -var import_main = require_main(); -var import_dist = require_dist(); const PRIVACY_ORDER = { standard: 0, private: 1, @@ -7055,14 +6865,7 @@ function getUserProjectConfigPath(root) { return join(userHome(), ".agentmemory", "projects", `${projectPathHash(root)}.yaml`); } function loadAgentmemoryEnvironment() { - const envPath = join(userHome(), ".agentmemory", ".env"); - let fileEnv = {}; - if (existsSync(envPath)) try { - fileEnv = (0, import_main.parse)(readFileSync(envPath)); - } catch { - fileEnv = {}; - } - for (const [key, value] of Object.entries(fileEnv)) if (process.env[key] === void 0) process.env[key] = value; + hydrateProcessEnvFromFile(); if (!asString(process.env["AGENTMEMORY_SECRET"])) { const secretFile = asString(process.env["AGENTMEMORY_SECRET_FILE"]); if (secretFile) try { @@ -7070,10 +6873,7 @@ function loadAgentmemoryEnvironment() { if (secret) process.env["AGENTMEMORY_SECRET"] = secret; } catch {} } - return { - ...fileEnv, - ...process.env - }; + return { ...process.env }; } function envLayer(env) { return { diff --git a/plugin/scripts/_capture-CalTsfGN.mjs b/plugin/scripts/_capture-KP8LxcSz.mjs similarity index 99% rename from plugin/scripts/_capture-CalTsfGN.mjs rename to plugin/scripts/_capture-KP8LxcSz.mjs index 2f2c18dcb..1447e9b62 100644 --- a/plugin/scripts/_capture-CalTsfGN.mjs +++ b/plugin/scripts/_capture-KP8LxcSz.mjs @@ -1,4 +1,4 @@ -import { a as normalizedProjectPath, r as isProjectPathExcluded } from "./_auth-r09nwS46.mjs"; +import { a as normalizedProjectPath, r as isProjectPathExcluded } from "./_auth-dmt9vymH.mjs"; import { resolve } from "node:path"; import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; diff --git a/plugin/scripts/_delivery--9SDKTY7.mjs b/plugin/scripts/_delivery-BtSVOGKV.mjs similarity index 97% rename from plugin/scripts/_delivery--9SDKTY7.mjs rename to plugin/scripts/_delivery-BtSVOGKV.mjs index 9865974e1..41e07573a 100644 --- a/plugin/scripts/_delivery--9SDKTY7.mjs +++ b/plugin/scripts/_delivery-BtSVOGKV.mjs @@ -1,4 +1,4 @@ -import { n as projectAuthHeaders } from "./_auth-r09nwS46.mjs"; +import { n as projectAuthHeaders } from "./_auth-dmt9vymH.mjs"; //#region src/hooks/_delivery.ts var HookDeliveryError = class extends Error { retryable; diff --git a/plugin/scripts/_observe-delivery-Dp1TkllS.mjs b/plugin/scripts/_observe-delivery-Bt5vTi-C.mjs similarity index 97% rename from plugin/scripts/_observe-delivery-Dp1TkllS.mjs rename to plugin/scripts/_observe-delivery-Bt5vTi-C.mjs index 276e07389..c69a5c72c 100644 --- a/plugin/scripts/_observe-delivery-Dp1TkllS.mjs +++ b/plugin/scripts/_observe-delivery-Bt5vTi-C.mjs @@ -1,4 +1,4 @@ -import { n as projectAuthHeaders } from "./_auth-r09nwS46.mjs"; +import { n as projectAuthHeaders } from "./_auth-dmt9vymH.mjs"; //#region src/hooks/_observe-delivery.ts const MAX_ATTEMPTS = 2; const REQUEST_TIMEOUT_MS = 250; diff --git a/plugin/scripts/_project-BNYA1N7W.mjs b/plugin/scripts/_project-CXCTta9T.mjs similarity index 89% rename from plugin/scripts/_project-BNYA1N7W.mjs rename to plugin/scripts/_project-CXCTta9T.mjs index 48db13530..f2c5e16a7 100644 --- a/plugin/scripts/_project-BNYA1N7W.mjs +++ b/plugin/scripts/_project-CXCTta9T.mjs @@ -1,4 +1,4 @@ -import { i as loadAgentmemoryEnvironment, o as resolveProjectConfig } from "./_auth-r09nwS46.mjs"; +import { i as loadAgentmemoryEnvironment, o as resolveProjectConfig } from "./_auth-dmt9vymH.mjs"; //#region src/hooks/_project.ts loadAgentmemoryEnvironment(); function resolveProject(cwd) { diff --git a/plugin/scripts/notification.mjs b/plugin/scripts/notification.mjs index 951566f89..3b86034b9 100755 --- a/plugin/scripts/notification.mjs +++ b/plugin/scripts/notification.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; -import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Dp1TkllS.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; +import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Bt5vTi-C.mjs"; //#region src/hooks/notification.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/post-commit.mjs b/plugin/scripts/post-commit.mjs index d4171ff35..95848464f 100755 --- a/plugin/scripts/post-commit.mjs +++ b/plugin/scripts/post-commit.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; -import { n as reportHookDeliveryFailure, t as deliverProjectRequest } from "./_delivery--9SDKTY7.mjs"; -import { n as credentialFreeWorktreeId, r as parseCommitTransitions } from "./_capture-CalTsfGN.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; +import { n as reportHookDeliveryFailure, t as deliverProjectRequest } from "./_delivery-BtSVOGKV.mjs"; +import { n as credentialFreeWorktreeId, r as parseCommitTransitions } from "./_capture-KP8LxcSz.mjs"; import { resolve } from "node:path"; import { execFile } from "node:child_process"; import { pathToFileURL } from "node:url"; diff --git a/plugin/scripts/post-tool-failure.mjs b/plugin/scripts/post-tool-failure.mjs index ece1f1026..80ef49d35 100755 --- a/plugin/scripts/post-tool-failure.mjs +++ b/plugin/scripts/post-tool-failure.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { o as resolveProjectConfig } from "./_auth-r09nwS46.mjs"; -import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Dp1TkllS.mjs"; -import { t as captureToolEvent } from "./_capture-CalTsfGN.mjs"; +import { o as resolveProjectConfig } from "./_auth-dmt9vymH.mjs"; +import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Bt5vTi-C.mjs"; +import { t as captureToolEvent } from "./_capture-KP8LxcSz.mjs"; //#region src/hooks/post-tool-failure.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/post-tool-use.mjs b/plugin/scripts/post-tool-use.mjs index 879d8b5f6..6d0371a9f 100755 --- a/plugin/scripts/post-tool-use.mjs +++ b/plugin/scripts/post-tool-use.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { o as resolveProjectConfig } from "./_auth-r09nwS46.mjs"; -import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Dp1TkllS.mjs"; -import { t as captureToolEvent } from "./_capture-CalTsfGN.mjs"; +import { o as resolveProjectConfig } from "./_auth-dmt9vymH.mjs"; +import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Bt5vTi-C.mjs"; +import { t as captureToolEvent } from "./_capture-KP8LxcSz.mjs"; //#region src/hooks/post-tool-use.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/pre-compact.mjs b/plugin/scripts/pre-compact.mjs index fd70a17a3..fd2500d97 100755 --- a/plugin/scripts/pre-compact.mjs +++ b/plugin/scripts/pre-compact.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { i as loadAgentmemoryEnvironment, n as projectAuthHeaders, t as contextAcknowledgementSecret } from "./_auth-r09nwS46.mjs"; -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; +import { i as loadAgentmemoryEnvironment, n as projectAuthHeaders, t as contextAcknowledgementSecret } from "./_auth-dmt9vymH.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; import { createHash, createHmac, randomUUID } from "node:crypto"; //#region src/hooks/pre-compact.ts function isSdkChildContext(payload) { diff --git a/plugin/scripts/pre-tool-use.mjs b/plugin/scripts/pre-tool-use.mjs index b14b1eddb..384a1df60 100755 --- a/plugin/scripts/pre-tool-use.mjs +++ b/plugin/scripts/pre-tool-use.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { i as loadAgentmemoryEnvironment, n as projectAuthHeaders } from "./_auth-r09nwS46.mjs"; -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; +import { i as loadAgentmemoryEnvironment, n as projectAuthHeaders } from "./_auth-dmt9vymH.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; //#region src/hooks/pre-tool-use.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/prompt-submit.mjs b/plugin/scripts/prompt-submit.mjs index 67af703b5..1dcbced85 100755 --- a/plugin/scripts/prompt-submit.mjs +++ b/plugin/scripts/prompt-submit.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; -import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Dp1TkllS.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; +import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Bt5vTi-C.mjs"; //#region src/hooks/prompt-submit.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/session-end.mjs b/plugin/scripts/session-end.mjs index 10ed11e67..7eeacb6d5 100755 --- a/plugin/scripts/session-end.mjs +++ b/plugin/scripts/session-end.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; -import { n as reportHookDeliveryFailure, t as deliverProjectRequest } from "./_delivery--9SDKTY7.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; +import { n as reportHookDeliveryFailure, t as deliverProjectRequest } from "./_delivery-BtSVOGKV.mjs"; //#region src/hooks/session-end.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/session-start.mjs b/plugin/scripts/session-start.mjs index 659b7ed3b..a7be025dd 100755 --- a/plugin/scripts/session-start.mjs +++ b/plugin/scripts/session-start.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { o as resolveProjectConfig } from "./_auth-r09nwS46.mjs"; -import "./_project-BNYA1N7W.mjs"; -import { n as reportHookDeliveryFailure, t as deliverProjectRequest } from "./_delivery--9SDKTY7.mjs"; +import { o as resolveProjectConfig } from "./_auth-dmt9vymH.mjs"; +import "./_project-CXCTta9T.mjs"; +import { n as reportHookDeliveryFailure, t as deliverProjectRequest } from "./_delivery-BtSVOGKV.mjs"; //#region src/hooks/session-start.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/stop.mjs b/plugin/scripts/stop.mjs index 6af6298ab..3ffe5fcbb 100755 --- a/plugin/scripts/stop.mjs +++ b/plugin/scripts/stop.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; -import { n as reportHookDeliveryFailure, t as deliverProjectRequest } from "./_delivery--9SDKTY7.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; +import { n as reportHookDeliveryFailure, t as deliverProjectRequest } from "./_delivery-BtSVOGKV.mjs"; //#region src/hooks/stop.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/subagent-start.mjs b/plugin/scripts/subagent-start.mjs index 7bab3c28d..5295268cc 100755 --- a/plugin/scripts/subagent-start.mjs +++ b/plugin/scripts/subagent-start.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; -import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Dp1TkllS.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; +import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Bt5vTi-C.mjs"; //#region src/hooks/subagent-start.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/subagent-stop.mjs b/plugin/scripts/subagent-stop.mjs index 01329f2b1..8f6244809 100755 --- a/plugin/scripts/subagent-stop.mjs +++ b/plugin/scripts/subagent-stop.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; -import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Dp1TkllS.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; +import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Bt5vTi-C.mjs"; //#region src/hooks/subagent-stop.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/plugin/scripts/task-completed.mjs b/plugin/scripts/task-completed.mjs index 8f6f412ba..f2c22a82e 100755 --- a/plugin/scripts/task-completed.mjs +++ b/plugin/scripts/task-completed.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { t as resolveProject } from "./_project-BNYA1N7W.mjs"; -import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Dp1TkllS.mjs"; +import { t as resolveProject } from "./_project-CXCTta9T.mjs"; +import { n as reportObservationDeliveryFailure, t as deliverObservation } from "./_observe-delivery-Bt5vTi-C.mjs"; //#region src/hooks/task-completed.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; diff --git a/src/cli.ts b/src/cli.ts index 733648e95..e72470e5e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -60,6 +60,8 @@ import { renderSplash } from "./cli/splash.js"; import { isFirstRun, readPrefs, resetPrefs, writePrefs } from "./cli/preferences.js"; import { runOnboarding } from "./cli/onboarding.js"; import { setBootVerbose } from "./logger.js"; +import { hydrateProcessEnvFromFile } from "./config.js"; +import { readDataDirFlag, warnOnLegacyDataDir } from "./data-dir.js"; import { VERSION } from "./version.js"; import { getAllTools, ESSENTIAL_TOOLS } from "./mcp/tools-registry.js"; import { knownAgents } from "./cli/connect/index.js"; @@ -113,6 +115,12 @@ if (args.includes("--version") || args.includes("-V")) { process.exit(0); } +// Fold ~/.agentmemory/.env into process.env before anything reads config +// from the environment (the iii version pin, --tools/--port/--instance +// handling, engine boot). Fill-missing-only: a real process.env value — +// including one set by a CLI flag below — always wins over the file. +hydrateProcessEnvFromFile(); + // Pinned iii-engine version. The unpinned `install.iii.dev/iii/main/install.sh` // script tracks `latest`, which made every fresh agentmemory install pull // engine 0.11.6 — and 0.11.6 introduces a new sandbox-everything-via- @@ -215,6 +223,11 @@ Options: --reset Wipe ~/.agentmemory/preferences.json and re-run onboarding --tools all|core Tool visibility (default: all = ${ALL_TOOLS_COUNT} tools; core = ${CORE_TOOLS_COUNT} essentials) --no-engine Skip auto-starting iii-engine + --data-dir Relocate the data directory (default: ~/.agentmemory). + Wins over the AGENTMEMORY_DATA_DIR environment variable. + Accepts ~ expansion. A legacy ./data store in the current + directory is NOT adopted automatically — pass this flag + explicitly to use one. --port Override REST port (default: 3111). Streams (N+1), viewer (N+2), and iii engine (N+46023) auto-derive from N so a single flag relocates the whole quartet. @@ -277,6 +290,19 @@ if (instanceIdx !== -1 && args[instanceIdx + 1]) { } } +// --data-dir relocates the agentmemory data dir (default ~/.agentmemory). +// The flag wins over a pre-set AGENTMEMORY_DATA_DIR, so folding it into the +// environment here keeps one precedence story — flag > env > default — for +// everything downstream of this process (worker, engine, hooks inherit env). +const dataDirFlagValue = readDataDirFlag(args); +if (dataDirFlagValue !== undefined && dataDirFlagValue.trim().length > 0) { + process.env["AGENTMEMORY_DATA_DIR"] = dataDirFlagValue.trim(); +} +// Upstream adopts a legacy cwd ./data store automatically; this fork does +// not. When state would silently land somewhere different from an existing +// local ./data store, tell the operator how to opt in explicitly. +warnOnLegacyDataDir(); + const skipEngine = args.includes("--no-engine"); function getRestPort(): number { diff --git a/src/config.ts b/src/config.ts index 49079a263..ec0c0d09b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,7 @@ import type { ClaudeBridgeConfig, TeamConfig, } from "./types.js"; +import { resolveDataDir } from "./data-dir.js"; function safeParseInt(value: string | undefined, fallback: number): number { if (!value) return fallback; @@ -17,14 +18,28 @@ function safeParseInt(value: string | undefined, fallback: number): number { return Number.isNaN(parsed) ? fallback : parsed; } -const DATA_DIR = join(homedir(), ".agentmemory"); -const ENV_FILE = join(DATA_DIR, ".env"); - let warnPremiumModelShown = false; +// Parsed /.env, memoized for the process lifetime. getMergedEnv() +// runs on every config getter, so without this cache a single request would +// readFileSync + reparse the file dozens of times. The file is boot-static, so +// read it from disk once and reuse the result. Tests that mutate the file +// between cases reset the module (clearing this via reload) or call +// __resetEnvFileCache(). +let envFileCache: Record | undefined; + +function envFilePath(): string { + return join(resolveDataDir(), ".env"); +} + function loadEnvFile(): Record { - if (!existsSync(ENV_FILE)) return {}; - const content = readFileSync(ENV_FILE, "utf-8"); + if (envFileCache) return envFileCache; + const envFile = envFilePath(); + if (!existsSync(envFile)) { + envFileCache = {}; + return envFileCache; + } + const content = readFileSync(envFile, "utf-8"); const vars: Record = {}; for (const line of content.split("\n")) { const trimmed = line.trim(); @@ -43,7 +58,30 @@ function loadEnvFile(): Record { } vars[key] = val; } - return vars; + envFileCache = vars; + return envFileCache; +} + +// Test hook: clears the memoized .env so the next loadEnvFile() re-reads disk +// within the same module instance. vi.resetModules() reloads this module and +// resets the cache on its own; this exists for tests that mutate the file +// without a module reload. +export function __resetEnvFileCache(): void { + envFileCache = undefined; +} + +// Hydrate ~/.agentmemory/.env into process.env at boot. loadEnvFile() is +// otherwise only consumed via getMergedEnv(), which the many modules that read +// raw process.env["X"] never call — so .env-only values were silently ignored +// by them. Copy the file's vars into process.env, but only when the key is +// currently unset so a real process.env value still wins (this preserves the +// {...fileEnv, ...process.env} precedence getMergedEnv uses). This is the +// single boot-time hydration for the whole precedence chain: +// real environment > ~/.agentmemory/.env > project manifest overrides > defaults +export function hydrateProcessEnvFromFile(): void { + for (const [k, v] of Object.entries(loadEnvFile())) { + if (process.env[k] === undefined) process.env[k] = v; + } } function hasRealValue(v: string | undefined): v is string { @@ -197,7 +235,7 @@ export function loadConfig(): AgentMemoryConfig { tokenBudget: safeParseInt(env["TOKEN_BUDGET"], 2000), maxObservationsPerSession: safeParseInt(env["MAX_OBS_PER_SESSION"], 500), compressionModel: provider.model, - dataDir: DATA_DIR, + dataDir: resolveDataDir(), }; } @@ -362,7 +400,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(resolveDataDir(), "snapshots"), }; } @@ -441,6 +479,21 @@ export function getConsolidationDecayDays(): number { return safeParseInt(getMergedEnv()["CONSOLIDATION_DECAY_DAYS"], 30); } +// Cooldown between corpus consolidations triggered by session stop. The Stop +// hook fires per agent turn and posts /session/end, so without this every +// turn would kick full-corpus LLM consolidation + crystallization. Debounced +// to at most once per window via the consolidation:lastRun marker; set to 0 +// to disable the debounce (consolidate on every stop). Default 5 minutes. +const CONSOLIDATION_COOLDOWN_DEFAULT_MS = 300000; + +export function getConsolidationCooldownMs(): number { + const raw = safeParseInt( + getMergedEnv()["AGENTMEMORY_CONSOLIDATION_COOLDOWN_MS"], + CONSOLIDATION_COOLDOWN_DEFAULT_MS, + ); + return raw >= 0 ? raw : CONSOLIDATION_COOLDOWN_DEFAULT_MS; +} + export function isStandaloneMcp(): boolean { return getMergedEnv()["STANDALONE_MCP"] === "true"; } @@ -449,7 +502,7 @@ export function getStandalonePersistPath(): string { const env = getMergedEnv(); return ( env["STANDALONE_PERSIST_PATH"] || - join(homedir(), ".agentmemory", "standalone.json") + join(resolveDataDir(), "standalone.json") ); } diff --git a/src/data-dir.ts b/src/data-dir.ts new file mode 100644 index 000000000..56f494257 --- /dev/null +++ b/src/data-dir.ts @@ -0,0 +1,115 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; + +export const DATA_DIR_FLAG = "--data-dir"; +export const DATA_DIR_ENV = "AGENTMEMORY_DATA_DIR"; + +export type DataDirSource = "flag" | "env" | "default"; + +export interface ResolvedDataDir { + dir: string; + source: DataDirSource; +} + +export interface ResolveDataDirOptions { + argv?: string[]; + env?: NodeJS.ProcessEnv; + cwd?: string; + home?: string; +} + +// Read a value from argv in both "--data-dir " and +// "--data-dir=" forms. Returns undefined when the flag is absent or +// has no following token. +export function readDataDirFlag(argv: string[]): string | undefined { + const equalsPrefix = `${DATA_DIR_FLAG}=`; + for (const arg of argv) { + if (arg.startsWith(equalsPrefix)) return arg.slice(equalsPrefix.length); + } + const idx = argv.indexOf(DATA_DIR_FLAG); + if (idx !== -1) return argv[idx + 1]; + return undefined; +} + +// Expand a leading ~ / ~/ to the given home directory; other values pass +// through unchanged. +export function expandHomePath(pathValue: string, home: string): string { + if (pathValue === "~") return home; + if (pathValue.startsWith("~/") || pathValue.startsWith("~\\")) { + return join(home, pathValue.slice(2)); + } + return pathValue; +} + +// Fork-compat default data directory. Unlike upstream, platform-specific +// directories are intentionally NOT adopted. +export function defaultDataDir(home: string = homedir()): string { + return join(home, ".agentmemory"); +} + +function toAbsoluteDataDir(raw: string, cwd: string, home: string): string { + const expanded = expandHomePath(raw.trim(), home); + return isAbsolute(expanded) ? expanded : resolve(cwd, expanded); +} + +// Single precedence chain for the agentmemory data dir: +// --data-dir CLI flag > AGENTMEMORY_DATA_DIR > ~/.agentmemory +// Resolution reads only the real environment and CLI args — never the +// hydrated .env file — so boot stays deterministic (the .env file itself is +// loaded from the resolved data dir afterwards). +export function resolveDataDirDetailed( + options: ResolveDataDirOptions = {}, +): ResolvedDataDir { + const argv = options.argv ?? process.argv.slice(2); + const env = options.env ?? process.env; + const cwd = options.cwd ?? process.cwd(); + const home = options.home ?? homedir(); + + const flagValue = readDataDirFlag(argv); + if (flagValue !== undefined && flagValue.trim().length > 0) { + return { dir: toAbsoluteDataDir(flagValue, cwd, home), source: "flag" }; + } + + const envValue = env[DATA_DIR_ENV]; + if (envValue !== undefined && envValue.trim().length > 0) { + return { dir: toAbsoluteDataDir(envValue, cwd, home), source: "env" }; + } + + return { dir: defaultDataDir(home), source: "default" }; +} + +export function resolveDataDir(options: ResolveDataDirOptions = {}): string { + return resolveDataDirDetailed(options).dir; +} + +// A legacy cwd-local ./data counts only when it actually holds an +// agentmemory store marker, so unrelated projects that happen to have a +// data/ folder don't trigger the relocation warning. +export function legacyDataDirInCwd(cwd: string = process.cwd()): boolean { + const legacyDir = resolve(cwd, "data"); + return ( + existsSync(join(legacyDir, "state_store.db")) || + existsSync(join(legacyDir, "iii-config.yaml")) + ); +} + +// Upstream's d8b5267 adopted a legacy cwd ./data store automatically; this +// fork deliberately does NOT. When state would silently land somewhere +// different from a pre-existing local store, tell the operator to opt in +// with an explicit --data-dir instead of copying anything. +export function warnOnLegacyDataDir( + options: ResolveDataDirOptions & { write?: (message: string) => void } = {}, +): boolean { + const resolved = resolveDataDirDetailed(options); + if (resolved.source !== "default") return false; + const cwd = options.cwd ?? process.cwd(); + if (!legacyDataDirInCwd(cwd)) return false; + const write = + options.write ?? ((message: string) => process.stderr.write(message)); + write( + `[agentmemory] Found a legacy ./data store directory in ${cwd}, but this install keeps its state in ${resolved.dir}. ` + + `Nothing was copied or moved — pass --data-dir ${join(cwd, "data")} (or set ${DATA_DIR_ENV}) to use the legacy location explicitly.\n`, + ); + return true; +} diff --git a/src/functions/migrate.ts b/src/functions/migrate.ts index d98898abc..a9a94d8ae 100644 --- a/src/functions/migrate.ts +++ b/src/functions/migrate.ts @@ -17,8 +17,11 @@ import type { } from "../types.js"; import { logger } from "../logger.js"; import { withKeyedLock } from "../state/keyed-mutex.js"; +import { resolveDataDir } from "../data-dir.js"; -const ALLOWED_DIRS = [resolve(homedir(), ".agentmemory")]; +function allowedDirs(): string[] { + return [resolve(resolveDataDir())]; +} type ProjectRecord = Record & { project?: string }; @@ -350,7 +353,7 @@ export async function normalizeProjectScopes( function isAllowedPath(dbPath: string): boolean { const resolved = resolve(dbPath); - return ALLOWED_DIRS.some((dir) => resolved.startsWith(dir + "/")); + return allowedDirs().some((dir) => resolved.startsWith(dir + "/")); } // Infer memory project from the majority project of its associated sessions. @@ -1015,7 +1018,7 @@ export function registerMigrateFunction(sdk: ISdk, kv: StateKV): void { if (!isAllowedPath(data.dbPath)) { return { success: false, - error: `Path not allowed. Must be under: ${ALLOWED_DIRS.join(", ")}`, + error: `Path not allowed. Must be under: ${allowedDirs().join(", ")}`, }; } diff --git a/src/index.ts b/src/index.ts index 94b1457bf..5f7850211 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { registerWorker } from "iii-sdk"; import { + hydrateProcessEnvFromFile, loadConfig, getEnvVar, loadEmbeddingConfig, @@ -184,6 +185,11 @@ process.on("unhandledRejection", (reason) => { }); async function main() { + // Fold ~/.agentmemory/.env into process.env before anything reads config + // or raw process.env. Fill-missing-only, so a real process.env value + // always wins over the file. + hydrateProcessEnvFromFile(); + const config = loadConfig(); const embeddingConfig = loadEmbeddingConfig(); const fallbackConfig = loadFallbackConfig(); diff --git a/src/project-config.ts b/src/project-config.ts index 2344d434e..0df6b7f8e 100644 --- a/src/project-config.ts +++ b/src/project-config.ts @@ -7,8 +7,8 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { basename, isAbsolute, join, relative, resolve } from "node:path"; -import { parse as parseDotenv } from "dotenv"; import { parse as parseYaml } from "yaml"; +import { hydrateProcessEnvFromFile } from "./config.js"; export type ProjectPrivacy = "standard" | "private" | "strict"; export type CaptureProfile = "minimal" | "balanced" | "full"; @@ -257,18 +257,12 @@ export function getUserProjectConfigPath(root: string): string { } export function loadAgentmemoryEnvironment(): Record { - const envPath = join(userHome(), ".agentmemory", ".env"); - let fileEnv: Record = {}; - if (existsSync(envPath)) { - try { - fileEnv = parseDotenv(readFileSync(envPath)); - } catch { - fileEnv = {}; - } - } - for (const [key, value] of Object.entries(fileEnv)) { - if (process.env[key] === undefined) process.env[key] = value; - } + // Single precedence story across the codebase: + // real environment > ~/.agentmemory/.env > project manifest overrides > defaults + // hydrateProcessEnvFromFile() (config.js) owns the fill-missing-only + // copy from ~/.agentmemory/.env into process.env; nothing here may + // overwrite an existing process.env entry. + hydrateProcessEnvFromFile(); if (!asString(process.env["AGENTMEMORY_SECRET"])) { const secretFile = asString(process.env["AGENTMEMORY_SECRET_FILE"]); @@ -281,7 +275,7 @@ export function loadAgentmemoryEnvironment(): Record { } } } - return { ...fileEnv, ...process.env } as Record; + return { ...process.env } as Record; } function envLayer(env: Record): ConfigLayer { diff --git a/src/providers/_fetch.ts b/src/providers/_fetch.ts index ed9b5e896..3b02d9352 100644 --- a/src/providers/_fetch.ts +++ b/src/providers/_fetch.ts @@ -1,15 +1,52 @@ import { getEnvVar } from "../config.js"; -export function fetchWithTimeout( +// Bounded retry for transient rate-limit / unavailable responses. Attempts is +// total tries (initial + retries). Retries are bounded by a TOTAL elapsed +// deadline — not per-attempt — so the worst case never blows past the caller's +// timeout budget or the iii invocation timeout. A single retry delay is capped +// low so a hostile Retry-After header can't dominate the budget. +const MAX_ATTEMPTS = 3; +const MAX_RETRY_DELAY_MS = 5000; +// Absolute ceiling on the total budget, kept well under the iii 180s invocation +// timeout so retries + sleeps + per-attempt timeouts can never overrun it. +const HARD_BUDGET_CAP_MS = 170000; +// A retry only makes sense if there's room for at least a token attempt after +// the sleep; without this floor we'd sleep, fire, and get instantly cut off. +const MIN_ATTEMPT_FLOOR_MS = 100; +const RETRY_STATUS = new Set([429, 503]); + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Parse a Retry-After header into a delay in milliseconds. Supports both the + * integer-seconds form and the HTTP-date form. Returns undefined when absent + * or unparseable, so the caller falls back to exponential backoff. Negative + * or past values clamp to 0. + */ +function parseRetryAfter(header: string | null): number | undefined { + if (!header) return undefined; + const trimmed = header.trim(); + if (trimmed === "") return undefined; + + const seconds = Number(trimmed); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const date = Date.parse(trimmed); + if (Number.isFinite(date)) { + return Math.max(0, date - Date.now()); + } + + return undefined; +} + +async function fetchOnce( url: string, init: RequestInit, - timeoutMs?: number, + ms: number, ): Promise { - const parsed = - timeoutMs ?? - Number.parseInt(getEnvVar("AGENTMEMORY_LLM_TIMEOUT_MS") ?? "60000", 10); - const ms = Number.isFinite(parsed) && parsed > 0 ? parsed : 60000; - const ctl = new AbortController(); const signal = init.signal ? AbortSignal.any([init.signal, ctl.signal]) @@ -17,3 +54,53 @@ export function fetchWithTimeout( const t = setTimeout(() => ctl.abort(), ms); return fetch(url, { ...init, signal }).finally(() => clearTimeout(t)); } + +export async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs?: number, +): Promise { + const parsed = + timeoutMs ?? + Number.parseInt(getEnvVar("AGENTMEMORY_LLM_TIMEOUT_MS") ?? "60000", 10); + const ms = Number.isFinite(parsed) && parsed > 0 ? parsed : 60000; + + // The caller's timeout is the TOTAL budget for all attempts + sleeps, hard + // capped so we never approach the iii invocation timeout. + const budgetMs = Math.min(ms, HARD_BUDGET_CAP_MS); + const start = Date.now(); + + // The first attempt must honor the capped budget too — passing raw `ms` + // here would let a large caller timeout hang past HARD_BUDGET_CAP_MS (and + // the iii 180s invocation timeout) before any retry logic runs. + let response: Response = await fetchOnce(url, init, budgetMs); + for (let attempt = 1; attempt < MAX_ATTEMPTS; attempt++) { + if (!RETRY_STATUS.has(response.status)) return response; + + const retryAfter = parseRetryAfter(response.headers.get("Retry-After")); + // Exponential backoff fallback when no Retry-After: 500ms, 1000ms, ... + const backoff = 500 * 2 ** (attempt - 1); + const delay = Math.min(retryAfter ?? backoff, MAX_RETRY_DELAY_MS); + + // Stop retrying if the sleep plus a minimal attempt would overrun the total + // budget — a hostile Retry-After that alone exceeds the remaining budget + // returns the last response instead of stalling the caller. + const elapsed = Date.now() - start; + const remaining = budgetMs - elapsed; + if (delay + MIN_ATTEMPT_FLOOR_MS > remaining) return response; + + // This response is being discarded for a retry; release its body so the + // underlying connection is returned to the pool instead of leaking. + await response.body?.cancel().catch(() => {}); + await sleep(delay); + + // Cap the per-attempt timeout to whatever budget is left so a late attempt + // can't push total elapsed past the deadline. + const attemptMs = Math.max( + MIN_ATTEMPT_FLOOR_MS, + Math.min(ms, budgetMs - (Date.now() - start)), + ); + response = await fetchOnce(url, init, attemptMs); + } + return response; +} diff --git a/src/triggers/events.ts b/src/triggers/events.ts index db89cd1af..d829ffd03 100644 --- a/src/triggers/events.ts +++ b/src/triggers/events.ts @@ -5,7 +5,9 @@ import { StateKV } from "../state/kv.js"; import { isReflectEnabled } from "../functions/slots.js"; import { getAgentId, + getConsolidationCooldownMs, getEnvVar, + isConsolidationEnabled, isGraphExtractionEnabled, } from "../config.js"; import { logger } from "../logger.js"; @@ -27,6 +29,36 @@ import { const MAX_BACKGROUND_PIPELINE_ATTEMPTS = 3; +// Global marker recording when corpus consolidation last ran, used to debounce +// the per-turn session-stop fan-out. +const CONSOLIDATION_MARKER_KEY = "consolidation:lastRun"; + +async function consolidationDueUnserialized(kv: StateKV): Promise { + const cooldownMs = getConsolidationCooldownMs(); + if (cooldownMs <= 0) return true; // debounce disabled + const now = Date.now(); + const marker = await kv + .get<{ at?: number }>(KV.config, CONSOLIDATION_MARKER_KEY) + .catch(() => null); + const lastAt = typeof marker?.at === "number" ? marker.at : 0; + if (now - lastAt < cooldownMs) return false; + await kv.set(KV.config, CONSOLIDATION_MARKER_KEY, { at: now }).catch(() => {}); + return true; +} + +// Concurrent session-stop events would otherwise interleave the marker +// read-check-write above and both pass the cooldown. Serialize the whole +// check through an in-process chain so exactly one concurrent caller wins. +let consolidationCheckChain: Promise = Promise.resolve(); + +function consolidationDue(kv: StateKV): Promise { + const result = consolidationCheckChain.then(() => + consolidationDueUnserialized(kv), + ); + consolidationCheckChain = result.catch(() => false); + return result; +} + function successful(result: unknown): result is Record & { success: true; } { @@ -812,6 +844,63 @@ export function registerEventTriggers(sdk: ISdk, kv: StateKV): void { }); } } + // Crystals + lessons corpus consolidation. Fires only after the + // background pipeline reached a successful terminal state inside the + // background-pipeline lock — the resume machinery above owns which + // stages run, and superseded / already-processed / terminal-failed + // completions never reach this point or consume the cooldown. + // + // Debounce: /session/end is posted by the per-turn Stop hook, so this + // handler fires on every agent turn. mem::consolidate-pipeline + + // mem::auto-crystallize are full-corpus LLM work with no internal + // "nothing changed" guard, so firing them every turn is a cost/latency + // storm. Bound the global corpus consolidation to once per cooldown + // window; AGENTMEMORY_CONSOLIDATION_COOLDOWN_MS=0 disables the debounce. + if (isConsolidationEnabled()) { + const due = await consolidationDue(kv); + if (due) { + // Same dispatch discipline as the slot-reflect / graph-extract + // fan-outs above: tolerate synchronous throws and non-promise + // returns from sdk.trigger, log async rejections without failing + // the stop lifecycle. + const fireVoid = ( + function_id: string, + payload: Record, + ) => { + try { + const dispatched = sdk.trigger({ + function_id, + payload, + action: TriggerAction.Void(), + }); + Promise.resolve(dispatched).catch((err: unknown) => + logger.warn(function_id + " trigger failed", { + sessionId: data.sessionId, + project: data.project, + pipelineRunId, + error: err instanceof Error ? err.message : String(err), + }), + ); + } catch (err) { + logger.warn(function_id + " trigger failed", { + sessionId: data.sessionId, + project: data.project, + pipelineRunId, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + fireVoid("mem::consolidate-pipeline", { + tier: "all", + force: true, + project: data.project, + }); + fireVoid("mem::auto-crystallize", { + olderThanDays: 0, + project: data.project, + }); + } + } return successful(summary) ? { ...summary, pipelineRunId, promotion } : { diff --git a/test/consolidation-lifecycle.test.ts b/test/consolidation-lifecycle.test.ts new file mode 100644 index 000000000..e01169010 --- /dev/null +++ b/test/consolidation-lifecycle.test.ts @@ -0,0 +1,348 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryKV } from "../src/mcp/in-memory-kv.js"; +import { KV } from "../src/state/schema.js"; +import { registerEventTriggers } from "../src/triggers/events.js"; +import type { Session } from "../src/types.js"; +import { mockSdk } from "./helpers/mocks.js"; + +const PROJECT = "github.com/example/consolidation-lifecycle"; + +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; + +type UpdateOperation = { + type: "set"; + path: string; + value: unknown; +}; + +class TestKV extends InMemoryKV { + override async update( + scope: string, + key: string, + operations: UpdateOperation[], + ): Promise { + const current = (await this.get>(scope, key)) ?? {}; + const next = structuredClone(current); + for (const operation of operations) { + const segments = operation.path.split(".").filter(Boolean); + let target: Record = next; + for (const segment of segments.slice(0, -1)) { + const child = target[segment]; + if (!child || typeof child !== "object" || Array.isArray(child)) { + target[segment] = {}; + } + target = target[segment] as Record; + } + target[segments.at(-1)!] = operation.value; + } + return this.set(scope, key, next as T); + } +} + +function sessionRow(id: string, overrides: Partial = {}): Session { + return { + id, + project: PROJECT, + cwd: "/tmp/consolidation-lifecycle", + startedAt: "2026-08-11T00:00:00.000Z", + status: "active", + observationCount: 1, + ...overrides, + } as Session; +} + +const ENV_KEYS = [ + "CONSOLIDATION_ENABLED", + "AGENTMEMORY_PROVIDER", + "AGENTMEMORY_CONSOLIDATION_COOLDOWN_MS", + "AGENTMEMORY_REFLECT", + "GRAPH_EXTRACTION_ENABLED", +] as const; + +interface RecordedCall { + functionId: string; + payload: Record; +} + +async function flush(): Promise { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +describe("session-stop consolidation lifecycle", () => { + let kv: TestKV; + let sdk: ReturnType; + let calls: RecordedCall[]; + let savedEnv: Record; + let promotionShouldFail: boolean; + let sandboxHome: string; + + function registerRuntime(): void { + calls = []; + promotionShouldFail = false; + const record = (functionId: string, payload: unknown) => { + calls.push({ + functionId, + payload: payload as Record, + }); + return { success: true }; + }; + sdk.registerFunction("mem::consolidate-pipeline", async (payload) => + record("mem::consolidate-pipeline", payload), + ); + sdk.registerFunction("mem::auto-crystallize", async (payload) => + record("mem::auto-crystallize", payload), + ); + let summarizeCalls = 0; + sdk.registerFunction("mem::summarize", async () => { + summarizeCalls += 1; + return { success: true, summary: "synthetic" }; + }); + (sdk as unknown as { __summarizeCalls: () => number }).__summarizeCalls = + () => summarizeCalls; + sdk.registerFunction("mem::promotion-generate", async () => { + if (promotionShouldFail) { + promotionShouldFail = false; + return { success: false, error: "SIMULATED_PROMOTION_FAILURE" }; + } + return { success: true, candidates: [], promoted: 0 }; + }); + registerEventTriggers(sdk as never, kv as never); + } + + async function stop(input: { + sessionId: string; + project?: string; + pipelineRunId?: string; + }): Promise> { + return (await sdk.trigger("event::session::stopped", { + sessionId: input.sessionId, + project: input.project ?? PROJECT, + ...(input.pipelineRunId ? { pipelineRunId: input.pipelineRunId } : {}), + })) as Record; + } + + function consolidationCalls(): RecordedCall[] { + return calls.filter( + (call) => call.functionId === "mem::consolidate-pipeline", + ); + } + + function crystallizeCalls(): RecordedCall[] { + return calls.filter((call) => call.functionId === "mem::auto-crystallize"); + } + + async function marker(): Promise<{ at?: number } | null> { + return kv.get<{ at?: number }>(KV.config, "consolidation:lastRun"); + } + + beforeEach(() => { + // Isolate the config layer from the real operator ~/.agentmemory/.env: + // getMergedEnv() folds file values under process.env, so a real file + // could otherwise flip the consolidation gate or the cooldown. + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-consolidation-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + process.env["CONSOLIDATION_ENABLED"] = "true"; + // Pin the default window explicitly: process.env beats the operator's + // ~/.agentmemory/.env, so a real file value can't skew the debounce. + process.env["AGENTMEMORY_CONSOLIDATION_COOLDOWN_MS"] = "300000"; + process.env["AGENTMEMORY_REFLECT"] = "false"; + process.env["GRAPH_EXTRACTION_ENABLED"] = "false"; + kv = new TestKV(); + sdk = mockSdk(); + }); + + afterEach(async () => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + 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; + rmSync(sandboxHome, { recursive: true, force: true }); + }); + + it("fires corpus consolidation and crystallization once per eligible stop", async () => { + await kv.set(KV.sessions, "s1", sessionRow("s1")); + registerRuntime(); + + const result = await stop({ sessionId: "s1" }); + expect(result).toMatchObject({ success: true }); + await flush(); + + expect(consolidationCalls()).toEqual([ + { + functionId: "mem::consolidate-pipeline", + payload: { tier: "all", force: true, project: PROJECT }, + }, + ]); + expect(crystallizeCalls()).toEqual([ + { + functionId: "mem::auto-crystallize", + payload: { olderThanDays: 0, project: PROJECT }, + }, + ]); + const written = await marker(); + expect(typeof written?.at).toBe("number"); + }); + + it("suppresses consolidation for a later stop inside the cooldown window", async () => { + await kv.set(KV.sessions, "s1", sessionRow("s1")); + await kv.set(KV.sessions, "s2", sessionRow("s2")); + registerRuntime(); + + await stop({ sessionId: "s1" }); + await flush(); + await stop({ sessionId: "s2" }); + await flush(); + + expect(consolidationCalls()).toHaveLength(1); + expect(crystallizeCalls()).toHaveLength(1); + }); + + it("fires again once the cooldown window has elapsed", async () => { + await kv.set(KV.sessions, "s1", sessionRow("s1")); + await kv.set(KV.sessions, "s2", sessionRow("s2")); + registerRuntime(); + + await stop({ sessionId: "s1" }); + await flush(); + const stale = (await marker())!; + await kv.set(KV.config, "consolidation:lastRun", { + at: (stale.at ?? Date.now()) - 300_001, + }); + + await stop({ sessionId: "s2" }); + await flush(); + + expect(consolidationCalls()).toHaveLength(2); + }); + + it("treats AGENTMEMORY_CONSOLIDATION_COOLDOWN_MS=0 as debounce disabled", async () => { + process.env["AGENTMEMORY_CONSOLIDATION_COOLDOWN_MS"] = "0"; + await kv.set(KV.sessions, "s1", sessionRow("s1")); + await kv.set(KV.sessions, "s2", sessionRow("s2")); + registerRuntime(); + + await stop({ sessionId: "s1" }); + await flush(); + await stop({ sessionId: "s2" }); + await flush(); + + expect(consolidationCalls()).toHaveLength(2); + }); + + it("resumes completed stages without rerunning them and still consolidates once", async () => { + await kv.set( + KV.sessions, + "s-resume", + sessionRow("s-resume", { + backgroundPipelineRunId: "pipeline-resume-1", + backgroundPipelineStatus: "failed", + backgroundPipelineStage: "promotion", + backgroundPipelineSummaryStatus: "succeeded", + backgroundPipelineAttempts: 1, + }), + ); + registerRuntime(); + + const first = await stop({ + sessionId: "s-resume", + pipelineRunId: "pipeline-resume-1", + }); + await flush(); + + // Summary stage was already complete — the resume machinery must not + // re-run mem::summarize; only promotion ran. + const summarizeCalls = ( + sdk as unknown as { __summarizeCalls: () => number } + ).__summarizeCalls(); + expect(summarizeCalls).toBe(0); + expect(first).toMatchObject({ success: true }); + expect(await marker()).not.toBeNull(); + + // A repeat stop on the now-succeeded pipeline short-circuits before the + // consolidation gate: no stage rerun and no cooldown consumption. + const second = await stop({ + sessionId: "s-resume", + pipelineRunId: "pipeline-resume-1", + }); + await flush(); + expect(second).toMatchObject({ success: true, alreadyProcessed: true }); + expect(consolidationCalls()).toHaveLength(1); + expect(crystallizeCalls()).toHaveLength(1); + }); + + it("does not consume the cooldown when the pipeline fails", async () => { + await kv.set(KV.sessions, "s-fail", sessionRow("s-fail")); + registerRuntime(); + promotionShouldFail = true; + + const failed = await stop({ + sessionId: "s-fail", + pipelineRunId: "pipeline-fail-1", + }); + await flush(); + expect(failed).toMatchObject({ success: false, error: "promotion_failed" }); + expect(await marker()).toBeNull(); + expect(consolidationCalls()).toHaveLength(0); + + // Retry under the same runId succeeds — the gate is reached with an + // empty marker and fires. + const retried = await stop({ + sessionId: "s-fail", + pipelineRunId: "pipeline-fail-1", + }); + await flush(); + expect(retried).toMatchObject({ success: true }); + expect(consolidationCalls()).toHaveLength(1); + }); + + it("never fires or consumes the cooldown when consolidation is disabled", async () => { + // Explicit env values beat ~/.agentmemory/.env in getMergedEnv + // precedence, so this gate is deterministic regardless of the real + // operator file on the test machine. + process.env["AGENTMEMORY_PROVIDER"] = "noop"; + process.env["CONSOLIDATION_ENABLED"] = "false"; + await kv.set(KV.sessions, "s-keyless", sessionRow("s-keyless")); + registerRuntime(); + + const result = await stop({ sessionId: "s-keyless" }); + await flush(); + + expect(result).toMatchObject({ success: true }); + expect(consolidationCalls()).toHaveLength(0); + expect(crystallizeCalls()).toHaveLength(0); + expect(await marker()).toBeNull(); + }); + + it("serializes concurrent stops so only one passes the cooldown check", async () => { + await kv.set(KV.sessions, "s-a", sessionRow("s-a")); + await kv.set(KV.sessions, "s-b", sessionRow("s-b")); + await kv.set(KV.sessions, "s-c", sessionRow("s-c")); + registerRuntime(); + + await Promise.all([ + stop({ sessionId: "s-a" }), + stop({ sessionId: "s-b" }), + stop({ sessionId: "s-c" }), + ]); + await flush(); + + expect(consolidationCalls()).toHaveLength(1); + expect(crystallizeCalls()).toHaveLength(1); + }); +}); diff --git a/test/data-dir.test.ts b/test/data-dir.test.ts new file mode 100644 index 000000000..476c592e6 --- /dev/null +++ b/test/data-dir.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DATA_DIR_ENV, + defaultDataDir, + expandHomePath, + legacyDataDirInCwd, + readDataDirFlag, + resolveDataDir, + resolveDataDirDetailed, + warnOnLegacyDataDir, +} from "../src/data-dir.js"; + +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; + +let sandboxHome: string; +let sandboxCwd: string; + +function seedLegacyStore(): string { + const dataDir = join(sandboxCwd, "data"); + mkdirSync(dataDir, { recursive: true }); + writeFileSync(join(dataDir, "state_store.db"), ""); + return dataDir; +} + +describe("readDataDirFlag", () => { + it("reads the separated form", () => { + expect(readDataDirFlag(["--data-dir", "/tmp/am"])).toBe("/tmp/am"); + }); + + it("reads the = form", () => { + expect(readDataDirFlag(["--data-dir=/tmp/am"])).toBe("/tmp/am"); + }); + + it("returns undefined when absent or dangling", () => { + expect(readDataDirFlag([])).toBeUndefined(); + expect(readDataDirFlag(["--verbose"])).toBeUndefined(); + expect(readDataDirFlag(["--data-dir"])).toBeUndefined(); + }); +}); + +describe("expandHomePath", () => { + it("expands ~ and ~/ to the given home", () => { + const home = "/home/operator"; + expect(expandHomePath("~", home)).toBe(home); + expect(expandHomePath("~/notes", home)).toBe(join(home, "notes")); + expect(expandHomePath("/abs/path", home)).toBe("/abs/path"); + expect(expandHomePath("relative/path", home)).toBe("relative/path"); + }); +}); + +describe("resolveDataDir precedence", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-dataroot-home-")); + sandboxCwd = mkdtempSync(join(tmpdir(), "agentmemory-dataroot-cwd-")); + }); + + 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; + delete process.env[DATA_DIR_ENV]; + rmSync(sandboxHome, { recursive: true, force: true }); + rmSync(sandboxCwd, { recursive: true, force: true }); + }); + + it("defaults to ~/.agentmemory (fork-compatible), not a platform dir", () => { + const resolved = resolveDataDirDetailed({ + argv: [], + env: {}, + cwd: sandboxCwd, + home: sandboxHome, + }); + expect(resolved).toEqual({ + dir: join(sandboxHome, ".agentmemory"), + source: "default", + }); + expect(defaultDataDir(sandboxHome)).toBe(join(sandboxHome, ".agentmemory")); + }); + + it("prefers AGENTMEMORY_DATA_DIR over the default and resolves relative to cwd", () => { + const resolved = resolveDataDirDetailed({ + argv: [], + env: { [DATA_DIR_ENV]: "state/memory" }, + cwd: sandboxCwd, + home: sandboxHome, + }); + expect(resolved).toEqual({ + dir: join(sandboxCwd, "state/memory"), + source: "env", + }); + }); + + it("prefers the --data-dir flag over both the environment and the default", () => { + const resolved = resolveDataDirDetailed({ + argv: ["--data-dir", "/explicit/path"], + env: { [DATA_DIR_ENV]: "/from/env" }, + cwd: sandboxCwd, + home: sandboxHome, + }); + expect(resolved).toEqual({ dir: "/explicit/path", source: "flag" }); + }); + + it("supports tilde expansion for the flag and env values", () => { + const flagged = resolveDataDirDetailed({ + argv: ["--data-dir=~/memories"], + env: {}, + cwd: sandboxCwd, + home: sandboxHome, + }); + expect(flagged.dir).toBe(join(sandboxHome, "memories")); + + const fromEnv = resolveDataDirDetailed({ + argv: [], + env: { [DATA_DIR_ENV]: "~" }, + cwd: sandboxCwd, + home: sandboxHome, + }); + expect(fromEnv.dir).toBe(sandboxHome); + }); + + it("ignores blank flag/env values and falls through to the default", () => { + const resolved = resolveDataDirDetailed({ + argv: ["--data-dir", " "], + env: { [DATA_DIR_ENV]: "" }, + cwd: sandboxCwd, + home: sandboxHome, + }); + expect(resolved.source).toBe("default"); + }); + + it("resolveDataDir returns just the directory", () => { + expect( + resolveDataDir({ argv: [], env: {}, cwd: sandboxCwd, home: sandboxHome }), + ).toBe(join(sandboxHome, ".agentmemory")); + }); +}); + +describe("legacy ./data handling", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-legacy-home-")); + sandboxCwd = mkdtempSync(join(tmpdir(), "agentmemory-legacy-cwd-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + delete process.env[DATA_DIR_ENV]; + }); + + 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; + delete process.env[DATA_DIR_ENV]; + rmSync(sandboxHome, { recursive: true, force: true }); + rmSync(sandboxCwd, { recursive: true, force: true }); + }); + + it("detects a legacy store only via agentmemory markers", () => { + expect(legacyDataDirInCwd(sandboxCwd)).toBe(false); + const dataDir = seedLegacyStore(); + expect(legacyDataDirInCwd(sandboxCwd)).toBe(true); + expect(dataDir).toBe(join(sandboxCwd, "data")); + }); + + it("warns when a legacy store exists and no explicit data dir was configured", () => { + seedLegacyStore(); + const messages: string[] = []; + const warned = warnOnLegacyDataDir({ + argv: [], + env: {}, + cwd: sandboxCwd, + home: sandboxHome, + write: (message) => messages.push(message), + }); + expect(warned).toBe(true); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain("--data-dir"); + expect(messages[0]).toContain(DATA_DIR_ENV); + expect(messages[0]).toContain(join(sandboxCwd, "data")); + // Log-and-warn only: the legacy store must be untouched. + expect(legacyDataDirInCwd(sandboxCwd)).toBe(true); + }); + + it("stays silent once an explicit data dir was configured", () => { + seedLegacyStore(); + const messages: string[] = []; + const viaFlag = warnOnLegacyDataDir({ + argv: ["--data-dir", "/elsewhere"], + env: {}, + cwd: sandboxCwd, + home: sandboxHome, + write: (message) => messages.push(message), + }); + const viaEnv = warnOnLegacyDataDir({ + argv: [], + env: { [DATA_DIR_ENV]: "/elsewhere" }, + cwd: sandboxCwd, + home: sandboxHome, + write: (message) => messages.push(message), + }); + expect(viaFlag).toBe(false); + expect(viaEnv).toBe(false); + expect(messages).toHaveLength(0); + }); + + it("stays silent when no legacy store exists", () => { + const messages: string[] = []; + const warned = warnOnLegacyDataDir({ + argv: [], + env: {}, + cwd: sandboxCwd, + home: sandboxHome, + write: (message) => messages.push(message), + }); + expect(warned).toBe(false); + expect(messages).toHaveLength(0); + }); +}); + +describe("config integration", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-dataroot-cfg-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + delete process.env[DATA_DIR_ENV]; + }); + + 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; + delete process.env[DATA_DIR_ENV]; + rmSync(sandboxHome, { recursive: true, force: true }); + }); + + async function freshConfig() { + vi.resetModules(); + return await import("../src/config.js"); + } + + it("loadConfig().dataDir follows AGENTMEMORY_DATA_DIR over the default", async () => { + const cfg = await freshConfig(); + expect(cfg.loadConfig().dataDir).toBe(join(homedir(), ".agentmemory")); + + process.env[DATA_DIR_ENV] = join(sandboxHome, "relocated"); + const relocated = await freshConfig(); + expect(relocated.loadConfig().dataDir).toBe( + join(sandboxHome, "relocated"), + ); + }); + + it("hydrates .env from the resolved data dir", async () => { + const relocated = join(sandboxHome, "relocated"); + mkdirSync(relocated, { recursive: true }); + writeFileSync(join(relocated, ".env"), "AM_DATAROOT_PROBE=from-relocated\n"); + + process.env[DATA_DIR_ENV] = relocated; + const cfg = await freshConfig(); + cfg.hydrateProcessEnvFromFile(); + expect(process.env["AM_DATAROOT_PROBE"]).toBe("from-relocated"); + delete process.env["AM_DATAROOT_PROBE"]; + + // The default location has no .env — nothing else may be hydrated from + // the old path once the data dir moved. + delete process.env[DATA_DIR_ENV]; + const backToDefault = await freshConfig(); + backToDefault.hydrateProcessEnvFromFile(); + expect(process.env["AM_DATAROOT_PROBE"]).toBeUndefined(); + }); + + it("snapshot and standalone defaults anchor under the resolved data dir", async () => { + process.env[DATA_DIR_ENV] = join(sandboxHome, "relocated"); + const cfg = await freshConfig(); + expect(cfg.loadSnapshotConfig().dir).toBe( + join(join(sandboxHome, "relocated"), "snapshots"), + ); + expect(cfg.getStandalonePersistPath()).toBe( + join(join(sandboxHome, "relocated"), "standalone.json"), + ); + }); +}); diff --git a/test/env-hydration.test.ts b/test/env-hydration.test.ts new file mode 100644 index 000000000..5fe3f9867 --- /dev/null +++ b/test/env-hydration.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; + +let sandboxHome: string; +const hydratedKeys = [ + "AM_HYD_MISSING", + "AM_HYD_TAKEN", + "AM_HYD_EMPTY", + "AM_HYD_QUOTED", +]; + +async function freshConfig() { + vi.resetModules(); + return await import("../src/config.js"); +} + +function writeEnv(contents: string) { + const dir = join(sandboxHome, ".agentmemory"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".env"), contents); +} + +describe("hydrateProcessEnvFromFile", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-hydrate-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + for (const key of hydratedKeys) delete process.env[key]; + }); + + 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; + for (const key of hydratedKeys) delete process.env[key]; + rmSync(sandboxHome, { recursive: true, force: true }); + }); + + it("sets process.env entries for names not already present", async () => { + writeEnv("AM_HYD_MISSING=from-file\n"); + const cfg = await freshConfig(); + cfg.hydrateProcessEnvFromFile(); + expect(process.env["AM_HYD_MISSING"]).toBe("from-file"); + }); + + it("leaves names that already exist in the real environment untouched", async () => { + writeEnv("AM_HYD_TAKEN=from-file\n"); + process.env["AM_HYD_TAKEN"] = "from-process"; + const cfg = await freshConfig(); + cfg.hydrateProcessEnvFromFile(); + expect(process.env["AM_HYD_TAKEN"]).toBe("from-process"); + }); + + it("treats an empty-string value as present and does not overwrite it", async () => { + writeEnv("AM_HYD_EMPTY=from-file\n"); + process.env["AM_HYD_EMPTY"] = ""; + const cfg = await freshConfig(); + cfg.hydrateProcessEnvFromFile(); + expect(process.env["AM_HYD_EMPTY"]).toBe(""); + }); + + it("ignores malformed lines without setting anything", async () => { + writeEnv( + [ + "# a full-line comment", + "", + " ", + "NO_EQUALS_SIGN_HERE", + "=value-without-key", + "AM_HYD_MISSING=valid-after-malformed", + ].join("\n"), + ); + const cfg = await freshConfig(); + cfg.hydrateProcessEnvFromFile(); + expect(process.env["AM_HYD_MISSING"]).toBe("valid-after-malformed"); + expect(process.env["NO_EQUALS_SIGN_HERE"]).toBeUndefined(); + }); + + it("hydrates a missing quoted value with quotes unwrapped", async () => { + writeEnv('AM_HYD_QUOTED="quoted # value"\n'); + const cfg = await freshConfig(); + cfg.hydrateProcessEnvFromFile(); + expect(process.env["AM_HYD_QUOTED"]).toBe("quoted # value"); + }); + + it("keeps getEnvVar precedence on the real environment after hydration", async () => { + writeEnv("AM_HYD_TAKEN=from-file\n"); + process.env["AM_HYD_TAKEN"] = "from-process"; + const cfg = await freshConfig(); + cfg.hydrateProcessEnvFromFile(); + expect(cfg.getEnvVar("AM_HYD_TAKEN")).toBe("from-process"); + }); + + it("is a no-op for config reads when ~/.agentmemory/.env does not exist", async () => { + const before = { ...process.env }; + const cfg = await freshConfig(); + expect(() => cfg.hydrateProcessEnvFromFile()).not.toThrow(); + expect({ ...process.env }).toEqual(before); + }); +}); diff --git a/test/fetch-timeout.test.ts b/test/fetch-timeout.test.ts index 5b2cd7c9c..5038ee51d 100644 --- a/test/fetch-timeout.test.ts +++ b/test/fetch-timeout.test.ts @@ -74,6 +74,231 @@ describe("fetchWithTimeout", () => { }); }); +// ───────────────────────────────────────────────────────────── +// Bounded-retry total-deadline tests +// +// The retry wrapper must bound TOTAL elapsed time across every +// attempt + sleep — not per-attempt — so worst case never blows +// past the caller's budget or the iii 180s invocation timeout. +// ───────────────────────────────────────────────────────────── +describe("fetchWithTimeout bounded retry (total deadline)", () => { + // Builds a fetch mock that replays a queue of {status, headers} in order, + // repeating the last entry once the queue is exhausted. Records how many + // times it was invoked so we can assert attempt counts. + function queuedFetch( + responses: Array<{ status: number; headers?: Record }>, + ): { fetch: typeof fetch; calls: () => number } { + let i = 0; + const impl = (async () => { + const spec = responses[Math.min(i, responses.length - 1)]; + i++; + return new Response(null, { + status: spec.status, + headers: spec.headers, + }); + }) as typeof fetch; + return { fetch: impl, calls: () => i }; + } + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + // The FIRST attempt must honor the hard budget cap, not the raw caller + // timeout — otherwise a large caller timeout could hang past + // HARD_BUDGET_CAP_MS before any retry logic runs. + it("caps the first attempt at the hard budget, not the raw caller timeout", async () => { + const signals: AbortSignal[] = []; + const capturing = ((_url: string, init?: RequestInit) => { + const signal = init!.signal as AbortSignal; + signals.push(signal); + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => + reject(new DOMException("AbortError", "AbortError")), + ); + }); + }) as typeof fetch; + vi.spyOn(globalThis, "fetch").mockImplementation(capturing); + vi.useFakeTimers(); + + // 300s caller timeout — far above the 170s hard budget cap. + const p = fetchWithTimeout("https://example.com", {}, 300000); + p.catch(() => {}); // observe rejection so it isn't flagged unhandled + + expect(signals).toHaveLength(1); + // Just before the cap the first attempt is still alive. + await vi.advanceTimersByTimeAsync(169999); + expect(signals[0].aborted).toBe(false); + // At the cap it must abort. + await vi.advanceTimersByTimeAsync(2); + expect(signals[0].aborted).toBe(true); + + await expect(p).rejects.toThrow(); + }); + + // 429 then 200 → retried exactly once, resolves 200. + it("retries once on 429 then resolves the follow-up 200", async () => { + const q = queuedFetch([{ status: 429 }, { status: 200 }]); + vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch); + vi.useFakeTimers(); + + const p = fetchWithTimeout("https://example.com", {}, 60000); + // Drain the backoff sleep (500ms default) so the retry fires. + await vi.advanceTimersByTimeAsync(500); + const res = await p; + + expect(res.status).toBe(200); + expect(q.calls()).toBe(2); + }); + + // A hostile Retry-After must NOT be honored literally — the delay collapses + // to the low per-delay cap so total elapsed stays bounded. + it("does not honour a hostile Retry-After literally — caps it and stays within budget", async () => { + const q = queuedFetch([ + { status: 429, headers: { "Retry-After": "100000" } }, // 100000s + { status: 200 }, + ]); + vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch); + vi.useFakeTimers(); + const start = Date.now(); + + const p = fetchWithTimeout("https://example.com", {}, 60000); + // runAllTimers drains every scheduled sleep; if the code honored 100000s + // literally this would advance 100_000_000ms of simulated time. + await vi.runAllTimersAsync(); + const res = await p; + const elapsed = Date.now() - start; + + expect(res.status).toBe(200); + expect(q.calls()).toBe(2); + // The only sleep was the capped 5000ms delay. + expect(elapsed).toBeLessThanOrEqual(5000); + expect(elapsed).toBeLessThan(60000); + }); + + // When even the capped delay cannot fit inside a small budget, do NOT + // retry — return the last 429 within bound. + it("returns the last 429 without retrying when even the capped delay overruns a small budget", async () => { + const q = queuedFetch([ + { status: 429, headers: { "Retry-After": "100000" } }, + { status: 200 }, + ]); + vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch); + vi.useFakeTimers(); + const start = Date.now(); + + // Budget 1000ms: capped delay 5000ms + floor 100ms > remaining, no retry. + const p = fetchWithTimeout("https://example.com", {}, 1000); + await vi.runAllTimersAsync(); + const res = await p; + const elapsed = Date.now() - start; + + expect(res.status).toBe(429); + expect(q.calls()).toBe(1); + expect(elapsed).toBeLessThan(1000); + }); + + // Retry-After larger than the per-delay cap collapses to MAX_RETRY_DELAY_MS, + // still bounded, still retried when budget allows. + it("caps an oversized Retry-After to the max delay and still retries within budget", async () => { + const q = queuedFetch([ + { status: 503, headers: { "Retry-After": "60" } }, // 60s requested + { status: 200 }, + ]); + vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch); + vi.useFakeTimers(); + + const p = fetchWithTimeout("https://example.com", {}, 60000); + // Requested 60s, but the honored delay is capped at 5000ms. + await vi.advanceTimersByTimeAsync(5000); + const res = await p; + + expect(res.status).toBe(200); + expect(q.calls()).toBe(2); + }); + + // Persistent 503 → stops after the bounded attempts AND within the total + // deadline (initial + 2 retries = MAX_ATTEMPTS). + it("stops persistent 503 at the attempt cap and within the deadline", async () => { + const q = queuedFetch([{ status: 503 }]); + vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch); + vi.useFakeTimers(); + const start = Date.now(); + + const p = fetchWithTimeout("https://example.com", {}, 60000); + // Two retries with 500ms + 1000ms exponential backoff. + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(1000); + const res = await p; + const elapsed = Date.now() - start; + + expect(res.status).toBe(503); + expect(q.calls()).toBe(3); + expect(elapsed).toBeLessThan(60000); + // Total simulated sleep was only the two backoffs. + expect(elapsed).toBeLessThanOrEqual(1500); + }); + + // A tiny budget forces a bail before even the first retry sleep. + it("does not retry when the budget is too small to fit a retry", async () => { + const q = queuedFetch([{ status: 503 }, { status: 200 }]); + vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch); + vi.useFakeTimers(); + + // Budget of 200ms: backoff 500ms + floor 100ms > remaining, so no retry. + const p = fetchWithTimeout("https://example.com", {}, 200); + await vi.runAllTimersAsync(); + const res = await p; + + expect(res.status).toBe(503); + expect(q.calls()).toBe(1); + }); + + // Retry-After as an HTTP-date is parsed and the retry fires within budget. + it("parses an HTTP-date Retry-After and honors it when within budget", async () => { + // 2s in the future — under the max delay cap, so honored as-is. + const future = new Date(Date.now() + 2000).toUTCString(); + const q = queuedFetch([ + { status: 429, headers: { "Retry-After": future } }, + { status: 200 }, + ]); + vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch); + vi.useFakeTimers(); + + const p = fetchWithTimeout("https://example.com", {}, 60000); + await vi.advanceTimersByTimeAsync(2000); + const res = await p; + + expect(res.status).toBe(200); + expect(q.calls()).toBe(2); + }); + + // A far-future HTTP-date collapses to the capped delay (5000ms), which still + // exceeds a small budget → no retry, last 503 returned in bound. + it("does not retry on a far-future HTTP-date Retry-After that overruns a small budget", async () => { + const farFuture = new Date(Date.now() + 3600_000).toUTCString(); // 1h out + const q = queuedFetch([ + { status: 503, headers: { "Retry-After": farFuture } }, + { status: 200 }, + ]); + vi.spyOn(globalThis, "fetch").mockImplementation(q.fetch); + vi.useFakeTimers(); + const start = Date.now(); + + // Budget 1000ms: capped delay 5000ms + floor > remaining, so no retry. + const p = fetchWithTimeout("https://example.com", {}, 1000); + await vi.runAllTimersAsync(); + const res = await p; + const elapsed = Date.now() - start; + + expect(res.status).toBe(503); + expect(q.calls()).toBe(1); + expect(elapsed).toBeLessThan(1000); + }); +}); + // ───────────────────────────────────────────────────────────── // Provider hang regression tests // Each provider must call fetchWithTimeout, which honours the diff --git a/test/session-end-triggers-graph.test.ts b/test/session-end-triggers-graph.test.ts index 5b95f3185..cb5f1463c 100644 --- a/test/session-end-triggers-graph.test.ts +++ b/test/session-end-triggers-graph.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, it, expect } from "vitest"; +import { afterEach, beforeEach, describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { registerApiTriggers } from "../src/triggers/api.js"; import { @@ -69,9 +69,19 @@ describe("api::session::end → event::session::stopped (#666)", () => { beforeEach(() => { process.env["AGENTMEMORY_REFLECT"] = "false"; process.env["GRAPH_EXTRACTION_ENABLED"] = "false"; + // These tests pin the exact post-stop fan-out list. Explicitly disable + // the corpus-consolidation debounce gate (added by the session-stop + // consolidation lifecycle) so the expectations don't depend on the + // operator's real ~/.agentmemory/.env or LLM keys; that behavior has + // dedicated coverage in test/consolidation-lifecycle.test.ts. + process.env["CONSOLIDATION_ENABLED"] = "false"; resetBackgroundPipelineHealthForTests(); }); + afterEach(() => { + delete process.env["CONSOLIDATION_ENABLED"]; + }); + it("closes quickly, records dispatch acceptance, and is idempotent", async () => { const calls: Array<{ function_id: string; payload?: Record }> = []; const pending = new Promise(() => undefined); diff --git a/tsdown.config.ts b/tsdown.config.ts index f0f8d323b..6cdc44e19 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -46,12 +46,13 @@ const shared = { // Provider plugin caches contain only the published plugin tree. Bundle the // small config parsers used by hooks so those entrypoints never depend on a -// repository or package-level node_modules directory at runtime. +// repository or package-level node_modules directory at runtime. picocolors +// rides in too: config.ts colorizes a provider hint that hook chunks reach. const pluginShared = { ...shared, deps: { ...shared.deps, - alwaysBundle: ["dotenv", "yaml"], + alwaysBundle: ["dotenv", "yaml", "picocolors"], }, };