diff --git a/README.md b/README.md index f0dae25..60d0091 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ You can override configuration using environment variables: - `NOSTR_LOCK_WEEKLY_ROSTER` (comma-separated list of agents) - `TORCH_CONFIG_PATH` (path to config file) - `AGENT_PLATFORM` (platform identifier, e.g. `codex`) +- `TORCH_MEMORY_ENABLED` (`true`/`false`; global memory kill switch, defaults to enabled) +- `TORCH_MEMORY_INGEST_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list) +- `TORCH_MEMORY_RETRIEVAL_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list) +- `TORCH_MEMORY_PRUNE_ENABLED` (`true`, `false`, or `dry-run`) ## Roster precedence @@ -98,3 +102,19 @@ If you are developing `torch-lock` itself: + + +## Memory rollout plan + +1. Deploy memory schema changes with scheduler jobs disabled (`TORCH_MEMORY_ENABLED=false` or subsystem flags set to `false`). +2. Enable ingest for one canary `agent_id` via `TORCH_MEMORY_INGEST_ENABLED=`. +3. Validate retrieval quality and storage growth metrics before expanding scope. +4. Enable broader retrieval (`TORCH_MEMORY_RETRIEVAL_ENABLED=` then `true`). +5. Enable pruning in `dry-run` mode first, then switch to active pruning after revalidation. + +## Memory rollback plan + +1. Disable memory flags (`TORCH_MEMORY_ENABLED=false` and/or set ingest/retrieval/prune flags to `false`). +2. Stop memory maintenance scheduler processes. +3. Preserve database state for post-incident analysis; do not drop or rewrite memory tables during rollback. +4. Keep prune actions in `dry-run` (or disabled) until lifecycle policy and data integrity are revalidated. diff --git a/src/docs/TORCH.md b/src/docs/TORCH.md index 6c27215..c7af3b7 100644 --- a/src/docs/TORCH.md +++ b/src/docs/TORCH.md @@ -36,6 +36,10 @@ node src/nostr-lock.mjs list - `NOSTR_LOCK_WEEKLY_ROSTER` - `TORCH_CONFIG_PATH` - `AGENT_PLATFORM` +- `TORCH_MEMORY_ENABLED` (`true`/`false`; global memory kill switch, defaults to enabled) +- `TORCH_MEMORY_INGEST_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list) +- `TORCH_MEMORY_RETRIEVAL_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list) +- `TORCH_MEMORY_PRUNE_ENABLED` (`true`, `false`, or `dry-run`) ## torch-config.json @@ -75,3 +79,19 @@ The lock CLI resolves roster names in this order: - `1`: usage error - `2`: relay/network error - `3`: lock denied (already locked or race lost) + + +## Memory rollout plan + +1. Deploy memory schema changes with scheduler jobs disabled (`TORCH_MEMORY_ENABLED=false` or subsystem flags set to `false`). +2. Enable ingest for one canary `agent_id` via `TORCH_MEMORY_INGEST_ENABLED=`. +3. Validate retrieval quality and storage growth metrics before expanding scope. +4. Enable broader retrieval (`TORCH_MEMORY_RETRIEVAL_ENABLED=` then `true`). +5. Enable pruning in `dry-run` mode first, then switch to active pruning after revalidation. + +## Memory rollback plan + +1. Disable memory flags (`TORCH_MEMORY_ENABLED=false` and/or set ingest/retrieval/prune flags to `false`). +2. Stop memory maintenance scheduler processes. +3. Preserve database state for post-incident analysis; do not drop or rewrite memory tables during rollback. +4. Keep prune actions in `dry-run` (or disabled) until lifecycle policy and data integrity are revalidated. diff --git a/src/services/memory/feature-flags.js b/src/services/memory/feature-flags.js new file mode 100644 index 0000000..784f376 --- /dev/null +++ b/src/services/memory/feature-flags.js @@ -0,0 +1,61 @@ +const FALSEY = new Set(['0', 'false', 'off', 'no', 'disabled']); +const TRUTHY = new Set(['1', 'true', 'on', 'yes', 'enabled', 'all']); + +function normalize(value) { + return String(value ?? '').trim(); +} + +function parseAgentToggle(value) { + const raw = normalize(value); + if (!raw) return { kind: 'unset' }; + + const lower = raw.toLowerCase(); + if (FALSEY.has(lower)) return { kind: 'off' }; + if (TRUTHY.has(lower)) return { kind: 'all' }; + + const allowList = raw + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + return allowList.length > 0 ? { kind: 'allow-list', allowList } : { kind: 'off' }; +} + +function isToggleEnabledForAgent(value, agentId, fallbackWhenUnset) { + const parsed = parseAgentToggle(value); + if (parsed.kind === 'unset') return fallbackWhenUnset; + if (parsed.kind === 'off') return false; + if (parsed.kind === 'all') return true; + if (!agentId) return false; + return parsed.allowList.includes(String(agentId)); +} + +export function isMemoryEnabled(env = process.env) { + return isToggleEnabledForAgent(env.TORCH_MEMORY_ENABLED, undefined, true); +} + +export function isMemoryIngestEnabled(agentId, env = process.env) { + if (!isMemoryEnabled(env)) return false; + return isToggleEnabledForAgent(env.TORCH_MEMORY_INGEST_ENABLED, agentId, true); +} + +export function isMemoryRetrievalEnabled(agentId, env = process.env) { + if (!isMemoryEnabled(env)) return false; + return isToggleEnabledForAgent(env.TORCH_MEMORY_RETRIEVAL_ENABLED, agentId, false); +} + +export function getMemoryPruneMode(env = process.env) { + if (!isMemoryEnabled(env)) return 'off'; + + const raw = normalize(env.TORCH_MEMORY_PRUNE_ENABLED); + if (!raw) return 'active'; + + const lower = raw.toLowerCase(); + if (FALSEY.has(lower)) return 'off'; + if (['dry-run', 'dryrun', 'report', 'audit'].includes(lower)) return 'dry-run'; + return 'active'; +} + +export function isMemoryPruneEnabled(env = process.env) { + return getMemoryPruneMode(env) === 'active'; +} diff --git a/src/services/memory/formatter.js b/src/services/memory/formatter.js index 8b04cc1..3f3509b 100644 --- a/src/services/memory/formatter.js +++ b/src/services/memory/formatter.js @@ -1,3 +1,5 @@ +import { isMemoryRetrievalEnabled } from './feature-flags.js'; +export { isMemoryRetrievalEnabled } from './feature-flags.js'; const DEFAULT_TOKEN_BUDGET = 1000; const DEFAULT_MAX_SUMMARY_CHARS = 240; const DEFAULT_MAX_CONTEXT_CHARS = 320; @@ -130,32 +132,6 @@ function toContextString(agentContext) { return String(agentContext).trim(); } -/** - * Supports safe rollout and canarying with one env var: - * - true/1/on/all => enabled for all agents - * - false/0/off/no/empty => disabled - * - comma-separated agent ids => enabled only for matching ids - * - * @param {string} agentId - * @param {Record} [env] - */ -export function isMemoryRetrievalEnabled(agentId, env = process.env) { - const raw = String(env.TORCH_MEMORY_RETRIEVAL_ENABLED ?? '').trim(); - if (!raw) return false; - - const normalized = raw.toLowerCase(); - if (['0', 'false', 'off', 'no', 'disabled'].includes(normalized)) return false; - if (['1', 'true', 'on', 'yes', 'enabled', 'all'].includes(normalized)) return true; - - const allowList = raw - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - - if (allowList.length === 0) return false; - return allowList.includes(agentId); -} - /** * Builds a final prompt with retrieved memory context inserted ahead of the base prompt. * diff --git a/src/services/memory/index.js b/src/services/memory/index.js index 4922ee5..29c8ee3 100644 --- a/src/services/memory/index.js +++ b/src/services/memory/index.js @@ -3,6 +3,7 @@ import { ingestMemoryWindow } from './ingestor.js'; import { listPruneCandidates, selectPrunableMemories } from './pruner.js'; import { filterAndRankMemories, updateMemoryUsage } from './retriever.js'; import { startScheduler } from './scheduler.js'; +import { getMemoryPruneMode, isMemoryIngestEnabled } from './feature-flags.js'; const memoryStore = new Map(); const cache = createMemoryCache(); @@ -133,6 +134,10 @@ function recordThroughput(samples, now) { export async function ingestEvents(events, options = {}) { const repository = options.repository ?? memoryRepository; const telemetry = buildTelemetryEmitter(options); + if (!isMemoryIngestEnabled(options.agent_id, options.env)) { + telemetry('memory:ingest_skipped', { reason: 'flag_disabled', agent_id: options.agent_id ?? null }); + return []; + } const records = await ingestMemoryWindow({ events }, { ...options, repository, @@ -201,6 +206,7 @@ export async function runPruneCycle(options = {}) { const repository = options.repository ?? memoryRepository; const telemetry = buildTelemetryEmitter(options); const retentionMs = options.retentionMs ?? (1000 * 60 * 60 * 24 * 30); + const pruneMode = options.pruneMode ?? getMemoryPruneMode(options.env); const dbCandidates = await listPruneCandidates(repository, { retentionMs, now: options.now, @@ -213,6 +219,21 @@ export async function runPruneCycle(options = {}) { now: options.now, }); + if (pruneMode === 'off') { + telemetry('memory:prune_skipped', { reason: 'flag_disabled', retention_ms: retentionMs }); + return { pruned: [], mode: 'off' }; + } + + if (pruneMode === 'dry-run') { + telemetry('memory:pruned', { + pruned_count: prunable.length, + retention_ms: retentionMs, + dry_run: true, + }); + emitMetric(options, 'memory_pruned_total', { count: 0, retention_ms: retentionMs, dry_run: true }); + return { pruned: [], candidates: prunable, mode: 'dry-run' }; + } + for (const memory of prunable) { memoryStore.delete(memory.id); } @@ -229,7 +250,7 @@ export async function runPruneCycle(options = {}) { const result = { pruned: prunable }; if (Number.isFinite(options.scheduleEveryMs) && options.scheduleEveryMs > 0) { - result.scheduler = startScheduler(() => runPruneCycle({ retentionMs, repository }), { + result.scheduler = startScheduler(() => runPruneCycle({ retentionMs, repository, env: options.env, pruneMode: options.pruneMode }), { intervalMs: options.scheduleEveryMs, }); } diff --git a/src/services/memory/scheduler.js b/src/services/memory/scheduler.js index 562a835..533734d 100644 --- a/src/services/memory/scheduler.js +++ b/src/services/memory/scheduler.js @@ -1,3 +1,4 @@ +import { getMemoryPruneMode, isMemoryEnabled, isMemoryIngestEnabled } from './feature-flags.js'; const MINUTE_MS = 60_000; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; @@ -182,6 +183,21 @@ async function runScheduledJob(options) { } } +function shouldRunJob(jobName, options) { + const env = options.env ?? process.env; + if (!isMemoryEnabled(env)) return false; + + if (jobName === 'ingestRecentRuntimeEvents') { + return isMemoryIngestEnabled(options.ingestAgentId, env); + } + + if (jobName === 'pruningCycle') { + return getMemoryPruneMode(env) !== 'off'; + } + + return true; +} + /** * @param {{ * handlers: { @@ -218,6 +234,17 @@ export function startMemoryMaintenanceScheduler(options) { const startFixedIntervalJob = (jobName, definition, handler) => { const run = () => { + if (!shouldRunJob(jobName, options)) { + options.emitMetric?.('memory_scheduler_job', { + job: jobName, + status: 'skipped_flag_disabled', + durationMs: 0, + itemCount: 0, + failures: 0, + retries: 0, + }); + return; + } void runJob(jobName, definition, handler); }; @@ -232,6 +259,18 @@ export function startMemoryMaintenanceScheduler(options) { const delayMs = definition.minIntervalMs + Math.floor((definition.maxIntervalMs - definition.minIntervalMs) * jitterRatio); const timeout = setTimeout(async () => { timeouts.delete(timeout); + if (!shouldRunJob(jobName, options)) { + options.emitMetric?.('memory_scheduler_job', { + job: jobName, + status: 'skipped_flag_disabled', + durationMs: 0, + itemCount: 0, + failures: 0, + retries: 0, + }); + scheduleNext(); + return; + } await runJob(jobName, definition, handler); scheduleNext(); }, delayMs); @@ -239,6 +278,18 @@ export function startMemoryMaintenanceScheduler(options) { }; if (options.runImmediately !== false) { + if (!shouldRunJob(jobName, options)) { + options.emitMetric?.('memory_scheduler_job', { + job: jobName, + status: 'skipped_flag_disabled', + durationMs: 0, + itemCount: 0, + failures: 0, + retries: 0, + }); + scheduleNext(); + return; + } void runJob(jobName, definition, handler).then(() => { scheduleNext(); }); diff --git a/test/memory-admin.test.mjs b/test/memory-admin.test.mjs index 4adbba8..7f43e0e 100644 --- a/test/memory-admin.test.mjs +++ b/test/memory-admin.test.mjs @@ -8,6 +8,7 @@ import { listMemories, memoryStats, pinMemory, + runPruneCycle, triggerPruneDryRun, unpinMemory, } from '../src/services/memory/index.js'; @@ -104,3 +105,31 @@ test('memoryStats reports counts/rates and retrieval telemetry remains redaction assert.ok(stats.indexSizeEstimateBytes >= 0); assert.ok(Number.isFinite(stats.ingestThroughputPerMinute)); }); + + +test('runPruneCycle respects prune feature-flag modes', async () => { + const repository = { + async listPruneCandidates() { + return [ + { id: 'old-1', agent_id: 'agent-z', type: 'event', pinned: false, last_seen: 1, merged_into: null }, + ]; + }, + }; + + const dryRunResult = await runPruneCycle({ + repository, + env: { TORCH_MEMORY_PRUNE_ENABLED: 'dry-run' }, + retentionMs: 1000, + }); + assert.equal(dryRunResult.mode, 'dry-run'); + assert.equal(dryRunResult.pruned.length, 0); + assert.equal(dryRunResult.candidates.length, 1); + + const offResult = await runPruneCycle({ + repository, + env: { TORCH_MEMORY_PRUNE_ENABLED: 'false' }, + retentionMs: 1000, + }); + assert.equal(offResult.mode, 'off'); + assert.equal(offResult.pruned.length, 0); +}); diff --git a/test/memory-feature-flags.test.mjs b/test/memory-feature-flags.test.mjs new file mode 100644 index 0000000..ef0cff2 --- /dev/null +++ b/test/memory-feature-flags.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + getMemoryPruneMode, + isMemoryEnabled, + isMemoryIngestEnabled, + isMemoryPruneEnabled, +} from '../src/services/memory/feature-flags.js'; + +test('memory feature flags parse defaults and canary allow-lists', () => { + assert.equal(isMemoryEnabled({}), true); + assert.equal(isMemoryEnabled({ TORCH_MEMORY_ENABLED: 'false' }), false); + + assert.equal(isMemoryIngestEnabled('agent-a', {}), true); + assert.equal(isMemoryIngestEnabled('agent-a', { TORCH_MEMORY_INGEST_ENABLED: 'false' }), false); + assert.equal(isMemoryIngestEnabled('agent-a', { TORCH_MEMORY_INGEST_ENABLED: 'agent-a,agent-b' }), true); + assert.equal(isMemoryIngestEnabled('agent-c', { TORCH_MEMORY_INGEST_ENABLED: 'agent-a,agent-b' }), false); +}); + +test('memory prune flag supports off, dry-run, and active modes', () => { + assert.equal(getMemoryPruneMode({}), 'active'); + assert.equal(getMemoryPruneMode({ TORCH_MEMORY_PRUNE_ENABLED: 'false' }), 'off'); + assert.equal(getMemoryPruneMode({ TORCH_MEMORY_PRUNE_ENABLED: 'dry-run' }), 'dry-run'); + assert.equal(getMemoryPruneMode({ TORCH_MEMORY_PRUNE_ENABLED: 'true' }), 'active'); + assert.equal(isMemoryPruneEnabled({ TORCH_MEMORY_PRUNE_ENABLED: 'dry-run' }), false); + assert.equal(isMemoryPruneEnabled({ TORCH_MEMORY_PRUNE_ENABLED: 'true' }), true); +}); diff --git a/test/memory-retriever.test.mjs b/test/memory-retriever.test.mjs index 9fee8e6..24c8689 100644 --- a/test/memory-retriever.test.mjs +++ b/test/memory-retriever.test.mjs @@ -143,6 +143,13 @@ test('isMemoryRetrievalEnabled supports global toggle and agent canary list', () isMemoryRetrievalEnabled('agent-c', { TORCH_MEMORY_RETRIEVAL_ENABLED: 'agent-a,agent-b' }), false, ); + assert.equal( + isMemoryRetrievalEnabled('agent-a', { + TORCH_MEMORY_ENABLED: 'false', + TORCH_MEMORY_RETRIEVAL_ENABLED: 'true', + }), + false, + ); }); test('renderPromptWithMemoryContext injects bounded memory block before base prompt', async () => { diff --git a/test/memory-scheduler.test.mjs b/test/memory-scheduler.test.mjs index 5770741..d609ae6 100644 --- a/test/memory-scheduler.test.mjs +++ b/test/memory-scheduler.test.mjs @@ -90,3 +90,37 @@ test('startMemoryMaintenanceScheduler runs all jobs and emits metrics including assert.equal(byJob.pruningCycle[0].payload.status, 'success'); assert.equal(byJob.deepMergeArchivalMaintenance[0].payload.status, 'success'); }); + + +test('startMemoryMaintenanceScheduler skips jobs when feature flags disable memory', async () => { + const metrics = []; + const called = []; + + const scheduler = startMemoryMaintenanceScheduler({ + runImmediately: true, + env: { TORCH_MEMORY_ENABLED: 'false' }, + emitMetric(name, payload) { + metrics.push({ name, payload }); + }, + handlers: { + async ingestRecentRuntimeEvents() { + called.push('ingest'); + }, + async consolidateObservations() { + called.push('consolidate'); + }, + async pruningCycle() { + called.push('prune'); + }, + async deepMergeArchivalMaintenance() { + called.push('merge'); + }, + }, + }); + + await wait(10); + scheduler.stop(); + + assert.deepEqual(called, []); + assert.ok(metrics.some((entry) => entry.payload.status === 'skipped_flag_disabled')); +});