UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

474 lines (473 loc) • 17.5 kB
import { r as isPathInside } from "./path-guards-CH7noY0Z.mjs"; import { d as sleepWithAbort } from "./src-D1BHGNTj.mjs"; import { t as formatCliCommand } from "./command-format-DwEdrDKL.mjs"; import { t as createSubsystemLogger } from "./subsystem-I-uqdx4f.mjs"; import "./memory-core-host-engine-storage-g5_Iw_VB.mjs"; import { d as matchesExtraMemoryPathEntry, f as normalizeExtraMemoryPathEntries } from "./read-file-DsXzvp1B.mjs"; import { r as classifyMemoryMultimodalPath } from "./multimodal-B9eYMDXo.mjs"; import { f as watch } from "./file-access-runtime-BMp89X4Y.mjs"; import { a as resolveFsObservationIntervalMs, i as observationPrefixKind, n as readObservationSnapshot, o as resolveFsObservationMode, r as admitObservationRoot, t as ObservationSampleCloseError } from "./fs-observation-snapshot-DTptcApl.mjs"; import "./runtime-env-C0XPA9NZ.mjs"; import "./setup-tools-8zAOgl__.mjs"; import "./memory-core-host-engine-embeddings-D4RTPmUb.mjs"; import "./memory-core-host-engine-foundation-DjbDXn4b.mjs"; import path from "node:path"; import fs from "node:fs/promises"; //#region extensions/memory-core/src/memory/watch-policy.ts const IGNORED = /* @__PURE__ */ new Set([ ".git", "node_modules", ".pnpm-store", ".venv", "venv", ".tox", "__pycache__" ]); async function firstLink(authority, relative, signal) { let parent = "."; const segments = relative.split(path.sep).filter((part) => part && part !== "."); for (const segment of segments) { signal.throwIfAborted(); parent = path.join(parent, segment); const kind = await observationPrefixKind(authority, parent, signal); if (kind === "symlink") return parent; if (kind !== "directory") return; } } var MemoryWatchPolicy = class { constructor(workspace, settings) { this.settings = settings; this.roots = /* @__PURE__ */ new Map(); this.extras = normalizeExtraMemoryPathEntries(workspace, settings.extraPaths); this.targets = [ { path: path.resolve(workspace, "MEMORY.md"), kind: "entry", core: true }, { path: path.resolve(workspace, "USER.md"), kind: "entry", core: true }, { path: path.resolve(workspace, "memory"), kind: "tree", core: true }, ...this.extras.map((entry) => ({ path: entry.path, kind: "tree", core: false })) ]; } async admit(boundary, signal) { const cached = this.roots.get(boundary); if (cached) return cached; const admitted = await admitObservationRoot(boundary, "allow"); signal.throwIfAborted(); this.roots.set(boundary, admitted); return admitted; } async observations(signal) { const groups = /* @__PURE__ */ new Map(); const add = (authority, selection) => { const group = groups.get(authority) ?? { root: authority, selections: [] }; group.selections.push(selection); groups.set(authority, group); }; for (const target of this.targets) { signal.throwIfAborted(); let boundary = target.core ? path.dirname(path.dirname(target.path)) : path.dirname(target.path); let absolute = target.path; for (let links = 0;; links++) { if (links >= 40) throw new Error("Memory watch target has too many symbolic links"); const authority = await this.admit(boundary, signal); signal.throwIfAborted(); const relative = path.relative(authority.rootDir, absolute) || "."; const link = await firstLink(authority, relative, signal); signal.throwIfAborted(); add(authority, { scope: link ? { path: link, kind: "entry" } : { path: relative, kind: target.kind, depth: 128 }, resolved: path.resolve(authority.rootDir, link ?? relative), lexical: target.path, core: target.core, alias: link !== void 0 }); if (!link || path.normalize(link) === path.normalize(relative)) break; const alias = path.resolve(authority.rootDir, link); const destination = await fs.readlink(alias); signal.throwIfAborted(); absolute = path.resolve(path.dirname(alias), destination, path.relative(alias, absolute)); boundary = path.dirname(absolute); } } return [...groups.values()]; } scopes(group) { return [...new Map(group.selections.map(({ scope }) => [JSON.stringify(scope), scope])).values()]; } ignored(relative, file, kind) { if (relative.split(path.sep).some((part) => IGNORED.has(part.toLowerCase()))) return true; if (kind === "directory") return false; if (kind === "symlink") return true; const extension = path.extname(file).toLowerCase(); return extension !== "" && extension !== ".md" && classifyMemoryMultimodalPath(file, this.settings.multimodal) === null; } exclude(group, entry) { const absolute = path.resolve(group.root.rootDir, entry.path); for (const selection of group.selections) { const selected = selection.resolved; if (absolute === selected || selection.scope.kind === "tree" && isPathInside(selected, absolute)) { if (selection.alias && absolute === selected) return false; const relative = path.relative(selected, absolute); const lexical = path.join(selection.lexical, relative); if (!this.ignored(relative, lexical, entry.kind)) return false; } else if (isPathInside(absolute, selected)) return false; } return true; } select(group, relative, structural) { const absolute = path.resolve(group.root.rootDir, relative); for (const selection of group.selections) { const selected = selection.resolved; if (absolute !== selected && (selection.scope.kind !== "tree" || !isPathInside(selected, absolute))) continue; if (selection.alias) return { root: group.root, relative: selection.scope.path, sample: false }; const selectedRelative = path.relative(selected, absolute); const lexical = path.join(selection.lexical, selectedRelative); if (this.ignored(selectedRelative, lexical, structural ? "directory" : "file")) continue; if (!selection.core && !this.extras.some((entry) => isPathInside(entry.path, lexical) && (!this.ignored(selectedRelative, lexical, "file") && matchesExtraMemoryPathEntry(entry, lexical) || structural && matchesExtraMemoryPathEntry(entry, lexical, { directory: true })))) continue; return { root: group.root, relative, sample: true }; } } }; //#endregion //#region extensions/memory-core/src/memory/watch-settle.ts const MEMORY_WATCH_MAX_PATHS = 1024; const MEMORY_WATCH_SETTLE_RECHECK_MS = 100; function snapshotsMatch(left, right) { return left === null || right === null ? left === right : left.size === right.size && left.mtimeMs === right.mtimeMs; } async function snapshotPath(file) { return file.sample ? await readObservationSnapshot(file.root, file.relative) ?? null : null; } function recordMemoryWatchEventPath(queue, file) { const key = path.resolve(file.root.rootDir, file.relative); queue.set(key, { file, snapshot: null }); if (queue.size > 1024) queue.clear(); } async function settleMemoryWatchEventPaths(queue, signal) { signal?.throwIfAborted(); const entries = [...queue]; queue.clear(); const missingBaseline = []; const retain = (key, pending) => { if (!queue.has(key) && queue.size < 1024) queue.set(key, pending); }; for (const [key, pending] of entries) { signal?.throwIfAborted(); const snapshot = await snapshotPath(pending.file); signal?.throwIfAborted(); if (pending.snapshot === null) { if (snapshot !== null) missingBaseline.push([key, { file: pending.file, snapshot }]); } else if (!snapshotsMatch(pending.snapshot, snapshot)) retain(key, { file: pending.file, snapshot }); } if (missingBaseline.length) { await sleepWithAbort(MEMORY_WATCH_SETTLE_RECHECK_MS, signal); for (const [key, pending] of missingBaseline) { signal?.throwIfAborted(); const snapshot = await snapshotPath(pending.file); signal?.throwIfAborted(); if (!snapshotsMatch(pending.snapshot, snapshot)) retain(key, { file: pending.file, snapshot }); } } return queue.size === 0; } //#endregion //#region extensions/memory-core/src/memory/file-watcher.ts const log = createSubsystemLogger("memory"); const RETRY_DELAYS_MS = [ 500, 2e3, 5e3 ]; const DEFAULT_POLL_INTERVAL_MS = 3e4; var MemoryFileWatcher = class { constructor(options) { this.options = options; this.lifetime = new AbortController(); this.observations = /* @__PURE__ */ new Map(); this.rootIds = /* @__PURE__ */ new WeakMap(); this.nextRootId = 0; this.pendingPaths = /* @__PURE__ */ new Map(); this.pressureWarningShown = false; this.pollingWarningShown = false; this.closeErrors = []; this.closed = false; this.degraded = false; this.retries = 0; this.retryExhausted = false; this.refreshRequested = false; this.pendingChange = false; this.revision = 0; this.policy = new MemoryWatchPolicy(options.workspaceDir, options.settings); } get capacityDegraded() { return this.degraded; } health() { return [...this.observations.values()].map(({ subscription, mode, pollIntervalMs }) => { const health = subscription.health(); return { state: health.state, mode: health.mode, directories: health.directories, failure: health.failure ? { operation: health.failure.operation, code: health.failure.code, error: String(health.failure.error) } : void 0, pollingFallback: mode === "auto" && health.mode === "poll", pollIntervalMs }; }); } start() { return this.starting ??= this.options.runInBackgroundContext(() => this.refresh()); } get canObserve() { return !(this.closed || this.degraded || this.closeErrors.length || this.recovering || this.retryTimer || this.retryExhausted); } refresh() { if (!this.canObserve) return Promise.resolve(); this.refreshRequested = true; return this.refreshing ??= Promise.resolve().then(async () => { while (this.refreshRequested && this.canObserve) { this.refreshRequested = false; const groups = (await this.policy.observations(this.lifetime.signal)).flatMap((group) => { let id = this.rootIds.get(group.root); if (id === void 0) { id = ++this.nextRootId; this.rootIds.set(group.root, id); } const chunks = []; for (let offset = 0; offset < group.selections.length; offset += 128) chunks.push({ id: String(id) + ":" + offset, group: { root: group.root, selections: group.selections.slice(offset, offset + 128) } }); return chunks; }); if (!this.canObserve) return; const next = new Set(groups.map((group) => group.id)); const removed = [...this.observations.values()].filter((entry) => !next.has(entry.id)); await this.retire(removed); if (!this.canObserve) return; const ready = []; for (const { id, group } of groups) { if (!this.canObserve) return; const scopes = this.policy.scopes(group); const key = JSON.stringify(scopes); const entry = this.observations.get(id); if (entry) { entry.group = group; if (entry.key !== key) { entry.key = key; ready.push(entry.subscription.setScopes(scopes)); } continue; } const mode = resolveFsObservationMode(); const pollIntervalMs = resolveFsObservationIntervalMs(process.env, DEFAULT_POLL_INTERVAL_MS); const owner = { id, group, key, mode, pollIntervalMs, subscription: watch(group.root, { scopes, mode, pollIntervalMs, maxDirectories: 1e6, maxEntries: 1e6, maxPendingPaths: MEMORY_WATCH_MAX_PATHS, signal: this.lifetime.signal, exclude: (file) => this.policy.exclude(owner.group, file), onInvalidate: (hint) => this.dirty(owner.group, hint), onHealth: (health) => this.onHealth(health, mode, pollIntervalMs) }) }; this.observations.set(id, owner); ready.push(owner.subscription.ready); } await Promise.all(ready); } }).catch((error) => { if (!this.closed) this.unavailable(error); }).finally(() => { this.refreshing = void 0; if (this.refreshRequested && !this.retryTimer) this.refresh(); }); } dirty(group, hint) { if (!hint.changes) { this.markDirty(); this.refresh(); return; } if (hint.changes.length && (hint.reason === "event" || hint.reason === "reconcile")) this.retries = 0; let structural = false; for (const change of hint.changes) { structural ||= change.type === "structural"; const file = this.policy.select(group, change.path, change.type === "structural"); if (file) this.markDirty(file); } if (structural) this.refresh(); } onHealth(health, mode, pollIntervalMs) { if (health.state === "unavailable") { const code = health.failure?.operation === "watch" && health.failure.code === "watch-limit" ? "watch-limit" : void 0; this.unavailable(health.failure?.error, code); return; } if (health.state !== "ready") return; if (mode === "auto" && health.mode === "poll" && !this.pollingWarningShown) { this.pollingWarningShown = true; const reason = health.failure ? `${health.failure.code ?? health.failure.operation}: ${String(health.failure.error)}` : "native watch events are unavailable; fs-safe did not report a reason"; log.warn(`memory watcher using fallback polling every ${pollIntervalMs} ms: ${reason}. Native watching will not be retried until the watcher restarts.`); } const count = [...this.observations.values()].reduce((total, entry) => total + entry.subscription.health().directories, 0); if (this.pressureWarningShown || count <= 2e3) return; this.pressureWarningShown = true; const detail = health.mode === "poll" ? "Large memory folders or extraPaths increase metadata polling work." : "Large memory folders or extraPaths can exhaust file-watch/open-file limits."; log.warn(`Memory file watching is tracking ${count} observed directories. ${detail} Remove unnecessary memory.search.extraPaths entries or narrow their roots. After changes, restart the Gateway. To refresh the index, run in the Gateway's environment: ` + formatCliCommand("openclaw memory index --force --agent " + this.options.agentId) + "."); } unavailable(error, capacityCode) { if (this.closed || this.recovering || this.retryTimer || this.degraded) return; this.degraded ||= Boolean(capacityCode); this.options.onUnavailable(); this.markDirty(); log.warn(capacityCode ? "memory watcher capacity exhausted (" + capacityCode + "); watching disabled, memory will refresh on search" : "memory watcher unavailable; memory will refresh on search: " + String(error)); const retirement = this.retire([...this.observations.values()]); this.recovering = retirement.finally(() => { this.recovering = void 0; if (this.closed || this.degraded || this.closeErrors.length) return; const delay = RETRY_DELAYS_MS[this.retries++]; if (delay === void 0) { this.retryExhausted = true; return; } this.retryTimer = setTimeout(() => { this.retryTimer = void 0; this.refresh(); }, delay); this.retryTimer.unref(); }); } async retire(entries) { for (const entry of entries) this.observations.delete(entry.id); const results = await Promise.allSettled(entries.map((entry) => entry.subscription.close())); for (const result of results) { if (result.status !== "rejected") continue; this.closeErrors.push(result.reason); if (!this.closed) this.options.onUnavailable(); log.warn("memory watcher close failed; observation will not restart: " + String(result.reason)); } } markDirty(file) { if (this.closed) return; this.revision++; this.pendingChange = true; if (file) recordMemoryWatchEventPath(this.pendingPaths, file); this.options.onDirty?.(); this.scheduleSync(); } scheduleSync(recheck = false) { if (this.closed || this.settling) return; clearTimeout(this.watchTimer); this.watchTimer = setTimeout(() => { this.watchTimer = void 0; let retry = false; const revision = this.revision; this.settling = Promise.resolve().then(async () => { if (this.closed) return; if (!await settleMemoryWatchEventPaths(this.pendingPaths, this.lifetime.signal)) { retry = true; return; } if (this.closed) return; this.pendingChange = this.revision !== revision; await this.options.runInBackgroundContext(() => this.options.onChange()); }).catch((error) => { if (error instanceof ObservationSampleCloseError) { this.closeErrors.push(error.cause); this.unavailable(error); } if (!this.closed) { this.pendingChange = this.revision !== revision; log.warn("memory sync failed (watch): " + String(error)); } }).finally(() => { this.settling = void 0; if (this.pendingChange) this.scheduleSync(retry); }); }, Math.max(recheck ? 100 : 0, this.options.settings.sync.watchDebounceMs)); this.watchTimer.unref(); } close() { if (this.closing) return this.closing; this.closing = this.options.runInBackgroundContext(async () => { this.closed = true; this.lifetime.abort(); clearTimeout(this.watchTimer); clearTimeout(this.retryTimer); const retirement = this.retire([...this.observations.values()]); await Promise.allSettled([ this.starting, this.refreshing, this.recovering, this.settling, retirement ]); this.pendingPaths.clear(); if (this.closeErrors.length) throw new AggregateError(this.closeErrors, "Memory watcher cleanup failed"); }); return this.closing; } }; //#endregion export { MemoryFileWatcher as t };