Merge branch 'main' into test/upload-validation-15691315589845316357

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-21 14:13:15 -05:00
commit 67ebd6a504
33 files changed
+9337 -1079

No files matched your search

+10 -7
View File
@@ -2,8 +2,6 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { execSync } from 'node:child_process';
const BACKUP_ROOT = path.resolve(process.cwd(), '.torch/backups');
// Paths to snapshot on each backup run
const STATE_SOURCES = [
'.scheduler-memory/memory-store.json',
@@ -22,19 +20,21 @@ const STATE_SOURCES = [
*
* @param {Object} [opts]
* @param {string|null} [opts.output] - Override destination directory (default: .torch/backups/<ts>)
* @param {string} [opts.cwd] - Working directory (default: process.cwd())
* @returns {Promise<{backupDir: string, manifest: Object}>}
*/
export async function cmdBackup({ output = null } = {}) {
export async function cmdBackup({ output = null, cwd = process.cwd() } = {}) {
const BACKUP_ROOT = path.resolve(cwd, '.torch/backups');
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const backupDir = output
? path.resolve(process.cwd(), output)
? path.resolve(cwd, output)
: path.join(BACKUP_ROOT, ts);
await fs.mkdir(backupDir, { recursive: true });
let gitCommit = null;
try {
gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim();
gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe', cwd }).toString().trim();
} catch {
// Not in a git repo or git unavailable
}
@@ -43,7 +43,7 @@ export async function cmdBackup({ output = null } = {}) {
const skipped = [];
for (const relPath of STATE_SOURCES) {
const src = path.resolve(process.cwd(), relPath);
const src = path.resolve(cwd, relPath);
const dest = path.join(backupDir, relPath.replace(/\//g, '__'));
try {
await fs.copyFile(src, dest);
@@ -77,9 +77,12 @@ export async function cmdBackup({ output = null } = {}) {
/**
* Lists all available backups under .torch/backups/, newest first.
*
* @param {Object} [opts]
* @param {string} [opts.cwd] - Working directory (default: process.cwd())
* @returns {Promise<Array<{id: string, createdAt: string|null, backupDir: string}>>}
*/
export async function listBackups() {
export async function listBackups({ cwd = process.cwd() } = {}) {
const BACKUP_ROOT = path.resolve(cwd, '.torch/backups');
try {
const entries = await fs.readdir(BACKUP_ROOT, { withFileTypes: true });
const backups = [];
+223
View File
@@ -0,0 +1,223 @@
import { generateSecretKey as _generateSecretKey, getPublicKey as _getPublicKey, finalizeEvent as _finalizeEvent } from 'nostr-tools/pure';
import {
getRelays as _getRelays,
getNamespace as _getNamespace,
getHashtag as _getHashtag,
getTtl as _getTtl,
} from './torch-config.mjs';
import {
KIND_APP_DATA,
RACE_CHECK_DELAY_MS,
MS_PER_SECOND,
} from './constants.mjs';
import { getRoster as _getRoster } from './roster.mjs';
import { queryLocks as _queryLocks, publishLock as _publishLock } from './lock-ops.mjs';
import { ExitError } from './errors.mjs';
import { todayDateStr, nowUnix, detectPlatform } from './utils.mjs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { execSync } from 'node:child_process';
function getGitCommit() {
try {
return execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim();
} catch {
return null;
}
}
async function getPromptInfo(agent, cadence) {
try {
const potentialPath = path.join(process.cwd(), 'src', 'prompts', cadence, `${agent}.md`);
const content = await fs.readFile(potentialPath, 'utf8');
const promptHash = createHash('sha256').update(content).digest('hex');
const promptPath = `src/prompts/${cadence}/${agent}.md`;
return { promptHash, promptPath };
} catch {
return { promptHash: null, promptPath: null };
}
}
function validateRoster(agent, roster, cadence, error) {
if (!roster.includes(agent)) {
error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
throw new ExitError(1, 'Agent not in roster');
}
}
function checkExistingLocks(existingLocks, agent, error, log) {
const conflicting = existingLocks.filter((l) => l.agent === agent);
if (conflicting.length > 0) {
const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0];
if (earliest.status === 'completed') {
error(`LOCK DENIED: Task already completed by event ${earliest.eventId}`);
log('LOCK_STATUS=denied');
log('LOCK_REASON=already_completed');
throw new ExitError(3, 'Task already completed');
}
error(
`LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` +
`(created ${earliest.createdAtIso}, platform: ${earliest.platform})`,
);
log('LOCK_STATUS=denied');
log('LOCK_REASON=already_locked');
log(`LOCK_EXISTING_EVENT=${earliest.eventId}`);
throw new ExitError(3, 'Lock denied');
}
}
function buildLockEvent(agent, cadence, namespace, hashtag, dateStr, now, expiresAt, platform, model, gitCommit, promptPath, promptHash, finalizeEvent, sk) {
return finalizeEvent(
{
kind: KIND_APP_DATA,
created_at: now,
tags: [
['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`],
['t', hashtag],
['t', `${namespace}-lock-${cadence}`],
['t', `${namespace}-lock-${cadence}-${dateStr}`],
['expiration', String(expiresAt)],
],
content: JSON.stringify({
agent,
cadence,
status: 'started',
namespace,
date: dateStr,
platform: platform || process.env.AGENT_PLATFORM || detectPlatform() || 'unknown',
model: model || process.env.AGENT_MODEL || 'unknown',
lockedAt: new Date(now * MS_PER_SECOND).toISOString(),
expiresAt: new Date(expiresAt * MS_PER_SECOND).toISOString(),
gitCommit,
promptPath,
promptHash,
}),
},
sk,
);
}
async function performRaceCheck(relays, cadence, dateStr, namespace, agent, myEventId, queryLocks, raceCheckDelayMs, error, log) {
await new Promise((resolve) => setTimeout(resolve, raceCheckDelayMs));
const postLocks = await queryLocks(relays, cadence, dateStr, namespace);
const racingLocks = postLocks
.filter((l) => l.agent === agent)
.sort((a, b) => (a.createdAt - b.createdAt) || String(a.eventId).localeCompare(String(b.eventId)));
if (racingLocks.length > 1 && racingLocks[0].eventId !== myEventId) {
const winner = racingLocks[0];
error(
`RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`,
);
log('LOCK_STATUS=race_lost');
log('LOCK_REASON=earlier_claim_exists');
log(`LOCK_WINNER_EVENT=${winner.eventId}`);
throw new ExitError(3, 'Race check lost');
}
}
/**
* Attempts to acquire an exclusive lock for an agent on the specified cadence.
*
* Algorithm:
* 1. Validate agent against the roster.
* 2. Query relays for existing valid locks (checking for conflicts).
* 3. Generate a new ephemeral keypair and build a lock event (kind 30078).
* 4. Publish the lock event to relays.
* 5. Wait for propagation (raceCheckDelayMs) and re-query to confirm no earlier lock won the race.
*
* @param {string} agent - Agent name
* @param {string} cadence - 'daily' or 'weekly'
* @param {boolean} [dryRun=false] - If true, skips publishing
* @param {Object} [deps] - Dependency injection
* @returns {Promise<{status: string, eventId: string}>}
* @throws {ExitError} If lock is denied (already locked, completed, or race lost)
*/
export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {}) {
const options = typeof optionsOrDryRun === 'object' ? optionsOrDryRun : { dryRun: !!optionsOrDryRun };
const { dryRun = false, platform = null, model = null } = options;
const {
getRelays = _getRelays,
getNamespace = _getNamespace,
getHashtag = _getHashtag,
getTtl = _getTtl,
queryLocks = _queryLocks,
getRoster = _getRoster,
publishLock = _publishLock,
generateSecretKey = _generateSecretKey,
getPublicKey = _getPublicKey,
finalizeEvent = _finalizeEvent,
raceCheckDelayMs = RACE_CHECK_DELAY_MS,
getDateStr = todayDateStr,
log = console.log,
error = console.error
} = deps;
const relays = await getRelays();
const namespace = await getNamespace();
const hashtag = await getHashtag();
const dateStr = getDateStr();
const ttl = await getTtl();
const now = nowUnix();
const expiresAt = now + ttl;
const gitCommit = getGitCommit();
const { promptHash, promptPath } = await getPromptInfo(agent, cadence);
error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`);
error(`Hashtag: #${hashtag}`);
error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * MS_PER_SECOND).toISOString()}`);
error(`Relays: ${relays.join(', ')}`);
if (gitCommit) error(`Git Commit: ${gitCommit}`);
if (promptHash) error(`Prompt Hash: ${promptHash.slice(0, 12)}...`);
const roster = await getRoster(cadence);
validateRoster(agent, roster, cadence, error);
error('Step 1: Checking for existing locks...');
const existingLocks = await queryLocks(relays, cadence, dateStr, namespace);
checkExistingLocks(existingLocks, agent, error, log);
error('Step 2: Generating ephemeral keypair...');
const sk = generateSecretKey();
const pk = getPublicKey(sk);
error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`);
error('Step 3: Building lock event...');
const event = buildLockEvent(agent, cadence, namespace, hashtag, dateStr, now, expiresAt, platform, model, gitCommit, promptPath, promptHash, finalizeEvent, sk);
error(` Event ID: ${event.id}`);
if (dryRun) {
error('Step 4: [DRY RUN] Skipping publish — event built but not sent');
error('RACE CHECK: won (dry run — no real contention possible)');
} else {
error('Step 4: Publishing to relays...');
await publishLock(relays, event);
error('Step 5: Race check...');
await performRaceCheck(relays, cadence, dateStr, namespace, agent, event.id, queryLocks, raceCheckDelayMs, error, log);
error('RACE CHECK: won');
}
log('LOCK_STATUS=ok');
log(`LOCK_EVENT_ID=${event.id}`);
log(`LOCK_PUBKEY=${pk}`);
log(`LOCK_AGENT=${agent}`);
log(`LOCK_HASHTAG=${hashtag}`);
log(`LOCK_CADENCE=${cadence}`);
log(`LOCK_DATE=${dateStr}`);
log(`LOCK_EXPIRES=${expiresAt}`);
log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * MS_PER_SECOND).toISOString()}`);
if (gitCommit) log(`LOCK_GIT_COMMIT=${gitCommit}`);
if (promptHash) log(`LOCK_PROMPT_HASH=${promptHash}`);
return { status: 'ok', eventId: event.id };
}
+54 -37
View File
@@ -1,99 +1,116 @@
import { createProposal, listProposals, applyProposal, rejectProposal, getProposal } from './services/governance/index.js';
import {
createProposal as _createProposal,
listProposals as _listProposals,
applyProposal as _applyProposal,
rejectProposal as _rejectProposal,
getProposal as _getProposal
} from './services/governance/index.js';
import { ExitError } from './errors.mjs';
import fs from 'node:fs/promises';
export async function cmdProposal(subcommand, args = {}) {
export async function cmdProposal(subcommand, args = {}, deps = {}) {
const {
createProposal = _createProposal,
listProposals = _listProposals,
applyProposal = _applyProposal,
rejectProposal = _rejectProposal,
getProposal = _getProposal,
readFile = fs.readFile,
log = console.log,
error = console.error
} = deps;
switch (subcommand) {
case 'create':
return await handleCreate(args);
return await handleCreate(args, { createProposal, readFile, log, error });
case 'list':
return await handleList(args);
return await handleList(args, { listProposals, log, error });
case 'apply':
return await handleApply(args);
return await handleApply(args, { applyProposal, log, error });
case 'reject':
return await handleReject(args);
return await handleReject(args, { rejectProposal, log, error });
case 'show':
return await handleShow(args);
return await handleShow(args, { getProposal, log, error });
default:
console.error(`Unknown proposal subcommand: ${subcommand}`);
error(`Unknown proposal subcommand: ${subcommand}`);
throw new ExitError(1, 'Unknown subcommand');
}
}
async function handleCreate({ agent, target, contentFile, reason }) {
async function handleCreate({ agent, target, contentFile, reason }, { createProposal, readFile, log, error }) {
if (!agent || !target || !contentFile || !reason) {
console.error('Usage: torch-lock proposal create --agent <name> --target <path> --content <file> --reason <text>');
error('Usage: torch-lock proposal create --agent <name> --target <path> --content <file> --reason <text>');
throw new ExitError(1, 'Missing arguments');
}
let newContent;
try {
newContent = await fs.readFile(contentFile, 'utf8');
newContent = await readFile(contentFile, 'utf8');
} catch (_e) {
console.error(`Failed to read content file: ${contentFile}`);
error(`Failed to read content file: ${contentFile}`);
throw new ExitError(1, 'File read error');
}
try {
const result = await createProposal({ agent, target, newContent, reason });
console.log(JSON.stringify(result, null, 2));
log(JSON.stringify(result, null, 2));
} catch (e) {
console.error(`Failed to create proposal: ${e.message}`);
error(`Failed to create proposal: ${e.message}`);
throw new ExitError(1, 'Proposal creation failed');
}
}
async function handleList({ status }) {
async function handleList({ status }, { listProposals, log, error }) {
try {
const proposals = await listProposals();
const filtered = status ? proposals.filter(p => p.status === status) : proposals;
console.log(JSON.stringify(filtered, null, 2));
log(JSON.stringify(filtered, null, 2));
} catch (e) {
console.error(`Failed to list proposals: ${e.message}`);
error(`Failed to list proposals: ${e.message}`);
throw new ExitError(1, 'List failed');
}
}
async function handleApply({ id }) {
async function handleApply({ id }, { applyProposal, log, error }) {
if (!id) {
console.error('Usage: torch-lock proposal apply --id <proposal-id>');
error('Usage: torch-lock proposal apply --id <proposal-id>');
throw new ExitError(1, 'Missing id');
}
try {
const result = await applyProposal(id);
console.log(JSON.stringify(result, null, 2));
log(JSON.stringify(result, null, 2));
} catch (e) {
console.error(`Failed to apply proposal: ${e.message}`);
error(`Failed to apply proposal: ${e.message}`);
throw new ExitError(1, 'Apply failed');
}
}
async function handleReject({ id, reason }) {
async function handleReject({ id, reason }, { rejectProposal, log, error }) {
if (!id || !reason) {
console.error('Usage: torch-lock proposal reject --id <proposal-id> --reason <text>');
error('Usage: torch-lock proposal reject --id <proposal-id> --reason <text>');
throw new ExitError(1, 'Missing arguments');
}
try {
const result = await rejectProposal(id, reason);
console.log(JSON.stringify(result, null, 2));
log(JSON.stringify(result, null, 2));
} catch (e) {
console.error(`Failed to reject proposal: ${e.message}`);
error(`Failed to reject proposal: ${e.message}`);
throw new ExitError(1, 'Reject failed');
}
}
async function handleShow({ id }) {
if (!id) {
console.error('Usage: torch-lock proposal show --id <proposal-id>');
throw new ExitError(1, 'Missing id');
}
try {
const proposal = await getProposal(id);
console.log(JSON.stringify(proposal, null, 2));
} catch (e) {
console.error(`Failed to show proposal: ${e.message}`);
throw new ExitError(1, 'Show failed');
}
async function handleShow({ id }, { getProposal, log, error }) {
if (!id) {
error('Usage: torch-lock proposal show --id <proposal-id>');
throw new ExitError(1, 'Missing id');
}
try {
const proposal = await getProposal(id);
log(JSON.stringify(proposal, null, 2));
} catch (e) {
error(`Failed to show proposal: ${e.message}`);
throw new ExitError(1, 'Show failed');
}
}
+3
View File
@@ -53,6 +53,9 @@ Commands:
init [--force] Initialize torch/ directory in current project
update [--force] Update torch/ configuration (backups, merges)
remove [--force] Remove all TORCH files and configuration from project
backup [--list] [--output <path>] Snapshot runtime state to .torch/backups/
proposal <create|list|apply|reject|show> [...] Manage prompt change proposals
rollback --target <file> [--list] Roll back a prompt to a previous version
list-memories [--agent <id>] [--type <type>] [--tags <a,b>] [--pinned <true|false>] [--full]
inspect-memory --id <memoryId>
@@ -0,0 +1,25 @@
---
agent: content-audit-agent
cadence: daily
session: 2026-02-21T18-44-11Z
run-start: 2026-02-21T18-44-11Z
prompt: src/prompts/daily/content-audit-agent.md
---
# Context Note — Content Audit — 2026-02-21
## Goal
Verify documentation accuracy against repository implementation.
## Scope
- `README.md`
- `TORCH.md`
- `bin/torch-lock.mjs`
- `src/constants.mjs`
## Findings
- `USAGE_TEXT` in `src/constants.mjs` was missing `proposal`, `rollback`, and `backup` commands, despite them being implemented and documented.
- `README.md` correctly lists these commands.
- `npm run validate:docs` passed.
## Constraints
- Limited to correcting specific inaccuracies.
+73 -1
View File
@@ -31,16 +31,72 @@ function timingSafeCompare(a, b) {
return crypto.timingSafeEqual(bufA, bufB);
}
function verifyPassword(input, stored) {
if (stored.startsWith('scrypt:')) {
const parts = stored.split(':');
if (parts.length !== 3) return false;
try {
const salt = Buffer.from(parts[1], 'hex');
const hash = Buffer.from(parts[2], 'hex');
const derived = crypto.scryptSync(input, salt, 64);
return crypto.timingSafeEqual(derived, hash);
} catch {
return false;
}
}
return timingSafeCompare(input, stored);
}
export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.0.1') {
// Resolve package root relative to this file (src/dashboard.mjs)
// this file is in <root>/src/dashboard.mjs, so '..' goes to <root>
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const auth = await getDashboardAuth();
// Rate limiting state
const MAX_AUTH_ATTEMPTS = 5;
const BLOCK_DURATION_MS = 300000; // 5 minutes
const CLEANUP_INTERVAL_MS = 600000; // 10 minutes
const rateLimit = new Map(); // ip -> { attempts, blockExpires, lastSeen }
// Cleanup old entries
const cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [ip, data] of rateLimit.entries()) {
if (data.blockExpires && data.blockExpires < now) {
rateLimit.delete(ip);
} else if (!data.blockExpires && (now - data.lastSeen > CLEANUP_INTERVAL_MS)) {
rateLimit.delete(ip);
}
}
}, CLEANUP_INTERVAL_MS);
const server = http.createServer(async (req, res) => {
try {
// Basic Auth check
if (auth) {
const clientIp = req.socket.remoteAddress;
const now = Date.now();
// Check rate limit
const clientState = rateLimit.get(clientIp) || { attempts: 0, blockExpires: 0, lastSeen: now };
// Update last seen
clientState.lastSeen = now;
if (clientState.blockExpires > now) {
res.writeHead(429, {
...SECURITY_HEADERS,
'Retry-After': Math.ceil((clientState.blockExpires - now) / 1000)
});
res.end('Too Many Requests');
return;
} else if (clientState.blockExpires) {
// Block expired, reset state
clientState.attempts = 0;
clientState.blockExpires = 0;
}
const authHeader = req.headers.authorization;
if (!authHeader) {
res.writeHead(401, { ...SECURITY_HEADERS, 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' });
@@ -54,17 +110,29 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
if (type === 'Basic' && credentials) {
try {
const decoded = Buffer.from(credentials, 'base64').toString();
isValid = timingSafeCompare(decoded, auth);
isValid = verifyPassword(decoded, auth);
} catch {
isValid = false;
}
}
if (!isValid) {
// Increment attempts
clientState.attempts += 1;
if (clientState.attempts >= MAX_AUTH_ATTEMPTS) {
clientState.blockExpires = Date.now() + BLOCK_DURATION_MS;
}
rateLimit.set(clientIp, clientState);
res.writeHead(401, { ...SECURITY_HEADERS, 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' });
res.end('Invalid credentials');
return;
}
// Reset attempts on success
if (rateLimit.has(clientIp)) {
rateLimit.delete(clientIp);
}
}
// URL parsing
@@ -219,6 +287,10 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
}
});
server.on('close', () => {
clearInterval(cleanupInterval);
});
return new Promise((resolve, reject) => {
server.listen(port, host, () => {
const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`;
@@ -0,0 +1,12 @@
---
agent: content-audit-agent
cadence: daily
session: 2026-02-21T18-44-11Z
run-start: 2026-02-21T18-44-11Z
prompt: src/prompts/daily/content-audit-agent.md
---
# Decision Note — Content Audit — 2026-02-21
## Update `src/constants.mjs` Usage Text
**Decision:** Add `proposal`, `rollback`, and `backup` commands to `USAGE_TEXT`.
**Rationale:** The implementation exists in `src/lib.mjs` and commands are documented in `README.md`. The CLI help text was incomplete, leading to potential user confusion.
+2 -191
View File
@@ -10,13 +10,11 @@ import {
loadTorchConfig as _loadTorchConfig,
getRelays as _getRelays,
getNamespace as _getNamespace,
getTtl as _getTtl,
getHashtag as _getHashtag,
} from './torch-config.mjs';
import {
VALID_CADENCES,
KIND_APP_DATA,
RACE_CHECK_DELAY_MS,
USAGE_TEXT,
MS_PER_SECOND,
} from './constants.mjs';
@@ -38,16 +36,15 @@ import { todayDateStr, nowUnix, detectPlatform } from './utils.mjs';
import { runRelayHealthCheck } from './relay-health.mjs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { execSync } from 'node:child_process';
import { getCompletedAgents } from './lock-utils.mjs';
import { cmdProposal } from './cmd-proposal.mjs';
import { cmdRollback } from './cmd-rollback.mjs';
import { cmdLock } from './cmd-lock.mjs';
useWebSocketImplementation(WebSocket);
// Re-export for backward compatibility/library usage
export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock };
export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock, cmdLock };
/**
* Checks the status of the repository locks for a given cadence.
@@ -147,192 +144,6 @@ export async function cmdCheck(cadence, deps = {}) {
return result;
}
/**
* Attempts to acquire an exclusive lock for an agent on the specified cadence.
*
* Algorithm:
* 1. Validate agent against the roster.
* 2. Query relays for existing valid locks (checking for conflicts).
* 3. Generate a new ephemeral keypair and build a lock event (kind 30078).
* 4. Publish the lock event to relays.
* 5. Wait for propagation (raceCheckDelayMs) and re-query to confirm no earlier lock won the race.
*
* @param {string} agent - Agent name
* @param {string} cadence - 'daily' or 'weekly'
* @param {boolean} [dryRun=false] - If true, skips publishing
* @param {Object} [deps] - Dependency injection
* @returns {Promise<{status: string, eventId: string}>}
* @throws {ExitError} If lock is denied (already locked, completed, or race lost)
*/
export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {}) {
const options = typeof optionsOrDryRun === 'object' ? optionsOrDryRun : { dryRun: !!optionsOrDryRun };
const { dryRun = false, platform = null, model = null } = options;
const {
getRelays = _getRelays,
getNamespace = _getNamespace,
getHashtag = _getHashtag,
getTtl = _getTtl,
queryLocks = _queryLocks,
getRoster = _getRoster,
publishLock = _publishLock,
generateSecretKey = _generateSecretKey,
getPublicKey = _getPublicKey,
finalizeEvent = _finalizeEvent,
raceCheckDelayMs = RACE_CHECK_DELAY_MS,
getDateStr = todayDateStr,
log = console.log,
error = console.error
} = deps;
const relays = await getRelays();
const namespace = await getNamespace();
const hashtag = await getHashtag();
const dateStr = getDateStr();
const ttl = await getTtl();
const now = nowUnix();
const expiresAt = now + ttl;
let gitCommit = null;
try {
gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim();
} catch {
// Ignore git errors (not a git repo, etc)
}
let promptHash = null;
let promptPath = null;
try {
// Attempt to locate the prompt file based on standard structure
// We assume the CWD is the project root or we are in a standard structure.
// If not found, we just omit the hash.
const potentialPath = path.join(process.cwd(), 'src', 'prompts', cadence, `${agent}.md`);
const content = await fs.readFile(potentialPath, 'utf8');
promptHash = createHash('sha256').update(content).digest('hex');
promptPath = `src/prompts/${cadence}/${agent}.md`;
} catch {
// Ignore missing prompt file
}
error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`);
error(`Hashtag: #${hashtag}`);
error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * MS_PER_SECOND).toISOString()}`);
error(`Relays: ${relays.join(', ')}`);
if (gitCommit) error(`Git Commit: ${gitCommit}`);
if (promptHash) error(`Prompt Hash: ${promptHash.slice(0, 12)}...`);
const roster = await getRoster(cadence);
if (!roster.includes(agent)) {
error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
throw new ExitError(1, 'Agent not in roster');
}
error('Step 1: Checking for existing locks...');
const existingLocks = await queryLocks(relays, cadence, dateStr, namespace);
const conflicting = existingLocks.filter((l) => l.agent === agent);
if (conflicting.length > 0) {
const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0];
// Check if it is a completed task
if (earliest.status === 'completed') {
error(`LOCK DENIED: Task already completed by event ${earliest.eventId}`);
log('LOCK_STATUS=denied');
log('LOCK_REASON=already_completed');
throw new ExitError(3, 'Task already completed');
}
error(
`LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` +
`(created ${earliest.createdAtIso}, platform: ${earliest.platform})`,
);
log('LOCK_STATUS=denied');
log('LOCK_REASON=already_locked');
log(`LOCK_EXISTING_EVENT=${earliest.eventId}`);
throw new ExitError(3, 'Lock denied');
}
error('Step 2: Generating ephemeral keypair...');
const sk = generateSecretKey();
const pk = getPublicKey(sk);
error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`);
error('Step 3: Building lock event...');
const event = finalizeEvent(
{
kind: KIND_APP_DATA,
created_at: now,
tags: [
['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`],
['t', hashtag],
['t', `${namespace}-lock-${cadence}`],
['t', `${namespace}-lock-${cadence}-${dateStr}`],
['expiration', String(expiresAt)],
],
content: JSON.stringify({
agent,
cadence,
status: 'started',
namespace,
date: dateStr,
platform: platform || process.env.AGENT_PLATFORM || detectPlatform() || 'unknown',
model: model || process.env.AGENT_MODEL || 'unknown',
lockedAt: new Date(now * MS_PER_SECOND).toISOString(),
expiresAt: new Date(expiresAt * MS_PER_SECOND).toISOString(),
gitCommit,
promptPath,
promptHash,
}),
},
sk,
);
error(` Event ID: ${event.id}`);
if (dryRun) {
error('Step 4: [DRY RUN] Skipping publish — event built but not sent');
error('RACE CHECK: won (dry run — no real contention possible)');
} else {
error('Step 4: Publishing to relays...');
await publishLock(relays, event);
error('Step 5: Race check...');
await new Promise((resolve) => setTimeout(resolve, raceCheckDelayMs));
const postLocks = await queryLocks(relays, cadence, dateStr, namespace);
const racingLocks = postLocks
.filter((l) => l.agent === agent)
.sort((a, b) => (a.createdAt - b.createdAt) || String(a.eventId).localeCompare(String(b.eventId)));
if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) {
const winner = racingLocks[0];
error(
`RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`,
);
log('LOCK_STATUS=race_lost');
log('LOCK_REASON=earlier_claim_exists');
log(`LOCK_WINNER_EVENT=${winner.eventId}`);
throw new ExitError(3, 'Race check lost');
}
error('RACE CHECK: won');
}
log('LOCK_STATUS=ok');
log(`LOCK_EVENT_ID=${event.id}`);
log(`LOCK_PUBKEY=${pk}`);
log(`LOCK_AGENT=${agent}`);
log(`LOCK_HASHTAG=${hashtag}`);
log(`LOCK_CADENCE=${cadence}`);
log(`LOCK_DATE=${dateStr}`);
log(`LOCK_EXPIRES=${expiresAt}`);
log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * MS_PER_SECOND).toISOString()}`);
if (gitCommit) log(`LOCK_GIT_COMMIT=${gitCommit}`);
if (promptHash) log(`LOCK_PROMPT_HASH=${promptHash}`);
return { status: 'ok', eventId: event.id };
}
/**
* Lists all active locks for the specified cadence (or all cadences if null).
* It prints a formatted table to stdout with lock age, TTL, and event ID.
+54 -129
View File
@@ -6,100 +6,14 @@ import {
getRelayFallbacks,
getMinActiveRelayPool,
} from './torch-config.mjs';
import { mergeRelayList, relayListLabel, withTimeout } from './utils.mjs';
import { relayListLabel, withTimeout, mergeRelayList } from './utils.mjs';
import { defaultHealthManager, buildRelayHealthConfig } from './relay-health-manager.mjs';
import { secureRandom } from './lock-utils.mjs';
const PUBLISH_ERROR_CODES = {
TIMEOUT: 'publish_timeout',
DNS: 'dns_resolution',
TCP: 'tcp_connect_timeout',
TLS: 'tls_handshake',
WEBSOCKET: 'websocket_open_failure',
NETWORK: 'network_timeout',
CONNECTION_RESET: 'connection_reset',
RELAY_UNAVAILABLE: 'relay_unavailable',
PERMANENT: 'permanent_validation_error',
};
const PUBLISH_FAILURE_CATEGORIES = {
QUORUM_FAILURE: 'relay_publish_quorum_failure',
NON_RETRYABLE: 'relay_publish_non_retryable',
};
/**
* Classifies a raw error message into a standardized publication error code.
* Used to determine if a failure is transient (retryable) or permanent.
*
* @param {string|Error} message - The error message or object to classify.
* @returns {string} One of the PUBLISH_ERROR_CODES constants.
*/
function classifyPublishError(message) {
const normalized = String(message || '').toLowerCase();
if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) {
return PUBLISH_ERROR_CODES.TIMEOUT;
}
if (
normalized.includes('enotfound')
|| normalized.includes('eai_again')
|| normalized.includes('getaddrinfo')
|| (normalized.includes('dns') && normalized.includes('websocket'))
) {
return PUBLISH_ERROR_CODES.DNS;
}
if (
normalized.includes('connect etimedout')
|| normalized.includes('tcp connect timed out')
|| normalized.includes('connect timeout')
) {
return PUBLISH_ERROR_CODES.TCP;
}
if (
normalized.includes('tls')
|| normalized.includes('ssl')
|| normalized.includes('certificate')
|| normalized.includes('handshake')
) {
return PUBLISH_ERROR_CODES.TLS;
}
if (
normalized.includes('websocket')
|| normalized.includes('bad response')
|| normalized.includes('unexpected server response')
) {
return PUBLISH_ERROR_CODES.WEBSOCKET;
}
if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) {
return PUBLISH_ERROR_CODES.NETWORK;
}
if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) {
return PUBLISH_ERROR_CODES.CONNECTION_RESET;
}
if (
normalized.includes('unavailable')
|| normalized.includes('offline')
|| normalized.includes('econnrefused')
|| normalized.includes('connection refused')
|| normalized.includes('enotfound')
|| normalized.includes('503')
) {
return PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE;
}
return PUBLISH_ERROR_CODES.PERMANENT;
}
function isTransientPublishCategory(category) {
return [
PUBLISH_ERROR_CODES.TIMEOUT,
PUBLISH_ERROR_CODES.DNS,
PUBLISH_ERROR_CODES.TCP,
PUBLISH_ERROR_CODES.TLS,
PUBLISH_ERROR_CODES.WEBSOCKET,
PUBLISH_ERROR_CODES.NETWORK,
PUBLISH_ERROR_CODES.CONNECTION_RESET,
PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE,
].includes(category);
}
import {
PUBLISH_FAILURE_CATEGORIES,
classifyPublishError,
isTransientPublishCategory,
} from './lock-error-classifier.mjs';
function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = secureRandom) {
const maxDelay = Math.min(capMs, baseMs * (2 ** Math.max(0, attemptNumber - 1)));
@@ -187,6 +101,17 @@ export class LockPublisher {
* @throws {Error} If publication quorum is not met after all attempts.
*/
async publish() {
this._initialize();
try {
this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:periodic');
return await this._executeRetryLoop(Date.now());
} finally {
this.pool.close(this.allRelays);
}
}
_initialize() {
const {
poolFactory = () => new SimplePool(),
retryAttempts = 4,
@@ -222,49 +147,49 @@ export class LockPublisher {
this.attemptId = diagnostics.attemptId || randomUUID();
this.allRelays = mergeRelayList(this.relays, this.fallbackRelays);
}
try {
this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:periodic');
let lastAttemptState = null;
const retryTimeline = [];
const overallStartedAt = Date.now();
let terminalFailureCategory = PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE;
async _executeRetryLoop(overallStartedAt) {
let lastAttemptState = null;
const retryTimeline = [];
let terminalFailureCategory = PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE;
// Retry loop: attempts publication until success or max attempts reached
for (let attemptNumber = 1; attemptNumber <= this.maxAttempts; attemptNumber += 1) {
const startedAtMs = Date.now();
lastAttemptState = await this.executePublishCycle();
const elapsedMs = Date.now() - startedAtMs;
retryTimeline.push({
publishAttempt: attemptNumber,
successCount: lastAttemptState.successCount,
relayAttemptedCount: lastAttemptState.attempted.size,
elapsedMs,
});
// Retry loop: attempts publication until success or max attempts reached
for (let attemptNumber = 1; attemptNumber <= this.maxAttempts; attemptNumber += 1) {
const startedAtMs = Date.now();
lastAttemptState = await this.executePublishCycle();
const elapsedMs = Date.now() - startedAtMs;
retryTimeline.push({
publishAttempt: attemptNumber,
successCount: lastAttemptState.successCount,
relayAttemptedCount: lastAttemptState.attempted.size,
elapsedMs,
});
if (lastAttemptState.successCount >= this.minSuccesses) {
this.logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt);
return this.event;
}
const { hasTransientFailure, hasPermanentFailure } = this.analyzeFailures(lastAttemptState.failures);
const canRetry = attemptNumber < this.maxAttempts && hasTransientFailure && !hasPermanentFailure;
terminalFailureCategory = this.determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber);
if (!canRetry) {
break;
}
const nextDelayMs = calculateBackoffDelayMs(attemptNumber, this.retryBaseDelayMs, this.retryCapDelayMs, this.randomFn);
this.logRetry(attemptNumber, lastAttemptState.failures, elapsedMs, nextDelayMs);
await this.sleepFn(nextDelayMs);
if (lastAttemptState.successCount >= this.minSuccesses) {
this.logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt);
return this.event;
}
this.handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt);
} finally {
this.pool.close(this.allRelays);
const { hasTransientFailure, hasPermanentFailure } = this.analyzeFailures(lastAttemptState.failures);
const canRetry = this._shouldRetry(attemptNumber, hasTransientFailure, hasPermanentFailure);
terminalFailureCategory = this.determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber);
if (!canRetry) {
break;
}
const nextDelayMs = calculateBackoffDelayMs(attemptNumber, this.retryBaseDelayMs, this.retryCapDelayMs, this.randomFn);
this.logRetry(attemptNumber, lastAttemptState.failures, elapsedMs, nextDelayMs);
await this.sleepFn(nextDelayMs);
}
this.handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt);
}
_shouldRetry(attemptNumber, hasTransientFailure, hasPermanentFailure) {
return attemptNumber < this.maxAttempts && hasTransientFailure && !hasPermanentFailure;
}
async executePublishCycle() {
+1 -7
View File
@@ -3,7 +3,7 @@ import path from 'node:path';
import { randomInt } from 'node:crypto';
import { todayDateStr, getIsoWeekStr } from './utils.mjs';
export { relayListLabel } from './utils.mjs';
export { relayListLabel, withTimeout, mergeRelayList } from './utils.mjs';
/**
* Scans the local log directory to identify agents that have already completed their task
@@ -51,12 +51,6 @@ export async function getCompletedAgents(cadence, logDir, deps) {
return completed;
}
export { withTimeout } from './utils.mjs';
export function mergeRelayList(primaryRelays, fallbackRelays) {
return [...new Set([...primaryRelays, ...fallbackRelays])];
}
const MAX_RANDOM = 281474976710655; // 2**48 - 1
export function secureRandom() {
+4 -26
View File
@@ -10,17 +10,16 @@
You are: **perf-agent**, a senior software engineer AI agent working inside this repository (default branch).
Mission: **daily, measurable improvement of app responsiveness** by finding and fixing background CPU/network work that degrades UX — and ensuring user-facing docs (`/content`) match runtime upload/contribution behavior. Make small, safe, incremental changes. Every change must be traceable, tested or manually verified, and documented.
Mission: **daily, measurable improvement of app responsiveness** by finding and fixing background CPU/network work that degrades UX. Make small, safe, incremental changes. Every change must be traceable, tested or manually verified, and documented.
-------------------------------------------------------------------------------
IDENTITY & MANDATE
- Role: senior engineer agent (implementer + doc auditor).
- Role: senior engineer agent.
- Repo: this repository — default branch.
- Focus (priority):
- **P0**: Login/auth, relay initialization & profile hydration, decryption of user lists (hashtags, subscriptions, blocks, watch history, moderation lists).
- **P1**: Anything affecting initial UI responsiveness (profile cache, relay load).
- **P2**: User-initiated heavy features (WebTorrent playback) — lazy-init/deprioritize until P0/P1 resolved.
- **Docs**: `/content` — keep upload/contribution docs accurate and runnable.
-------------------------------------------------------------------------------
PRIMARY GOALS & SUCCESS CRITERIA
@@ -28,11 +27,9 @@ PRIMARY GOALS & SUCCESS CRITERIA
1. Finds expensive background patterns (main-thread loops, unbounded concurrency, eager socket/torrent creation).
2. Applies small safety-first fixes (visibility-gating, bounded concurrency, workerization, lazy-init).
3. Keeps login/auth and decryption flows fast and reliable.
4. Verifies and updates `/content` so user docs are accurate and executable.
- Success:
- Login/auth & profile hydration not blocked by background tasks.
- Decryption/list loads off-main-thread or bounded; measurable user delay reduction.
- `/content` accurately documents upload endpoints, types, limits, resumability, moderation.
- Every fix is a small PR with files in `src/context/`, `src/todo/`, `src/decisions/`, `src/test_logs/` and tests/QA steps.
-------------------------------------------------------------------------------
@@ -79,10 +76,7 @@ DAILY WORKFLOW (run each day)
- Make the smallest change that improves UX; add tests or manual QA steps.
- If risky, gate behind feature flags in `src/constants.js`.
5. **Docs audit**
- If upload/ingest code or `/content` is touched, run the docs audit workflow (inventory → verify → update → validate → deliver). Always leave `/content` runnable/precise.
6. **Report**
5. **Report**
- Produce `reports/performance/daily-perf-report-YYYY-MM-DD.md` summarizing findings, PRs/issues, metrics, and blockers.
-------------------------------------------------------------------------------
@@ -119,19 +113,9 @@ SMALL SAFE FIX EXAMPLES (PRs)
- Add `MAX_DM_WORKER_PENDING` to `dmDecryptWorkerClient.js`. Throttle/reject when exceeded; log `getDmDecryptWorkerQueueSize()`.
- **perf: visibility-gate ambient RAF & canvas**
- Ensure RAF loops check `document.hidden` and avoid multiple attachments.
- **docs: `/content` upload alignment**
- Audit `/content`, update exact accepted types, size limits, endpoints, resumability examples (curl/JS).
All PRs: small, feature-flagged if risky, include `src/context/CONTEXT_<timestamp>.md`, `src/test_logs/TEST_LOG_<timestamp>.md`, `src/decisions/DECISIONS_<timestamp>.md`.
-------------------------------------------------------------------------------
DOCS AUDIT (integrated sub-workflow — mandatory when doc/code touched)
1. INVENTORY: list `/content` pages that document uploading/contribution. Extract concrete claims and map to code locations.
2. VERIFY: confirm claims against frontend components, API handlers, storage/processing pipelines, and build/deploy. Pay attention to MIME lists, size limits, resumability, error messages, moderation.
3. UPDATE: make `/content` exact and copy-pastable. If code is wrong, either fix code (small) or document divergence and open issue.
4. VALIDATE: run end-to-end uploads where possible; capture terminal logs, curl responses, screenshots (redact secrets).
5. DELIVER: open PR titled `"docs: align /content user docs with actual upload & contribution behavior"` with validation artifacts and a contributor-facing note.
-------------------------------------------------------------------------------
WORK LOOP & VERIFICATION (for code changes)
A) PLAN: 3–7 bullet plan before coding.
@@ -162,10 +146,6 @@ PR & ISSUE GUIDELINES (what to include)
- QA steps & manual validation instructions.
- Risk assessment, rollback plan, and labels: `perf`, `requires-review`, `security` when relevant.
Docs PR:
- Title: `"🌐 Align /content user docs with actual upload & contribution behavior"`
- Body: summary, diffs, validation artifacts, note for contributors.
-------------------------------------------------------------------------------
- If no work is required, exit without making changes.
ESCALATION & RISK POLICY
@@ -178,8 +158,7 @@ FIRST-RUN CHECKLIST (execute now)
- Commit files in `src/context/`, `src/todo/`, `src/decisions/`, `src/test_logs/`, `reports/performance/INITIAL_BASELINE.md`.
2. Run Search Patterns across repo and save `reports/performance/hits-YYYY-MM-DD.json`.
3. Prioritize P0 findings, open at most 1–3 small PRs (bounded concurrency, worker queue limits, visibility gating).
4. Audit `/content` upload pages: inventory claims → map to code → verify → open docs PR or issue.
5. Produce `reports/performance/daily-perf-report-YYYY-MM-DD.md` summarizing results and PR links.
4. Produce `reports/performance/daily-perf-report-YYYY-MM-DD.md` summarizing results and PR links.
-------------------------------------------------------------------------------
@@ -196,7 +175,6 @@ OUTPUTS (per run)
- PRs opened / Issues opened.
- Blockers & human decisions requested.
- For every PR: branch contains files in `src/context/`, `src/todo/`, `src/decisions/`, `src/test_logs/`.
- For docs PR: verification artifacts attached.
-------------------------------------------------------------------------------
BEHAVIORAL GUARDRAILS & QUALITY BAR
+44 -19
View File
@@ -70,33 +70,58 @@ const FALLBACK_ROSTER = {
let cachedCanonicalRoster = null;
function loadCanonicalRoster() {
async function loadCanonicalRoster() {
if (cachedCanonicalRoster) return cachedCanonicalRoster;
let rosterPath = ROSTER_FILE;
let content = null;
// Prefer user-managed roster if present
if (deps.fs.existsSync(USER_ROSTER_FILE)) {
const tryRead = async (p) => {
try {
return { data: await deps.fs.promises.readFile(p, 'utf8'), error: null };
} catch (error) {
return { data: null, error };
}
};
// Try user roster
let result = await tryRead(USER_ROSTER_FILE);
if (result.data) {
rosterPath = USER_ROSTER_FILE;
} else if (deps.fs.existsSync(CWD_ROSTER_FILE)) {
rosterPath = CWD_ROSTER_FILE;
content = result.data;
} else if (result.error.code === 'ENOENT') {
// Try CWD roster
result = await tryRead(CWD_ROSTER_FILE);
if (result.data) {
rosterPath = CWD_ROSTER_FILE;
content = result.data;
} else if (result.error.code === 'ENOENT') {
// Try default roster
result = await tryRead(ROSTER_FILE);
if (result.data) {
rosterPath = ROSTER_FILE;
content = result.data;
}
}
}
try {
const parsed = JSON.parse(deps.fs.readFileSync(rosterPath, 'utf8'));
const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : [];
const weekly = Array.isArray(parsed.weekly)
? parsed.weekly.map((item) => String(item).trim()).filter(Boolean)
: [];
if (content) {
try {
const parsed = JSON.parse(content);
const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : [];
const weekly = Array.isArray(parsed.weekly)
? parsed.weekly.map((item) => String(item).trim()).filter(Boolean)
: [];
if (daily.length > 0 && weekly.length > 0) {
cachedCanonicalRoster = { daily, weekly };
return cachedCanonicalRoster;
if (daily.length > 0 && weekly.length > 0) {
cachedCanonicalRoster = { daily, weekly };
return cachedCanonicalRoster;
}
console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${rosterPath}`);
} catch {
// It's okay if roster file is missing when used as a library/CLI without the file present
}
console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${rosterPath}`);
} catch {
// It's okay if roster file is missing when used as a library/CLI without the file present
}
cachedCanonicalRoster = FALLBACK_ROSTER;
@@ -120,7 +145,7 @@ export async function getRoster(cadence) {
const config = await deps.loadTorchConfig();
const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER);
const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER);
const canonical = loadCanonicalRoster();
const canonical = await loadCanonicalRoster();
const dailyFromConfig = config.nostrLock.dailyRoster;
const weeklyFromConfig = config.nostrLock.weeklyRoster;
+34 -17
View File
@@ -91,21 +91,26 @@ export function createMemoryCache(options = {}) {
}
}
function removeExpired(now = Date.now()) {
for (const [namespaceKey, bucket] of runtimeNamespaces.entries()) {
if (!bucket.events.length) continue;
function expireNamespace(namespaceKey, now) {
const bucket = runtimeNamespaces.get(namespaceKey);
if (!bucket || !bucket.events.length) return;
const originalLength = bucket.events.length;
bucket.events = bucket.events.filter((entry) => entry.expiresAt > now);
const removed = originalLength - bucket.events.length;
if (removed > 0) {
totalRuntimeEvents -= removed;
metrics.runtime_events_expired += removed;
}
const originalLength = bucket.events.length;
bucket.events = bucket.events.filter((entry) => entry.expiresAt > now);
const removed = originalLength - bucket.events.length;
if (removed > 0) {
totalRuntimeEvents -= removed;
metrics.runtime_events_expired += removed;
}
if (bucket.events.length === 0) {
runtimeNamespaces.delete(namespaceKey);
}
if (bucket.events.length === 0) {
runtimeNamespaces.delete(namespaceKey);
}
}
function expireAllNamespaces(now = Date.now()) {
for (const [namespaceKey] of runtimeNamespaces.entries()) {
expireNamespace(namespaceKey, now);
}
}
@@ -152,13 +157,25 @@ export function createMemoryCache(options = {}) {
runtimeNamespaces.clear();
totalRuntimeEvents = 0;
},
prune(predicate) {
if (typeof predicate !== 'function') return;
for (const key of store.keys()) {
if (predicate(key)) {
store.delete(key);
}
}
},
setRuntimeEvent(scope, event, ttlMs = MINUTE_MS) {
const now = Date.now();
removeExpired(now);
const namespaceKey = getNamespaceKey(scope);
expireNamespace(namespaceKey, now);
if (totalRuntimeEvents >= maxTotalEvents) {
expireAllNamespaces(now);
}
const safeTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : MINUTE_MS;
const expiresAt = now + safeTtlMs;
const namespaceKey = getNamespaceKey(scope);
const bucket = runtimeNamespaces.get(namespaceKey) ?? { events: [] };
bucket.events.push({
@@ -182,9 +199,9 @@ export function createMemoryCache(options = {}) {
},
getRecentRuntimeEvents(scope, params = {}) {
const now = Date.now();
removeExpired(now);
const namespaceKey = getNamespaceKey(scope);
expireNamespace(namespaceKey, now);
const bucket = runtimeNamespaces.get(namespaceKey);
if (!bucket) return [];
+108 -29
View File
@@ -38,9 +38,50 @@ function loadMemoryStore() {
return new Map();
}
let currentSavePromise = null;
let pendingSavePromise = null;
let pendingSaveResolve = null;
class CoalescingWorker {
constructor(workerFn) {
this.workerFn = workerFn;
this.currentPromise = null;
this.pendingPromise = null;
this.pendingResolve = null;
this.pendingArg = null;
}
enqueue(arg) {
this.pendingArg = arg;
if (this.pendingPromise) {
return this.pendingPromise;
}
if (this.currentPromise) {
this.pendingPromise = new Promise((resolve) => {
this.pendingResolve = resolve;
});
return this.pendingPromise;
}
this.currentPromise = this._execute(arg);
return this.currentPromise;
}
async _execute(arg) {
try {
await this.workerFn(arg);
} finally {
this.currentPromise = null;
if (this.pendingPromise) {
const resolve = this.pendingResolve;
const nextArg = this.pendingArg;
this.pendingPromise = null;
this.pendingResolve = null;
this.pendingArg = null;
this.currentPromise = this._execute(nextArg).then(resolve);
}
}
}
}
let lastSaveTime = 0;
const MIN_SAVE_INTERVAL_MS = 1000;
@@ -61,31 +102,13 @@ async function performSave(store) {
lastSaveTime = Date.now();
} catch (err) {
console.error('Failed to save memory store:', err);
} finally {
currentSavePromise = null;
if (pendingSavePromise) {
const resolve = pendingSaveResolve;
pendingSavePromise = null;
pendingSaveResolve = null;
currentSavePromise = performSave(store).then(() => resolve());
}
}
}
const saveWorker = new CoalescingWorker(performSave);
async function saveMemoryStore(store) {
if (pendingSavePromise) {
return pendingSavePromise;
}
if (currentSavePromise) {
pendingSavePromise = new Promise((resolve) => {
pendingSaveResolve = resolve;
});
return pendingSavePromise;
}
currentSavePromise = performSave(store);
return currentSavePromise;
return saveWorker.enqueue(store);
}
const memoryStore = loadMemoryStore();
@@ -227,6 +250,24 @@ function recordThroughput(samples, now) {
}
}
function invalidateAgentCache(agentId) {
if (!agentId) return;
// If the cache supports prune (it should), use it to remove entries for this agent.
if (typeof cache.prune === 'function') {
cache.prune((key) => {
try {
const parsed = JSON.parse(key);
return parsed.agent_id === agentId;
} catch {
return false;
}
});
} else {
// Fallback if cache implementation doesn't support prune
cache.clear();
}
}
/**
* Ingests agent events and stores them as memory records.
*
@@ -255,7 +296,22 @@ export async function ingestEvents(events, options = {}) {
}
},
});
cache.clear();
const uniqueAgents = new Set();
if (options.agent_id) uniqueAgents.add(options.agent_id);
for (const event of events) {
if (event.agent_id) uniqueAgents.add(event.agent_id);
}
for (const record of records) {
if (record.agent_id) uniqueAgents.add(record.agent_id);
}
if (uniqueAgents.size > 0) {
for (const agentId of uniqueAgents) invalidateAgentCache(agentId);
} else {
cache.clear();
}
return records;
}
@@ -372,7 +428,16 @@ export async function runPruneCycle(options = {}) {
});
emitMetric(options, 'memory_pruned_total', { count: prunable.length, retention_ms: retentionMs });
cache.clear();
const uniqueAgents = new Set();
for (const memory of prunable) {
if (memory.agent_id) uniqueAgents.add(memory.agent_id);
}
if (uniqueAgents.size > 0) {
for (const agentId of uniqueAgents) invalidateAgentCache(agentId);
} else if (prunable.length > 0) {
cache.clear();
}
const result = { pruned: prunable };
if (Number.isFinite(options.scheduleEveryMs) && options.scheduleEveryMs > 0) {
@@ -397,7 +462,11 @@ export async function runPruneCycle(options = {}) {
export async function pinMemory(id, options = {}) {
const repository = options.repository ?? memoryRepository;
const updated = await repository.setPinned(id, true);
cache.clear();
if (updated && updated.agent_id) {
invalidateAgentCache(updated.agent_id);
} else {
cache.clear();
}
return updated;
}
@@ -414,7 +483,11 @@ export async function pinMemory(id, options = {}) {
export async function unpinMemory(id, options = {}) {
const repository = options.repository ?? memoryRepository;
const updated = await repository.setPinned(id, false);
cache.clear();
if (updated && updated.agent_id) {
invalidateAgentCache(updated.agent_id);
} else {
cache.clear();
}
return updated;
}
@@ -431,8 +504,14 @@ export async function unpinMemory(id, options = {}) {
*/
export async function markMemoryMerged(id, mergedInto, options = {}) {
const repository = options.repository ?? memoryRepository;
const memory = await inspectMemory(id, { repository });
const merged = await repository.markMerged(id, mergedInto);
cache.clear();
if (memory && memory.agent_id) {
invalidateAgentCache(memory.agent_id);
} else {
cache.clear();
}
return merged;
}
@@ -0,0 +1,20 @@
---
agent: content-audit-agent
cadence: daily
session: 2026-02-21T18-44-11Z
run-start: 2026-02-21T18-44-11Z
prompt: src/prompts/daily/content-audit-agent.md
---
# Test Log — Content Audit — 2026-02-21
## `npm run validate:docs`
**Result:** PASS
**Output:**
```
Found 365 markdown files. Checking links (including anchors)...
No broken links found.
```
## Manual Verification
**Command:** `node bin/torch-lock.mjs --help`
**Result:** Originally missing commands; verified fix (pending verification step).
+2 -2
View File
@@ -5,10 +5,10 @@ Run Start: 2026-02-20T01-13-24Z
Prompt: src/prompts/daily/deps-security-agent.md
## Pending Tasks
- [ ] Investigate `eslint` upgrade path to resolve `minimatch` (High) vulnerability.
- [ ] Investigate `ajv` vulnerability in `eslint` dependency tree.
## Completed Tasks
- [x] Investigate `eslint` upgrade path to resolve `minimatch` (High) vulnerability.
- [x] Investigate `ajv` vulnerability in `eslint` dependency tree.
- [x] Run npm audit.
- [x] Run npm outdated.
- [x] Generate security report.
+16
View File
@@ -0,0 +1,16 @@
---
agent: content-audit-agent
cadence: daily
session: 2026-02-21T18-44-11Z
run-start: 2026-02-21T18-44-11Z
prompt: src/prompts/daily/content-audit-agent.md
---
# Todo Note — Content Audit — 2026-02-21
## Completed
- [x] Run `npm run validate:docs`.
- [x] Check `README.md` commands against implementation.
- [x] Update `src/constants.mjs` to include missing commands in usage text.
## Pending
- None.