Verify torch/src/ with lint

- Restore missing source files in `torch/src/` from `torch-lock` dependency.
- Setup ESLint in `torch/`.
- Fix linting issues in `src/lock-ops.mjs`, `src/lock-utils.mjs`, `src/ops.mjs`, `src/services/memory/embedder.js`.
- Mark 'Verify with lint' as completed in `src/todo/TODO_2026-02-14T13-30-00Z.md`.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-18 03:33:32 +00:00
1 parent 8cdf1c523d
commit 22bb29f16f
67 files changed
+7105 -24

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js';
import globals from 'globals';
export default [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.node,
...globals.es2021,
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-undef': 'error',
},
ignores: ['node_modules/', 'task-logs/', 'package.json', 'package-lock.json', 'torch-config.json'],
},
];
+918 -23
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -14,5 +14,10 @@
"type": "commonjs",
"dependencies": {
"torch-lock": "https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.0.0",
"globals": "^17.3.0"
}
}
+137
View File
@@ -0,0 +1,137 @@
import { DEFAULT_DASHBOARD_PORT } from './constants.mjs';
/**
* Parses command-line arguments into a structured object.
*
* @param {string[]} argv - Arguments from process.argv.slice(2)
* @returns {Object} - Parsed arguments (command, flags, values)
*/
export function parseArgs(argv) {
const args = {
command: null,
agent: null,
cadence: null,
json: false,
jsonFile: null,
quiet: false,
dryRun: false,
force: false,
port: DEFAULT_DASHBOARD_PORT,
host: null,
logDir: 'task-logs',
ignoreLogs: false,
id: null,
type: null,
tags: [],
pinned: null,
limit: null,
offset: null,
retentionMs: null,
windowMs: null,
timeoutMs: null,
allRelaysDownMinutes: null,
minSuccessRate: null,
windowMinutes: null,
platform: null,
model: null,
};
let i = 0;
if (argv.length > 0 && !argv[0].startsWith('-')) {
args.command = argv[0];
i = 1;
}
for (; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--agent' || arg === '-a') {
args.agent = argv[++i];
} else if (arg === '--cadence' || arg === '-c') {
args.cadence = argv[++i];
} else if (arg === '--json') {
args.json = true;
} else if (arg === '--json-file') {
args.jsonFile = argv[++i];
} else if (arg.startsWith('--json-file=')) {
args.jsonFile = arg.split('=')[1];
} else if (arg === '--quiet') {
args.quiet = true;
} else if (arg.startsWith('--agent=')) {
args.agent = arg.split('=')[1];
} else if (arg.startsWith('--cadence=')) {
args.cadence = arg.split('=')[1];
} else if (arg === '--dry-run') {
args.dryRun = true;
} else if (arg === '--force') {
args.force = true;
} else if (arg === '--port') {
args.port = parseInt(argv[++i], 10) || DEFAULT_DASHBOARD_PORT;
} else if (arg === '--host') {
args.host = argv[++i];
} else if (arg === '--log-dir') {
args.logDir = argv[++i];
} else if (arg === '--ignore-logs') {
args.ignoreLogs = true;
} else if (arg === '--id') {
args.id = argv[++i];
} else if (arg.startsWith('--id=')) {
args.id = arg.split('=')[1];
} else if (arg === '--type') {
args.type = argv[++i];
} else if (arg.startsWith('--type=')) {
args.type = arg.split('=')[1];
} else if (arg === '--tags') {
args.tags = String(argv[++i]).split(',').map((tag) => tag.trim()).filter(Boolean);
} else if (arg.startsWith('--tags=')) {
args.tags = String(arg.split('=')[1]).split(',').map((tag) => tag.trim()).filter(Boolean);
} else if (arg === '--pinned') {
const value = String(argv[++i]).toLowerCase();
args.pinned = value === 'true' ? true : value === 'false' ? false : null;
} else if (arg.startsWith('--pinned=')) {
const value = String(arg.split('=')[1]).toLowerCase();
args.pinned = value === 'true' ? true : value === 'false' ? false : null;
} else if (arg === '--limit') {
args.limit = parseInt(argv[++i], 10);
} else if (arg.startsWith('--limit=')) {
args.limit = parseInt(arg.split('=')[1], 10);
} else if (arg === '--offset') {
args.offset = parseInt(argv[++i], 10);
} else if (arg.startsWith('--offset=')) {
args.offset = parseInt(arg.split('=')[1], 10);
} else if (arg === '--retention-ms') {
args.retentionMs = parseInt(argv[++i], 10);
} else if (arg.startsWith('--retention-ms=')) {
args.retentionMs = parseInt(arg.split('=')[1], 10);
} else if (arg === '--window-ms') {
args.windowMs = parseInt(argv[++i], 10);
} else if (arg.startsWith('--window-ms=')) {
args.windowMs = parseInt(arg.split('=')[1], 10);
} else if (arg === '--timeout-ms') {
args.timeoutMs = parseInt(argv[++i], 10);
} else if (arg.startsWith('--timeout-ms=')) {
args.timeoutMs = parseInt(arg.split('=')[1], 10);
} else if (arg === '--all-relays-down-minutes') {
args.allRelaysDownMinutes = parseInt(argv[++i], 10);
} else if (arg.startsWith('--all-relays-down-minutes=')) {
args.allRelaysDownMinutes = parseInt(arg.split('=')[1], 10);
} else if (arg === '--min-success-rate') {
args.minSuccessRate = parseFloat(argv[++i]);
} else if (arg.startsWith('--min-success-rate=')) {
args.minSuccessRate = parseFloat(arg.split('=')[1]);
} else if (arg === '--window-minutes') {
args.windowMinutes = parseInt(argv[++i], 10);
} else if (arg.startsWith('--window-minutes=')) {
args.windowMinutes = parseInt(arg.split('=')[1], 10);
} else if (arg === '--platform') {
args.platform = argv[++i];
} else if (arg.startsWith('--platform=')) {
args.platform = arg.split('=')[1];
} else if (arg === '--model') {
args.model = argv[++i];
} else if (arg.startsWith('--model=')) {
args.model = arg.split('=')[1];
}
}
return args;
}
+61
View File
@@ -0,0 +1,61 @@
import {
loadTorchConfig,
getRelays,
getNamespace,
} from './torch-config.mjs';
import { getRoster } from './roster.mjs';
import { queryLocks } from './lock-ops.mjs';
import { todayDateStr } from './utils.mjs';
export async function cmdCheck(cadence, deps = {}) {
const {
loadTorchConfigFn = loadTorchConfig,
getRelaysFn = getRelays,
getNamespaceFn = getNamespace,
getRosterFn = getRoster,
queryLocksFn = queryLocks,
todayDateStrFn = todayDateStr,
} = deps;
const relays = await getRelaysFn();
const namespace = await getNamespaceFn();
const dateStr = todayDateStrFn();
const config = await loadTorchConfigFn();
const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || [];
console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`);
console.error(`Relays: ${relays.join(', ')}`);
if (pausedAgents.length > 0) {
console.error(`Paused agents: ${pausedAgents.join(', ')}`);
}
const locks = await queryLocksFn(relays, cadence, dateStr, namespace);
const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))];
const roster = await getRosterFn(cadence);
const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])];
const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent));
const available = roster.filter((a) => !excludedAgents.includes(a));
const result = {
namespace,
cadence,
date: dateStr,
locked: lockedAgents.sort(),
paused: pausedAgents.sort(),
excluded: excludedAgents.sort(),
available: available.sort(),
lockCount: locks.length,
unknownLockedAgents: unknownLockedAgents.sort(),
locks: locks.map((l) => ({
agent: l.agent,
eventId: l.eventId,
createdAt: l.createdAtIso,
expiresAt: l.expiresAtIso,
platform: l.platform,
})),
};
console.log(JSON.stringify(result, null, 2));
return result;
}
+62
View File
@@ -0,0 +1,62 @@
import {
getRelays,
getNamespace,
} from './torch-config.mjs';
import { getRoster } from './roster.mjs';
import { queryLocks } from './lock-ops.mjs';
import { todayDateStr, nowUnix } from './utils.mjs';
export async function cmdList(cadence) {
const relays = await getRelays();
const namespace = await getNamespace();
const dateStr = todayDateStr();
const cadences = cadence ? [cadence] : ['daily', 'weekly'];
console.error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`);
const results = await Promise.all(
cadences.map(async (c) => {
const locks = await queryLocks(relays, c, dateStr, namespace);
return { c, locks };
}),
);
for (const { c, locks } of results) {
console.log(`\n${'='.repeat(72)}`);
console.log(`Active ${namespace} ${c} locks (${dateStr})`);
console.log('='.repeat(72));
if (locks.length === 0) {
console.log(' (no active locks)');
continue;
}
const sorted = locks.sort((a, b) => a.createdAt - b.createdAt);
for (const lock of sorted) {
const age = nowUnix() - lock.createdAt;
const ageMin = Math.round(age / 60);
const remaining = lock.expiresAt ? lock.expiresAt - nowUnix() : null;
const remainMin = remaining ? Math.round(remaining / 60) : '?';
console.log(
` ${(lock.agent ?? 'unknown').padEnd(30)} ` +
`age: ${String(ageMin).padStart(4)}m ` +
`ttl: ${String(remainMin).padStart(4)}m ` +
`platform: ${lock.platform ?? '?'} ` +
`event: ${lock.eventId?.slice(0, 12)}...`,
);
}
const roster = await getRoster(c);
const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean));
const unknownLockedAgents = [...lockedAgents].filter((agent) => !roster.includes(agent));
const available = roster.filter((a) => !lockedAgents.has(a));
if (unknownLockedAgents.length > 0) {
console.log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`);
}
console.log(`\n Locked: ${lockedAgents.size}/${roster.length}`);
console.log(` Available: ${available.join(', ') || '(none)'}`);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import {
getRelays,
getNamespace,
getTtl,
} from './torch-config.mjs';
import {
RACE_CHECK_DELAY_MS,
KIND_APP_DATA,
} from './constants.mjs';
import { getRoster } from './roster.mjs';
import { queryLocks, publishLock } from './lock-ops.mjs';
import { todayDateStr, nowUnix } from './utils.mjs';
import { ExitError } from './errors.mjs';
export async function cmdLock(agent, cadence, dryRun = false) {
const relays = await getRelays();
const namespace = await getNamespace();
const dateStr = todayDateStr();
const ttl = await getTtl();
const now = nowUnix();
const expiresAt = now + ttl;
console.error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`);
console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`);
console.error(`Relays: ${relays.join(', ')}`);
const roster = await getRoster(cadence);
if (!roster.includes(agent)) {
console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
throw new ExitError(1, 'Agent not in roster');
}
console.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];
console.error(
`LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` +
`(created ${earliest.createdAtIso}, platform: ${earliest.platform})`,
);
console.log('LOCK_STATUS=denied');
console.log('LOCK_REASON=already_locked');
console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`);
throw new ExitError(3, 'Lock denied');
}
console.error('Step 2: Generating ephemeral keypair...');
const sk = generateSecretKey();
const pk = getPublicKey(sk);
console.error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`);
console.error('Step 3: Building lock event...');
const event = finalizeEvent(
{
kind: KIND_APP_DATA,
created_at: now,
tags: [
['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`],
['t', `${namespace}-agent-lock`],
['t', `${namespace}-lock-${cadence}`],
['t', `${namespace}-lock-${cadence}-${dateStr}`],
['expiration', String(expiresAt)],
],
content: JSON.stringify({
agent,
cadence,
status: 'started',
namespace,
date: dateStr,
platform: process.env.AGENT_PLATFORM || 'unknown',
lockedAt: new Date(now * 1000).toISOString(),
expiresAt: new Date(expiresAt * 1000).toISOString(),
}),
},
sk,
);
console.error(` Event ID: ${event.id}`);
if (dryRun) {
console.error('Step 4: [DRY RUN] Skipping publish — event built but not sent');
console.error('RACE CHECK: won (dry run — no real contention possible)');
} else {
console.error('Step 4: Publishing to relays...');
await publishLock(relays, event);
console.error('Step 5: Race check...');
await new Promise((resolve) => setTimeout(resolve, RACE_CHECK_DELAY_MS));
const postLocks = await queryLocks(relays, cadence, dateStr, namespace);
const racingLocks = postLocks
.filter((l) => l.agent === agent)
.sort((a, b) => a.createdAt - b.createdAt);
if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) {
const winner = racingLocks[0];
console.error(
`RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`,
);
console.log('LOCK_STATUS=race_lost');
console.log('LOCK_REASON=earlier_claim_exists');
console.log(`LOCK_WINNER_EVENT=${winner.eventId}`);
throw new ExitError(3, 'Race check lost');
}
console.error('RACE CHECK: won');
}
console.log('LOCK_STATUS=ok');
console.log(`LOCK_EVENT_ID=${event.id}`);
console.log(`LOCK_PUBKEY=${pk}`);
console.log(`LOCK_AGENT=${agent}`);
console.log(`LOCK_CADENCE=${cadence}`);
console.log(`LOCK_DATE=${dateStr}`);
console.log(`LOCK_EXPIRES=${expiresAt}`);
console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`);
return { status: 'ok', eventId: event.id };
}
+67
View File
@@ -0,0 +1,67 @@
export const DEFAULT_DASHBOARD_PORT = 4173;
export const RACE_CHECK_DELAY_MS = 1500;
export const DEFAULT_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
];
export const DEFAULT_TTL = 7200;
export const DEFAULT_NAMESPACE = 'torch';
export const DEFAULT_QUERY_TIMEOUT_MS = 30_000;
export const DEFAULT_PUBLISH_TIMEOUT_MS = 15_000;
export const DEFAULT_MIN_SUCCESSFUL_PUBLISHES = 1;
export const DEFAULT_MIN_ACTIVE_RELAY_POOL = 1;
export const DEFAULT_RETRY_ATTEMPTS = 4;
export const DEFAULT_RETRY_BASE_DELAY_MS = 500;
export const DEFAULT_RETRY_CAP_DELAY_MS = 8000;
export const DEFAULT_ROLLING_WINDOW_SIZE = 25;
export const DEFAULT_FAILURE_THRESHOLD = 3;
export const DEFAULT_QUARANTINE_COOLDOWN_MS = 30_000;
export const DEFAULT_MAX_QUARANTINE_COOLDOWN_MS = 300_000;
export const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
export const VALID_CADENCES = new Set(['daily', 'weekly']);
export const KIND_APP_DATA = 30078;
export const USAGE_TEXT = `Usage: torch-lock <command> [options]
Commands:
check --cadence <daily|weekly> Check locked agents (JSON)
lock --agent <name> --cadence <daily|weekly> Claim a lock
complete --agent <name> --cadence <daily|weekly> Mark task as completed (permanent)
list [--cadence <daily|weekly>] Print active lock table
health --cadence <daily|weekly> Probe relay websocket + publish/read health
dashboard [--port <port>] [--host <host>] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT})
init [--force] Initialize torch/ directory in current project
update [--force] Update torch/ configuration (backups, merges)
list-memories [--agent <id>] [--type <type>] [--tags <a,b>] [--pinned <true|false>] [--full]
inspect-memory --id <memoryId>
pin-memory --id <memoryId>
unpin-memory --id <memoryId>
trigger-prune-dry-run [--retention-ms <ms>]
memory-stats [--window-ms <ms>]
Options:
--dry-run Build and sign the event but do not publish
--force Overwrite existing files (for init) or all files (for update)
--log-dir Path to task logs directory (default: task-logs)
--ignore-logs Skip checking local logs for completed tasks
--json Emit compact single-line JSON
--json-file Write JSON output to a file path
--quiet Suppress stderr progress logs (pairs well with --json)
Environment:
NOSTR_LOCK_NAMESPACE Namespace prefix for lock tags (default: torch)
NOSTR_LOCK_RELAYS Comma-separated relay WSS URLs
NOSTR_LOCK_TTL Lock TTL in seconds (default: 7200)
NOSTR_LOCK_QUERY_TIMEOUT_MS Relay query timeout in milliseconds (default: 15000)
NOSTR_LOCK_DAILY_ROSTER Comma-separated daily roster (optional)
NOSTR_LOCK_WEEKLY_ROSTER Comma-separated weekly roster (optional)
TORCH_CONFIG_PATH Optional path to torch-config.json (default: ./torch-config.json)
AGENT_PLATFORM Platform identifier (e.g., codex)
Exit codes:
0 Success
1 Usage error
2 Relay/network error
3 Lock denied (already locked or race lost)`;
+210
View File
@@ -0,0 +1,210 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import http from 'node:http';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { DEFAULT_DASHBOARD_PORT } from './constants.mjs';
import { getDashboardAuth, parseTorchConfig } from './torch-config.mjs';
function timingSafeCompare(a, b) {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) {
// Still do a comparison to avoid some timing leaks, though length leak is hard to avoid entirely
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
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();
const server = http.createServer(async (req, res) => {
try {
// Basic Auth check
if (auth) {
const authHeader = req.headers.authorization;
if (!authHeader) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' });
res.end('Authentication required');
return;
}
const parts = authHeader.split(' ');
const type = parts[0];
const credentials = parts[1];
let isValid = false;
if (type === 'Basic' && credentials) {
try {
const decoded = Buffer.from(credentials, 'base64').toString();
isValid = timingSafeCompare(decoded, auth);
} catch {
isValid = false;
}
}
if (!isValid) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' });
res.end('Invalid credentials');
return;
}
}
// URL parsing
const url = new URL(req.url, `http://${req.headers.host}`);
let pathname = url.pathname;
// Redirect / to /dashboard/
if (pathname === '/' || pathname === '/dashboard') {
res.writeHead(302, { 'Location': '/dashboard/' });
res.end();
return;
}
async function statSafe(p) {
try {
return await fsp.stat(p);
} catch {
return null;
}
}
// Special case: /torch-config.json
// Priority: Env Var > User's CWD > Package default
if (pathname === '/torch-config.json') {
let configContent = null;
if (process.env.TORCH_CONFIG_PATH) {
const envPath = path.resolve(process.cwd(), process.env.TORCH_CONFIG_PATH);
try {
configContent = await fsp.readFile(envPath, 'utf8');
} catch {
// Explicitly provided config path not found, do not fall back
}
} else {
const userConfigPath = path.resolve(process.cwd(), 'torch-config.json');
try {
configContent = await fsp.readFile(userConfigPath, 'utf8');
} catch {
// Try package default
const packageConfigPath = path.join(packageRoot, 'torch-config.json');
try {
configContent = await fsp.readFile(packageConfigPath, 'utf8');
} catch {
// Not found in either location
}
}
}
if (configContent) {
try {
const rawConfig = JSON.parse(configContent);
const safeConfig = parseTorchConfig(rawConfig);
// Security: Sanitize sensitive fields
if (safeConfig.dashboard) {
delete safeConfig.dashboard.auth;
}
delete safeConfig.configPath;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(safeConfig, null, 2));
return;
} catch (err) {
console.error('Error parsing torch-config.json:', err);
res.writeHead(500);
res.end('Internal Server Error');
return;
}
} else {
res.writeHead(404);
res.end('Not Found');
return;
}
}
// Security check: prevent directory traversal
const safePath = path.normalize(pathname).replace(new RegExp('^(\\.\\.[\\/\\\\])+'), '');
let filePath = path.join(packageRoot, safePath);
// Security check: restrict access to allowed paths
const allowedPaths = [
path.join(packageRoot, 'dashboard'),
path.join(packageRoot, 'assets'),
path.join(packageRoot, 'src', 'docs'),
path.join(packageRoot, 'src', 'constants.mjs'),
path.join(packageRoot, 'torch-config.json')
];
const isAllowed = allowedPaths.some(allowedPath => {
const rel = path.relative(allowedPath, filePath);
return !rel.startsWith('..') && !path.isAbsolute(rel);
});
if (!isAllowed) {
res.writeHead(403);
res.end('Forbidden');
return;
}
// If directory, try index.html
let fileStat = await statSafe(filePath);
if (fileStat && fileStat.isDirectory()) {
filePath = path.join(filePath, 'index.html');
fileStat = await statSafe(filePath);
}
// Check if file exists and is a file
if (!fileStat || !fileStat.isFile()) {
res.writeHead(404);
res.end('Not Found');
return;
}
// MIME types
const extname = path.extname(filePath);
let contentType = 'text/plain';
switch (extname) {
case '.html': contentType = 'text/html'; break;
case '.js': contentType = 'text/javascript'; break;
case '.mjs': contentType = 'text/javascript'; break;
case '.css': contentType = 'text/css'; break;
case '.json': contentType = 'application/json'; break;
case '.png': contentType = 'image/png'; break;
case '.jpg': contentType = 'image/jpeg'; break;
case '.svg': contentType = 'image/svg+xml'; break;
case '.ico': contentType = 'image/x-icon'; break;
case '.md': contentType = 'text/markdown'; break;
}
res.writeHead(200, { 'Content-Type': contentType });
fs.createReadStream(filePath).pipe(res);
} catch (err) {
console.error('Dashboard Server Error:', err);
if (!res.headersSent) {
res.writeHead(500);
res.end('Internal Server Error');
}
}
});
return new Promise((resolve, reject) => {
server.listen(port, host, () => {
const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`;
console.log(`Dashboard running at ${listenUrl}`);
if (auth) {
console.log('Authentication: enabled (Basic Auth)');
} else {
console.warn('Authentication: DISABLED (Dashboard is public)');
}
console.log(`Serving files from ${packageRoot}`);
console.log(`Using configuration from ${process.cwd()}`);
resolve(server);
});
server.on('error', reject);
});
}
+7
View File
@@ -0,0 +1,7 @@
// Custom Error class for controlled exits
export class ExitError extends Error {
constructor(code, message) {
super(message);
this.code = code;
}
}
+684
View File
@@ -0,0 +1,684 @@
#!/usr/bin/env node
// TORCH — Task Orchestration via Relay-Coordinated Handoff
// Generic Nostr-based task locking for multi-agent development.
import { generateSecretKey as _generateSecretKey, getPublicKey as _getPublicKey, finalizeEvent as _finalizeEvent } from 'nostr-tools/pure';
import { useWebSocketImplementation } from 'nostr-tools/relay';
import WebSocket from 'ws';
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,
} from './constants.mjs';
import { cmdInit, cmdUpdate } from './ops.mjs';
import { parseArgs } from './cli-parser.mjs';
import { getRoster as _getRoster } from './roster.mjs';
import { queryLocks as _queryLocks, publishLock as _publishLock, parseLockEvent } from './lock-ops.mjs';
import { cmdDashboard } from './dashboard.mjs';
import {
inspectMemory,
listMemories,
memoryStats,
pinMemory,
triggerPruneDryRun,
unpinMemory,
} from './services/memory/index.js';
import { ExitError } from './errors.mjs';
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 { getCompletedAgents } from './lock-utils.mjs';
useWebSocketImplementation(WebSocket);
// Re-export for backward compatibility/library usage
export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock };
/**
* Checks the status of the repository locks for a given cadence.
*
* It aggregates data from three sources:
* 1. Configuration (paused agents)
* 2. Local logs (completed agents)
* 3. Nostr relays (current active locks)
*
* It outputs a JSON object describing the state (locked, available, excluded agents).
*
* @param {string} cadence - 'daily' or 'weekly'
* @param {Object} [deps] - Dependency injection
* @returns {Promise<Object>} - The check result object
*/
export async function cmdCheck(cadence, deps = {}) {
const {
getRelays = _getRelays,
getNamespace = _getNamespace,
loadTorchConfig = _loadTorchConfig,
queryLocks = _queryLocks,
getRoster = _getRoster,
getDateStr = todayDateStr,
log = console.log,
error = console.error,
logDir = 'task-logs',
ignoreLogs = false,
json = false,
jsonFile = null,
quiet = false,
} = deps;
const relays = await getRelays();
const namespace = await getNamespace();
const dateStr = getDateStr();
const config = await loadTorchConfig();
const pausedAgents = config.scheduler.paused[cadence] || [];
if (!quiet) {
error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`);
error(`Relays: ${relays.join(', ')}`);
if (pausedAgents.length > 0) {
error(`Paused agents: ${pausedAgents.join(', ')}`);
}
}
let completedAgents = new Set();
if (!ignoreLogs) {
completedAgents = await getCompletedAgents(cadence, logDir, deps);
if (!quiet && completedAgents.size > 0) {
error(`Completed agents (logs): ${[...completedAgents].join(', ')}`);
}
}
const locks = await queryLocks(relays, cadence, dateStr, namespace);
const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))];
const roster = await getRoster(cadence);
const rosterSet = new Set(roster);
const excludedAgentsSet = new Set([...lockedAgents, ...pausedAgents, ...completedAgents]);
const excludedAgents = [...excludedAgentsSet];
const unknownLockedAgents = lockedAgents.filter((agent) => !rosterSet.has(agent));
const available = roster.filter((a) => !excludedAgentsSet.has(a));
const result = {
namespace,
cadence,
date: dateStr,
locked: lockedAgents.sort(),
paused: pausedAgents.sort(),
completed: [...completedAgents].sort(),
excluded: excludedAgents.sort(),
available: available.sort(),
lockCount: locks.length,
unknownLockedAgents: unknownLockedAgents.sort(),
locks: locks.map((l) => ({
agent: l.agent,
eventId: l.eventId,
createdAt: l.createdAtIso,
expiresAt: l.expiresAtIso,
platform: l.platform,
})),
};
const output = json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
if (jsonFile) {
const resolvedPath = path.resolve(process.cwd(), jsonFile);
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, `${output}\n`, 'utf8');
}
if (json || !quiet) {
log(output);
}
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;
error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`);
error(`Hashtag: #${hashtag}`);
error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`);
error(`Relays: ${relays.join(', ')}`);
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 * 1000).toISOString(),
expiresAt: new Date(expiresAt * 1000).toISOString(),
}),
},
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 * 1000).toISOString()}`);
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.
*
* @param {string|null} cadence - Filter by cadence ('daily', 'weekly') or null for all
* @param {Object} [deps] - Dependency injection
* @returns {Promise<void>}
*/
export async function cmdList(cadence, deps = {}) {
const {
getRelays = _getRelays,
getNamespace = _getNamespace,
queryLocks = _queryLocks,
getRoster = _getRoster,
getDateStr = todayDateStr,
log = console.log,
error = console.error
} = deps;
const relays = await getRelays();
const namespace = await getNamespace();
const dateStr = getDateStr();
const cadences = cadence ? [cadence] : [...VALID_CADENCES];
error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`);
const results = await Promise.all(
cadences.map(async (c) => {
const locks = await queryLocks(relays, c, dateStr, namespace);
return { c, locks };
}),
);
for (const { c, locks } of results) {
log(`\n${'='.repeat(72)}`);
log(`Active ${namespace} ${c} locks (${dateStr})`);
log('='.repeat(72));
if (locks.length === 0) {
log(' (no active locks)');
continue;
}
const sorted = locks.sort((a, b) => a.createdAt - b.createdAt);
for (const lock of sorted) {
const age = nowUnix() - lock.createdAt;
const ageMin = Math.round(age / 60);
let remainMin = '?';
if (lock.status === 'completed') {
remainMin = 'done';
} else if (lock.expiresAt) {
const remaining = lock.expiresAt - nowUnix();
remainMin = Math.round(remaining / 60);
}
log(
` ${(lock.agent ?? 'unknown').padEnd(30)} ` +
`age: ${String(ageMin).padStart(4)}m ` +
`ttl: ${String(remainMin).padStart(4)} ` +
`platform: ${lock.platform ?? '?'} ` +
`event: ${lock.eventId?.slice(0, 12)}...`,
);
}
const roster = await getRoster(c);
const rosterSet = new Set(roster);
const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean));
const unknownLockedAgents = [...lockedAgents].filter((agent) => !rosterSet.has(agent));
const available = roster.filter((a) => !lockedAgents.has(a));
if (unknownLockedAgents.length > 0) {
log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`);
}
log(`\n Locked: ${lockedAgents.size}/${roster.length}`);
log(` Available: ${available.join(', ') || '(none)'}`);
}
}
/**
* Marks a task as permanently completed by publishing a new lock event with
* `status: 'completed'` and no expiration.
*
* This function:
* 1. Verifies that the agent currently holds a valid lock.
* 2. Publishes a replacement event that preserves the original `startedAt` time.
*
* @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 no active lock exists for the agent
*/
export async function cmdComplete(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,
queryLocks = _queryLocks,
publishLock = _publishLock,
generateSecretKey = _generateSecretKey,
getPublicKey = _getPublicKey,
finalizeEvent = _finalizeEvent,
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 now = nowUnix();
error(`Completing task: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`);
error(`Relays: ${relays.join(', ')}`);
// 1. Find existing lock
const locks = await queryLocks(relays, cadence, dateStr, namespace);
const myLock = locks.find((l) => l.agent === agent);
if (!myLock) {
error(`ERROR: No active lock found for agent "${agent}" on ${dateStr}.`);
error(`Cannot complete a task that is not locked or has already expired.`);
throw new ExitError(1, 'No active lock found');
}
if (myLock.status === 'completed') {
error(`Task is already marked as completed (event ${myLock.eventId}).`);
log('LOCK_STATUS=completed');
return { status: 'completed', eventId: myLock.eventId };
}
// 2. Build completion event
const startedAtIso = myLock.createdAtIso;
error('Step 1: Generating completion event...');
const sk = generateSecretKey();
const pk = getPublicKey(sk);
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}`],
// No expiration tag -> permanent
],
content: JSON.stringify({
agent,
cadence,
status: 'completed',
namespace,
date: dateStr,
platform: platform || process.env.AGENT_PLATFORM || detectPlatform() || 'unknown',
model: model || process.env.AGENT_MODEL || 'unknown',
startedAt: startedAtIso,
completedAt: new Date(now * 1000).toISOString(),
}),
},
sk,
);
error(` Event ID: ${event.id}`);
if (dryRun) {
error('Step 2: [DRY RUN] Skipping publish — event built but not sent');
} else {
error('Step 2: Publishing completion event...');
await publishLock(relays, event);
error(' Published successfully.');
}
log('LOCK_STATUS=completed');
log(`LOCK_EVENT_ID=${event.id}`);
log(`LOCK_PUBKEY=${pk}`);
log(`LOCK_AGENT=${agent}`);
log(`LOCK_CADENCE=${cadence}`);
log(`LOCK_DATE=${dateStr}`);
return { status: 'completed', eventId: event.id };
}
function usage() {
console.error(USAGE_TEXT);
}
/**
* Main entry point for the torch-lock CLI.
* Dispatches to specific commands (check, lock, complete, etc.) based on argv.
*
* @param {string[]} argv - Arguments from process.argv.slice(2)
*/
export async function main(argv) {
try {
const args = parseArgs(argv);
if (!args.command) {
usage();
throw new ExitError(1, 'No command specified');
}
switch (args.command) {
case 'check': {
if (!args.cadence || !VALID_CADENCES.has(args.cadence)) {
console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for check`);
throw new ExitError(1, 'Missing cadence');
}
await cmdCheck(args.cadence, {
logDir: args.logDir,
ignoreLogs: args.ignoreLogs,
json: args.json,
jsonFile: args.jsonFile,
quiet: args.quiet,
});
break;
}
case 'lock': {
if (!args.agent) {
console.error('ERROR: --agent <name> is required for lock');
throw new ExitError(1, 'Missing agent');
}
if (!args.cadence || !VALID_CADENCES.has(args.cadence)) {
console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for lock`);
throw new ExitError(1, 'Missing cadence');
}
await cmdLock(args.agent, args.cadence, {
dryRun: args.dryRun,
platform: args.platform,
model: args.model
});
break;
}
case 'complete': {
if (!args.agent) {
console.error('ERROR: --agent <name> is required for complete');
throw new ExitError(1, 'Missing agent');
}
if (!args.cadence || !VALID_CADENCES.has(args.cadence)) {
console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for complete`);
throw new ExitError(1, 'Missing cadence');
}
await cmdComplete(args.agent, args.cadence, {
dryRun: args.dryRun,
platform: args.platform,
model: args.model
});
break;
}
case 'list': {
if (args.cadence && !VALID_CADENCES.has(args.cadence)) {
console.error(`ERROR: --cadence must be one of: ${[...VALID_CADENCES].join(', ')}`);
throw new ExitError(1, 'Invalid cadence');
}
await cmdList(args.cadence || null);
break;
}
case 'health': {
if (!args.cadence || !VALID_CADENCES.has(args.cadence)) {
console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for health`);
throw new ExitError(1, 'Missing cadence');
}
const result = await runRelayHealthCheck({
cadence: args.cadence,
...(Number.isFinite(args.timeoutMs) ? { timeoutMs: args.timeoutMs } : {}),
...(Number.isFinite(args.allRelaysDownMinutes) ? { allRelaysDownMinutes: args.allRelaysDownMinutes } : {}),
...(Number.isFinite(args.minSuccessRate) ? { minSuccessRate: args.minSuccessRate } : {}),
...(Number.isFinite(args.windowMinutes) ? { windowMinutes: args.windowMinutes } : {}),
});
if (!result.ok) {
result.failureCategory = 'all relays unhealthy';
}
console.log(JSON.stringify(result, null, 2));
if (!result.ok) throw new ExitError(2, 'Relay health check failed');
break;
}
case 'dashboard': {
await cmdDashboard(args.port, args.host);
break;
}
case 'init': {
await cmdInit(args.force);
break;
}
case 'update': {
await cmdUpdate(args.force);
break;
}
case 'list-memories': {
const result = await listMemories({
agent_id: args.agent,
type: args.type,
tags: args.tags,
pinned: args.pinned,
limit: args.limit,
offset: args.offset,
});
if (!args.full && Array.isArray(result)) {
for (const memory of result) {
if (typeof memory.content === 'string' && memory.content.length > 200) {
memory.content = memory.content.slice(0, 200) + '... (truncated, use --full to see all)';
}
}
}
console.log(JSON.stringify(result, null, 2));
break;
}
case 'inspect-memory': {
if (!args.id) {
console.error('ERROR: --id <memoryId> is required for inspect-memory');
throw new ExitError(1, 'Missing memory id');
}
const result = await inspectMemory(args.id);
console.log(JSON.stringify(result, null, 2));
break;
}
case 'pin-memory': {
if (!args.id) {
console.error('ERROR: --id <memoryId> is required for pin-memory');
throw new ExitError(1, 'Missing memory id');
}
const result = await pinMemory(args.id);
console.log(JSON.stringify(result, null, 2));
break;
}
case 'unpin-memory': {
if (!args.id) {
console.error('ERROR: --id <memoryId> is required for unpin-memory');
throw new ExitError(1, 'Missing memory id');
}
const result = await unpinMemory(args.id);
console.log(JSON.stringify(result, null, 2));
break;
}
case 'trigger-prune-dry-run': {
const result = await triggerPruneDryRun({ retentionMs: args.retentionMs ?? undefined });
console.log(JSON.stringify(result, null, 2));
break;
}
case 'memory-stats': {
const result = await memoryStats({ windowMs: args.windowMs ?? undefined });
console.log(JSON.stringify(result, null, 2));
break;
}
default:
console.error(`ERROR: Unknown command: ${args.command}`);
usage();
throw new ExitError(1, 'Unknown command');
}
} catch (err) {
if (err instanceof ExitError) {
process.exit(err.code);
} else {
console.error(`torch-lock failed: ${err.message}`);
process.exit(2);
}
}
}
+697
View File
@@ -0,0 +1,697 @@
import { randomInt, randomUUID } from 'node:crypto';
import { SimplePool } from 'nostr-tools/pool';
import {
getQueryTimeoutMs,
getPublishTimeoutMs,
getMinSuccessfulRelayPublishes,
getRelayFallbacks,
getMinActiveRelayPool,
} from './torch-config.mjs';
import {
KIND_APP_DATA,
DEFAULT_RETRY_ATTEMPTS,
DEFAULT_RETRY_BASE_DELAY_MS,
DEFAULT_RETRY_CAP_DELAY_MS,
DEFAULT_ROLLING_WINDOW_SIZE,
DEFAULT_FAILURE_THRESHOLD,
DEFAULT_QUARANTINE_COOLDOWN_MS,
DEFAULT_MAX_QUARANTINE_COOLDOWN_MS,
DEFAULT_SNAPSHOT_INTERVAL_MS,
} from './constants.mjs';
function nowUnix() {
return Math.floor(Date.now() / 1000);
}
export function parseLockEvent(event) {
const dTag = event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
const expTag = event.tags.find((t) => t[0] === 'expiration')?.[1];
const expiresAt = expTag ? parseInt(expTag, 10) : null;
let content = {};
try {
const parsed = JSON.parse(event.content);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
content = parsed;
}
} catch {
// Ignore malformed JSON content
}
return {
eventId: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
createdAtIso: new Date(event.created_at * 1000).toISOString(),
expiresAt,
expiresAtIso: expiresAt ? new Date(expiresAt * 1000).toISOString() : null,
dTag,
agent: content.agent ?? null,
cadence: content.cadence ?? null,
status: content.status ?? null,
date: content.date ?? null,
platform: content.platform ?? null,
};
}
function filterActiveLocks(locks) {
const now = nowUnix();
return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now);
}
function withTimeout(promise, timeoutMs, timeoutMessage) {
let timeoutHandle;
const timeoutPromise = new Promise((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeoutHandle) clearTimeout(timeoutHandle);
});
}
function relayListLabel(relays) {
return relays.join(', ');
}
function classifyPublishError(message) {
const normalized = String(message || '').toLowerCase();
if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) {
return 'publish_timeout';
}
if (
normalized.includes('enotfound')
|| normalized.includes('eai_again')
|| normalized.includes('getaddrinfo')
|| (normalized.includes('dns') && normalized.includes('websocket'))
) {
return 'dns_resolution';
}
if (
normalized.includes('connect etimedout')
|| normalized.includes('tcp connect timed out')
|| normalized.includes('connect timeout')
) {
return 'tcp_connect_timeout';
}
if (
normalized.includes('tls')
|| normalized.includes('ssl')
|| normalized.includes('certificate')
|| normalized.includes('handshake')
) {
return 'tls_handshake';
}
if (
normalized.includes('websocket')
|| normalized.includes('bad response')
|| normalized.includes('unexpected server response')
) {
return 'websocket_open_failure';
}
if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) {
return 'network_timeout';
}
if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) {
return 'connection_reset';
}
if (
normalized.includes('unavailable')
|| normalized.includes('offline')
|| normalized.includes('econnrefused')
|| normalized.includes('connection refused')
|| normalized.includes('enotfound')
|| normalized.includes('503')
) {
return 'relay_unavailable';
}
return 'permanent_validation_error';
}
function isTransientPublishCategory(category) {
return [
'publish_timeout',
'dns_resolution',
'tcp_connect_timeout',
'tls_handshake',
'websocket_open_failure',
'network_timeout',
'connection_reset',
'relay_unavailable',
].includes(category);
}
const MAX_RANDOM = 281474976710655; // 2**48 - 1
function secureRandom() {
return randomInt(0, MAX_RANDOM) / MAX_RANDOM;
}
function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = secureRandom) {
const maxDelay = Math.min(capMs, baseMs * (2 ** Math.max(0, attemptNumber - 1)));
return Math.floor(randomFn() * maxDelay);
}
function summarizeRelayMetrics(metrics, nowMs) {
const total = metrics.recentOutcomes.length;
const successCount = metrics.recentOutcomes.filter((entry) => entry.success).length;
const timeoutCount = metrics.recentOutcomes.filter((entry) => entry.timedOut).length;
const latencyEntries = metrics.recentOutcomes.map((entry) => entry.latencyMs).filter((v) => Number.isFinite(v));
const averageLatencyMs = latencyEntries.length
? Math.round(latencyEntries.reduce((sum, value) => sum + value, 0) / latencyEntries.length)
: null;
const successRate = total > 0 ? successCount / total : 0.5;
const timeoutRate = total > 0 ? timeoutCount / total : 0;
const quarantineRemainingMs = metrics.quarantineUntil > nowMs ? metrics.quarantineUntil - nowMs : 0;
return {
relay: metrics.relay,
sampleSize: total,
successRate,
timeoutRate,
averageLatencyMs,
failureStreak: metrics.failureStreak,
quarantined: quarantineRemainingMs > 0,
quarantineRemainingMs,
cooldownMs: metrics.cooldownMs,
lastResultAt: metrics.lastResultAt,
};
}
function computeRelayScore(summary) {
const latencyPenalty = summary.averageLatencyMs === null
? 0.1
: Math.min(0.35, summary.averageLatencyMs / 10_000);
const quarantinePenalty = summary.quarantined ? 1 : 0;
return (summary.successRate * 1.4) - (summary.timeoutRate * 0.9) - latencyPenalty - quarantinePenalty;
}
export class RelayHealthManager {
constructor() {
this.metricsByRelay = new Map();
this.lastSnapshotAt = 0;
}
ensureMetrics(relay, config) {
let metrics = this.metricsByRelay.get(relay);
if (!metrics) {
metrics = {
relay,
recentOutcomes: [],
failureStreak: 0,
quarantineUntil: 0,
cooldownMs: config.quarantineCooldownMs,
lastLatencyMs: null,
lastResultAt: null,
};
this.metricsByRelay.set(relay, metrics);
}
return metrics;
}
rankRelays(relays, config, nowMs = Date.now()) {
const summaries = relays.map((relay) => {
const metrics = this.ensureMetrics(relay, config);
const summary = summarizeRelayMetrics(metrics, nowMs);
return {
relay,
summary,
score: computeRelayScore(summary),
};
});
return summaries.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.summary.quarantined !== b.summary.quarantined) return a.summary.quarantined ? 1 : -1;
if (a.summary.averageLatencyMs !== b.summary.averageLatencyMs) {
if (a.summary.averageLatencyMs === null) return 1;
if (b.summary.averageLatencyMs === null) return -1;
return a.summary.averageLatencyMs - b.summary.averageLatencyMs;
}
return a.relay.localeCompare(b.relay);
});
}
prioritizeRelays(relays, config, nowMs = Date.now()) {
const minActive = Math.max(1, Math.min(config.minActiveRelayPool, relays.length || 1));
const ranked = this.rankRelays([...new Set(relays)], config, nowMs);
const active = ranked.filter((entry) => !entry.summary.quarantined);
const quarantined = ranked
.filter((entry) => entry.summary.quarantined)
.sort((a, b) => a.summary.quarantineRemainingMs - b.summary.quarantineRemainingMs);
const selected = [...active];
if (selected.length < minActive) {
const additionalNeeded = Math.min(minActive - selected.length, quarantined.length);
selected.push(...quarantined.slice(0, additionalNeeded));
}
return {
prioritized: selected.map((entry) => entry.relay),
ranked,
};
}
recordOutcome(relay, success, errorMessage, latencyMs, config, nowMs = Date.now()) {
const metrics = this.ensureMetrics(relay, config);
const timedOut = String(errorMessage || '').toLowerCase().includes('timeout')
|| String(errorMessage || '').toLowerCase().includes('timed out');
metrics.recentOutcomes.push({ success, timedOut, latencyMs, atMs: nowMs });
if (metrics.recentOutcomes.length > config.rollingWindowSize) {
metrics.recentOutcomes.splice(0, metrics.recentOutcomes.length - config.rollingWindowSize);
}
metrics.lastResultAt = nowMs;
metrics.lastLatencyMs = Number.isFinite(latencyMs) ? latencyMs : null;
if (success) {
metrics.failureStreak = 0;
if (metrics.quarantineUntil > nowMs) {
metrics.quarantineUntil = 0;
metrics.cooldownMs = config.quarantineCooldownMs;
}
return;
}
metrics.failureStreak += 1;
if (metrics.failureStreak >= config.failureThreshold) {
metrics.quarantineUntil = nowMs + metrics.cooldownMs;
metrics.cooldownMs = Math.min(config.maxQuarantineCooldownMs, Math.floor(metrics.cooldownMs * 1.5));
}
}
collectSnapshot(relays, config, nowMs = Date.now()) {
const uniqueRelays = [...new Set(relays)];
return this.rankRelays(uniqueRelays, config, nowMs).map((entry) => ({
relay: entry.relay,
score: Number(entry.score.toFixed(4)),
...entry.summary,
}));
}
maybeLogSnapshot(relays, config, logger, reason, force = false, nowMs = Date.now()) {
const intervalReached = nowMs - this.lastSnapshotAt >= config.snapshotIntervalMs;
if (!force && !intervalReached) return;
this.lastSnapshotAt = nowMs;
logger(JSON.stringify({
event: 'relay_health_snapshot',
reason,
relays: this.collectSnapshot(relays, config, nowMs),
}));
}
reset() {
this.metricsByRelay.clear();
this.lastSnapshotAt = 0;
}
}
export const defaultHealthManager = new RelayHealthManager();
function buildRelayHealthConfig(deps) {
return {
rollingWindowSize: deps.rollingWindowSize ?? DEFAULT_ROLLING_WINDOW_SIZE,
failureThreshold: deps.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD,
quarantineCooldownMs: deps.quarantineCooldownMs ?? DEFAULT_QUARANTINE_COOLDOWN_MS,
maxQuarantineCooldownMs: deps.maxQuarantineCooldownMs ?? DEFAULT_MAX_QUARANTINE_COOLDOWN_MS,
snapshotIntervalMs: deps.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS,
minActiveRelayPool: Math.max(1, deps.minActiveRelayPool),
};
}
function mergeRelayList(primaryRelays, fallbackRelays) {
return [...new Set([...primaryRelays, ...fallbackRelays])];
}
export async function queryLocks(relays, cadence, dateStr, namespace, deps = {}) {
const {
poolFactory = () => new SimplePool(),
getQueryTimeoutMsFn = getQueryTimeoutMs,
getRelayFallbacksFn = getRelayFallbacks,
getMinActiveRelayPoolFn = getMinActiveRelayPool,
errorLogger = console.error,
healthLogger = console.error,
healthManager = defaultHealthManager,
} = deps;
const pool = poolFactory();
const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`;
const queryTimeoutMs = await getQueryTimeoutMsFn();
const fallbackRelays = (await getRelayFallbacksFn()).filter((relay) => !relays.includes(relay));
const healthConfig = buildRelayHealthConfig({
...deps,
minActiveRelayPool: await getMinActiveRelayPoolFn(),
});
const runQuery = async (relaySet, phase) => {
const { prioritized } = healthManager.prioritizeRelays(relaySet, healthConfig);
if (prioritized.length > 0) {
errorLogger(`[${phase}] Querying ${prioritized.length} relays (${relayListLabel(prioritized)})...`);
}
const startedAtMs = Date.now();
try {
const events = await withTimeout(
pool.querySync(prioritized, {
kinds: [KIND_APP_DATA],
'#t': [tagFilter],
}),
queryTimeoutMs,
`[${phase}] Relay query timed out after ${queryTimeoutMs}ms (relays: ${relayListLabel(prioritized)})`,
);
const elapsedMs = Date.now() - startedAtMs;
for (const relay of prioritized) {
healthManager.recordOutcome(relay, true, null, elapsedMs, healthConfig);
}
return filterActiveLocks(events.map(parseLockEvent));
} catch (err) {
const elapsedMs = Date.now() - startedAtMs;
const message = err instanceof Error ? err.message : String(err);
for (const relay of prioritized) {
healthManager.recordOutcome(relay, false, message, elapsedMs, healthConfig);
}
throw new Error(
`[${phase}] Relay query failed (timeout=${queryTimeoutMs}ms, relays=${relayListLabel(prioritized)}): ${message}`,
{ cause: err },
);
}
};
const allRelays = mergeRelayList(relays, fallbackRelays);
try {
healthManager.maybeLogSnapshot(allRelays, healthConfig, healthLogger, 'query:periodic');
try {
return await runQuery(relays, 'query:primary');
} catch (primaryErr) {
if (!fallbackRelays.length) {
healthManager.maybeLogSnapshot(allRelays, healthConfig, healthLogger, 'query:failure', true);
throw primaryErr;
}
errorLogger(`WARN: ${primaryErr.message}`);
errorLogger(`WARN: retrying query with fallback relays (${relayListLabel(fallbackRelays)})`);
return await runQuery(fallbackRelays, 'query:fallback');
}
} finally {
pool.close(allRelays);
}
}
async function publishToRelays(pool, relays, event, publishTimeoutMs, phase) {
const publishPromises = pool.publish(relays, event);
const settled = await Promise.allSettled(
publishPromises.map((publishPromise, index) => withTimeout(
publishPromise,
publishTimeoutMs,
`[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`,
)),
);
return settled.map((result, index) => {
if (result.status === 'fulfilled') {
return {
relay: relays[index],
success: true,
phase,
latencyMs: null,
};
}
const reason = result.reason;
const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown');
return {
relay: relays[index],
success: false,
phase,
message,
latencyMs: null,
};
});
}
export class LockPublisher {
constructor(relays, event, deps = {}) {
this.relays = relays;
this.event = event;
this.deps = deps;
this.pool = null;
this.healthConfig = null;
this.allRelays = [];
this.fallbackRelays = [];
this.publishTimeoutMs = 0;
this.minSuccesses = 0;
this.minActiveRelayPool = 0;
this.maxAttempts = 0;
this.retryBaseDelayMs = 0;
this.retryCapDelayMs = 0;
this.sleepFn = null;
this.randomFn = null;
this.telemetryLogger = null;
this.healthLogger = null;
this.healthManager = null;
this.correlationId = null;
this.attemptId = null;
}
async publish() {
const {
poolFactory = () => new SimplePool(),
retryAttempts = DEFAULT_RETRY_ATTEMPTS,
retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS,
retryCapDelayMs = DEFAULT_RETRY_CAP_DELAY_MS,
sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
randomFn = Math.random,
telemetryLogger = console.error,
healthLogger = console.error,
diagnostics = {},
healthManager = defaultHealthManager,
} = this.deps;
this.healthManager = healthManager;
this.pool = poolFactory();
this.publishTimeoutMs = this.deps.resolvedConfig?.publishTimeoutMs;
this.minSuccesses = this.deps.resolvedConfig?.minSuccesses;
this.fallbackRelays = (this.deps.resolvedConfig?.fallbackRelays || []).filter((relay) => !this.relays.includes(relay));
this.maxAttempts = Math.max(1, Math.floor(retryAttempts));
this.healthConfig = buildRelayHealthConfig({
...this.deps,
minActiveRelayPool: this.deps.resolvedConfig?.minActiveRelayPool,
});
this.retryBaseDelayMs = retryBaseDelayMs;
this.retryCapDelayMs = retryCapDelayMs;
this.sleepFn = sleepFn;
this.randomFn = randomFn;
this.telemetryLogger = telemetryLogger;
this.healthLogger = healthLogger;
this.healthManager = healthManager;
this.correlationId = diagnostics.correlationId || randomUUID();
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 = 'relay_publish_non_retryable';
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);
}
this.handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt);
} finally {
this.pool.close(this.allRelays);
}
}
async executePublishCycle() {
const attempted = new Set();
const publishResults = [];
await this.attemptPhase(this.relays, 'publish:primary', attempted, publishResults);
let successCount = publishResults.filter((result) => result.success).length;
if (successCount < this.minSuccesses && this.fallbackRelays.length > 0) {
await this.attemptPhase(this.fallbackRelays, 'publish:fallback', attempted, publishResults);
successCount = publishResults.filter((result) => result.success).length;
}
const failures = publishResults
.filter((result) => !result.success)
.map((result) => ({
...result,
category: classifyPublishError(result.message),
}));
return { attempted, publishResults, successCount, failures };
}
async attemptPhase(phaseRelays, phaseName, attempted, publishResults) {
if (!phaseRelays.length) return;
const { prioritized } = this.healthManager.prioritizeRelays(phaseRelays, this.healthConfig);
if (!prioritized.length) return;
console.error(`[${phaseName}] Publishing to ${prioritized.length} relays (${prioritized.join(', ')})...`);
const startedAtMs = Date.now();
for (const relay of prioritized) {
attempted.add(relay);
}
const phaseResults = await publishToRelays(this.pool, prioritized, this.event, this.publishTimeoutMs, phaseName);
const elapsedMs = Date.now() - startedAtMs;
for (const result of phaseResults) {
result.latencyMs = elapsedMs;
this.healthManager.recordOutcome(result.relay, result.success, result.message, elapsedMs, this.healthConfig);
}
publishResults.push(...phaseResults);
}
analyzeFailures(failures) {
const hasTransientFailure = failures.some((failure) => isTransientPublishCategory(failure.category));
const hasPermanentFailure = failures.some((failure) => !isTransientPublishCategory(failure.category));
return { hasTransientFailure, hasPermanentFailure };
}
determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber) {
if (hasTransientFailure && !hasPermanentFailure && attemptNumber >= this.maxAttempts) {
return 'relay_publish_quorum_failure';
} else if (hasPermanentFailure) {
return 'relay_publish_non_retryable';
} else {
return 'relay_publish_quorum_failure';
}
}
logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt) {
this.telemetryLogger(JSON.stringify({
event: 'lock_publish_quorum_met',
correlationId: this.correlationId,
attemptId: this.attemptId,
publishAttempt: attemptNumber,
successCount: lastAttemptState.successCount,
relayAttemptedCount: lastAttemptState.attempted.size,
requiredSuccesses: this.minSuccesses,
timeoutMs: this.publishTimeoutMs,
retryTimeline,
totalElapsedMs: Date.now() - overallStartedAt,
}));
console.error(
` Published to ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} relays `
+ `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms)`,
);
}
logRetry(attemptNumber, failures, elapsedMs, nextDelayMs) {
for (const failure of failures) {
if (!isTransientPublishCategory(failure.category)) continue;
this.telemetryLogger(JSON.stringify({
event: 'lock_publish_retry',
correlationId: this.correlationId,
attemptId: this.attemptId,
publishAttempt: attemptNumber,
relayUrl: failure.relay,
errorCategory: failure.category,
elapsedMs,
nextDelayMs,
}));
}
}
handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt) {
const reasonDistribution = {};
const failureLines = lastAttemptState.failures
.map((result) => {
reasonDistribution[result.category] = (reasonDistribution[result.category] || 0) + 1;
this.telemetryLogger(JSON.stringify({
event: 'lock_publish_failure',
correlationId: this.correlationId,
attemptId: this.attemptId,
relayUrl: result.relay,
phase: result.phase,
reason: result.category,
message: result.message,
}));
return `${result.relay} (${result.phase}, reason=${result.category}): ${result.message}`;
});
this.telemetryLogger(JSON.stringify({
event: 'lock_publish_quorum_failed',
correlationId: this.correlationId,
attemptId: this.attemptId,
errorCategory: terminalFailureCategory,
successCount: lastAttemptState.successCount,
relayAttemptedCount: lastAttemptState.attempted.size,
requiredSuccesses: this.minSuccesses,
timeoutMs: this.publishTimeoutMs,
attempts: this.maxAttempts,
reasonDistribution,
retryTimeline,
totalElapsedMs: Date.now() - overallStartedAt,
}));
this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:failure', true);
throw new Error(
`Failed relay publish quorum in publish phase: ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} successful `
+ `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms, attempts=${this.maxAttempts}, attempt_id=${this.attemptId}, correlation_id=${this.correlationId}, error_category=${terminalFailureCategory}, total_retry_timeline_ms=${Date.now() - overallStartedAt})\n`
+ ` retry timeline: ${retryTimeline.map((item) => `#${item.publishAttempt}:${item.elapsedMs}ms`).join(', ')}\n`
+ ` ${failureLines.join('\n ')}`,
);
}
}
export async function publishLock(relays, event, deps = {}) {
const {
getPublishTimeoutMsFn = getPublishTimeoutMs,
getMinSuccessfulRelayPublishesFn = getMinSuccessfulRelayPublishes,
getRelayFallbacksFn = getRelayFallbacks,
getMinActiveRelayPoolFn = getMinActiveRelayPool,
} = deps;
const [publishTimeoutMs, minSuccesses, fallbackRelays, minActiveRelayPool] = await Promise.all([
getPublishTimeoutMsFn(),
getMinSuccessfulRelayPublishesFn(),
getRelayFallbacksFn(),
getMinActiveRelayPoolFn(),
]);
return new LockPublisher(relays, event, {
...deps,
resolvedConfig: {
publishTimeoutMs,
minSuccesses,
fallbackRelays,
minActiveRelayPool,
},
}).publish();
}
export function _resetRelayHealthState() {
defaultHealthManager.reset();
}
export const _secureRandom = secureRandom;
+49
View File
@@ -0,0 +1,49 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { todayDateStr, getIsoWeekStr } from './utils.mjs';
/**
* Scans the local log directory to identify agents that have already completed their task
* for the current period (daily or weekly).
*
* @param {string} cadence - 'daily' or 'weekly'
* @param {string} logDir - Path to the log directory (e.g., 'task-logs')
* @param {Object} deps - Dependency injection for testing
* @returns {Promise<Set<string>>} - Set of agent names that have completed their task
*/
export async function getCompletedAgents(cadence, logDir, deps) {
const { readdir = fs.readdir, getDateStr = todayDateStr, getIsoWeek = getIsoWeekStr } = deps;
const completed = new Set();
const targetDir = path.join(logDir, cadence);
try {
const files = await readdir(targetDir);
const today = getDateStr();
const currentWeek = getIsoWeek();
for (const file of files) {
// Format: YYYY-MM-DDTHH-mm-ssZ__<agent>__<status>.md
const match = file.match(/^(\d{4}-\d{2}-\d{2})T.*__([a-zA-Z0-9-_]+)__(.*)\.md$/);
if (!match) continue;
const [, datePart, agent, status] = match;
if (status === 'completed') {
if (cadence === 'daily' && datePart === today) {
completed.add(agent);
} else if (cadence === 'weekly') {
const fileWeek = getIsoWeek(datePart); // datePart is YYYY-MM-DD
if (fileWeek === currentWeek) {
completed.add(agent);
}
}
}
}
} catch (err) {
if (err.code !== 'ENOENT') {
console.error(`Warning: Failed to read log dir ${targetDir}: ${err.message}`);
}
}
return completed;
}
+449
View File
@@ -0,0 +1,449 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import crypto from 'node:crypto';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { DEFAULT_RELAYS } from './constants.mjs';
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// Source paths (in the package)
const SRC_PROMPTS_DIR = path.join(PKG_ROOT, 'src', 'prompts');
// Files to treat as "Static" (always overwrite on update, with transformations)
const STATIC_FILES = [
'META_PROMPTS.md',
'scheduler-flow.md',
'daily-scheduler.md',
'weekly-scheduler.md',
];
// Directories containing "Evolving" files (copy if missing, preserve if present)
const EVOLVING_DIRS = ['daily', 'weekly'];
// New constants for full application install
const APP_DIRS = ['src', 'bin', 'dashboard', 'landing', 'assets', 'scripts'];
const APP_FILES = ['package.json', 'build.mjs', 'README.md', 'torch-config.example.json', 'TORCH.md'];
function getPaths(root, installDirName) {
const torchDir = path.resolve(root, installDirName);
return {
root,
torchDir,
promptsDir: path.join(torchDir, 'prompts'),
roster: path.join(torchDir, 'roster.json'),
};
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function copyDir(src, dest) {
if (fs.existsSync(src)) {
fs.cpSync(src, dest, { recursive: true });
}
}
function transformContent(content, installDirName) {
// Replace source paths with user paths
// We assume the user is running from root, so 'src/prompts/daily/' becomes 'torch/prompts/daily/'
// If installDirName is different, we should use that.
// Note: These replacements are somewhat fragile regexes.
// We'll replace 'src/' with 'installDirName/' essentially?
// The original code replaced 'src/prompts/' with 'torch/prompts/'.
// If installDirName is '.', we want 'prompts/daily/'.
// If installDirName is 'torch', we want 'torch/prompts/daily/'.
const prefix = installDirName === '.' ? '' : `${installDirName}/`;
return content
.replace(/src\/prompts\/daily\//g, `${prefix}prompts/daily/`)
.replace(/src\/prompts\/weekly\//g, `${prefix}prompts/weekly/`)
.replace(/src\/prompts\/roster\.json/g, `${prefix}roster.json`)
.replace(/src\/prompts\/scheduler-flow\.md/g, `${prefix}scheduler-flow.md`)
.replace(/TORCH\.md/g, `${prefix}TORCH.md`);
}
function copyFile(src, dest, transform = false, overwrite = true, installDirName = 'torch') {
if (fs.existsSync(dest) && !overwrite) {
return false; // Skipped
}
if (!fs.existsSync(src)) return false;
const content = fs.readFileSync(src, 'utf8');
const finalContent = transform ? transformContent(content, installDirName) : content;
fs.writeFileSync(dest, finalContent, 'utf8');
return true; // Copied/Overwritten
}
function syncAppDirectories(torchDir, verb = 'Copied') {
console.log(`${verb === 'Copied' ? 'Copying' : 'Updating'} application directories...`);
for (const dir of APP_DIRS) {
const src = path.join(PKG_ROOT, dir);
const dest = path.join(torchDir, dir);
if (fs.existsSync(src)) {
copyDir(src, dest);
console.log(` ${verb} ${dir}/`);
}
}
}
function syncAppFiles(torchDir, installDir, verb = 'Copied') {
console.log(`${verb === 'Copied' ? 'Copying' : 'Updating'} application files...`);
for (const file of APP_FILES) {
const src = path.join(PKG_ROOT, file);
const dest = path.join(torchDir, file);
if (fs.existsSync(src)) {
if (installDir === '.' && file === 'package.json') {
if (verb === 'Copied' && fs.existsSync(dest)) {
console.warn(' Skipping package.json to avoid overwriting host package.json (installing to root).');
continue;
}
if (verb === 'Updated') {
console.log(' Skipping package.json update (installed in root).');
continue;
}
}
fs.copyFileSync(src, dest);
console.log(` ${verb} ${file}`);
}
}
}
async function interactiveInit(cwd) {
const rl = readline.createInterface({ input, output });
const currentDirName = path.basename(cwd);
console.log('\n🔥 TORCH Initialization 🔥\n');
try {
// 1. Install Directory
let defaultDir = 'torch';
if (currentDirName === 'torch') {
defaultDir = '.';
}
const dirAnswer = await rl.question(`Install directory (default: ${defaultDir}): `);
const installDir = dirAnswer.trim() || defaultDir;
// 2. Namespace
const randomSuffix = crypto.randomBytes(4).toString('hex');
const defaultNamespace = `torch-${randomSuffix}`;
const namespaceAnswer = await rl.question(`Nostr Namespace (default: ${defaultNamespace}): `);
const namespace = namespaceAnswer.trim() || defaultNamespace;
// 3. Relays
console.log(`\nDefault Relays:\n ${DEFAULT_RELAYS.join('\n ')}`);
const relaysAnswer = await rl.question('Enter relays (comma-separated) or press Enter to use defaults: ');
let relays = DEFAULT_RELAYS;
if (relaysAnswer.trim()) {
relays = relaysAnswer.split(',').map(r => r.trim()).filter(Boolean);
}
return { installDir, namespace, relays };
} finally {
rl.close();
}
}
async function resolveConfiguration(cwd, mockAnswers) {
if (mockAnswers) {
return mockAnswers;
}
return await interactiveInit(cwd);
}
function ensureInstallDirectory(paths, force, installDir) {
if (fs.existsSync(paths.torchDir) && !force) {
const entries = fs.readdirSync(paths.torchDir);
if (entries.length > 0 && installDir !== '.') {
throw new Error(`Directory ${paths.torchDir} already exists and is not empty. Use --force to overwrite.`);
}
}
ensureDir(paths.torchDir);
ensureDir(paths.promptsDir);
}
function installAppAssets(torchDir, installDir) {
// 1. Copy App Directories
syncAppDirectories(torchDir, 'Copied');
// 2. Copy App Files
syncAppFiles(torchDir, installDir, 'Copied');
}
function installTorchAssets(paths, installDir) {
// 3. Copy Roster
const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json');
if (fs.existsSync(srcRoster)) {
copyFile(srcRoster, paths.roster, false, true, installDir);
console.log(`Created ${path.relative(paths.root, paths.roster)}`);
}
// 4. Copy Static Files
for (const file of STATIC_FILES) {
const src = path.join(SRC_PROMPTS_DIR, file);
const dest = path.join(paths.torchDir, file);
if (fs.existsSync(src)) {
copyFile(src, dest, true, true, installDir);
console.log(`Created ${path.relative(paths.root, dest)}`);
}
}
// 5. Copy Prompts
for (const dir of EVOLVING_DIRS) {
const srcDir = path.join(SRC_PROMPTS_DIR, dir);
const destDir = path.join(paths.promptsDir, dir);
ensureDir(destDir);
if (fs.existsSync(srcDir)) {
const files = fs.readdirSync(srcDir);
for (const file of files) {
const srcFile = path.join(srcDir, file);
const destFile = path.join(destDir, file);
copyFile(srcFile, destFile, false, true, installDir);
}
console.log(`Created ${files.length} files in ${path.relative(paths.root, destDir)}/`);
}
}
}
function configureTorch(cwd, paths, installDir, namespace, relays) {
// 6. Create/Update torch-config.json
const configPath = path.join(paths.root, 'torch-config.json');
let configData = {};
// Try to load existing or example
if (fs.existsSync(configPath)) {
try {
configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
console.log(`Updating existing ${path.relative(cwd, configPath)}...`);
} catch (e) {
console.warn(`Could not parse existing config: ${e.message}`);
}
} else {
const exampleConfigPath = path.join(PKG_ROOT, 'torch-config.example.json');
if (fs.existsSync(exampleConfigPath)) {
configData = JSON.parse(fs.readFileSync(exampleConfigPath, 'utf8'));
}
}
// Apply user choices
if (!configData.nostrLock) configData.nostrLock = {};
configData.nostrLock.namespace = namespace;
configData.nostrLock.relays = relays;
// Configure memory policy with correct paths
if (!configData.scheduler) configData.scheduler = {};
// Always ensure memory policy exists and points to the correct scripts
// We use the installDir to construct the path
const scriptPrefix = installDir === '.' ? '' : `${installDir}/`;
if (!configData.scheduler.memoryPolicyByCadence) {
configData.scheduler.memoryPolicyByCadence = {};
}
// Populate daily/weekly if missing
['daily', 'weekly'].forEach(cadence => {
if (!configData.scheduler.memoryPolicyByCadence[cadence]) {
configData.scheduler.memoryPolicyByCadence[cadence] = {
mode: "required",
retrieveCommand: `node ${scriptPrefix}scripts/memory/retrieve.mjs`,
storeCommand: `node ${scriptPrefix}scripts/memory/store.mjs`,
retrieveSuccessMarkers: ["MEMORY_RETRIEVED"],
storeSuccessMarkers: ["MEMORY_STORED"],
retrieveArtifacts: [`.scheduler-memory/latest/${cadence}/retrieve.ok`],
storeArtifacts: [`.scheduler-memory/latest/${cadence}/store.ok`]
};
}
});
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2), 'utf8');
console.log(`Saved configuration to ${path.relative(cwd, configPath)}`);
}
function injectHostScriptsIfNeeded(paths, installDir) {
// 7. Inject Scripts into Host Package.json
// If we are NOT installing to '.', the host package.json is in paths.root
if (installDir !== '.') {
injectScriptsIntoHost(paths.root, installDir);
}
}
export async function cmdInit(force = false, cwd = process.cwd(), mockAnswers = null) {
const config = await resolveConfiguration(cwd, mockAnswers);
const { installDir, namespace, relays } = config;
const paths = getPaths(cwd, installDir);
console.log(`\nInitializing torch in ${paths.torchDir}...`);
ensureInstallDirectory(paths, force, installDir);
installAppAssets(paths.torchDir, installDir);
installTorchAssets(paths, installDir);
configureTorch(cwd, paths, installDir, namespace, relays);
injectHostScriptsIfNeeded(paths, installDir);
console.log('\nInitialization complete.');
console.log('You can now customize the files in ' + path.relative(cwd, paths.torchDir) + '/');
}
function injectScriptsIntoHost(hostRoot, installDirName) {
const hostPkgPath = path.join(hostRoot, 'package.json');
if (!fs.existsSync(hostPkgPath)) {
console.warn('No package.json found in host root. Skipping script injection.');
return;
}
try {
const pkg = JSON.parse(fs.readFileSync(hostPkgPath, 'utf8'));
if (!pkg.scripts) pkg.scripts = {};
const scriptsToAdd = {
'torch:dashboard': `npm run --prefix ${installDirName} dashboard:serve`,
'torch:check': `npm run --prefix ${installDirName} lock:check:daily`, // Default to daily check
'torch:lock': `npm run --prefix ${installDirName} lock:lock`,
'torch:health': `npm run --prefix ${installDirName} lock:health`,
'torch:memory:list': `node ${installDirName === '.' ? '' : installDirName + '/'}bin/torch-lock.mjs list-memories`,
'torch:memory:inspect': `node ${installDirName === '.' ? '' : installDirName + '/'}bin/torch-lock.mjs inspect-memory`,
};
let modified = false;
for (const [key, cmd] of Object.entries(scriptsToAdd)) {
if (!pkg.scripts[key]) {
pkg.scripts[key] = cmd;
console.log(` Added script: "${key}"`);
modified = true;
} else {
console.log(` Script "${key}" already exists, skipping.`);
}
}
if (modified) {
fs.writeFileSync(hostPkgPath, JSON.stringify(pkg, null, 2), 'utf8');
console.log('Updated package.json with convenience scripts.');
}
} catch (e) {
console.error(`Failed to inject scripts: ${e.message}`);
}
}
export function cmdUpdate(force = false, cwd = process.cwd()) {
// Update logic needs to know WHERE torch is installed.
// We can look for torch directory? Or assume 'torch'?
// For now, let's look for 'torch' directory first, then fallback to '.' if we detect torch files?
// Or just default to 'torch' and let user move files if they changed it?
// Realistically, 'update' should probably take an argument for the dir, or we just default to 'torch'.
// If the user installed to 'custom-dir', cmdUpdate will fail unless we auto-detect.
// Auto-detection strategy: check if 'torch' exists. If not, check if 'package.json' has 'torch-lock' name?
let installDirName = 'torch';
if (!fs.existsSync(path.join(cwd, 'torch')) && fs.existsSync(path.join(cwd, 'package.json'))) {
// Check if current dir is the torch dir
try {
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
if (pkg.name === 'torch-lock') {
installDirName = '.';
}
} catch {
// Ignore error if package.json is missing or invalid
}
}
const paths = getPaths(cwd, installDirName);
console.log(`Updating torch configuration in ${paths.torchDir}...`);
if (!fs.existsSync(paths.torchDir)) {
throw new Error(`${paths.torchDir} not found. Run 'torch-lock init' first.`);
}
// 1. Backup
const backupName = `backup_${new Date().toISOString().replace(/[:.]/g, '-')}`;
const backupRoot = path.join(paths.torchDir, '_backups');
const thisBackupDir = path.join(backupRoot, backupName);
ensureDir(thisBackupDir);
console.log(`Creating backup at ${path.relative(paths.root, thisBackupDir)}...`);
// We backup EVERYTHING in torchDir except _backups and node_modules
const entries = fs.readdirSync(paths.torchDir);
for (const entry of entries) {
if (entry === '_backups' || entry === 'node_modules' || entry === '.git') continue;
const srcPath = path.join(paths.torchDir, entry);
const destPath = path.join(thisBackupDir, entry);
fs.cpSync(srcPath, destPath, { recursive: true });
}
// 2. Update App Directories (Overwrite)
syncAppDirectories(paths.torchDir, 'Updated');
// 3. Update App Files (Overwrite)
syncAppFiles(paths.torchDir, installDirName, 'Updated');
// 4. Update Static Files (Always Overwrite)
console.log('Updating static files...');
for (const file of STATIC_FILES) {
const src = path.join(SRC_PROMPTS_DIR, file);
const dest = path.join(paths.torchDir, file);
if (fs.existsSync(src)) {
copyFile(src, dest, true, true, installDirName);
console.log(` Updated ${file}`);
}
}
// 5. Update Roster (Preserve unless force)
const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json');
if (fs.existsSync(srcRoster)) {
if (force) {
copyFile(srcRoster, paths.roster, false, true, installDirName);
console.log(' Overwrote roster.json (forced)');
} else {
console.log(' Skipped roster.json (preserved)');
}
}
// 6. Update Prompts (Copy missing, preserve existing unless force)
console.log('Updating prompts...');
for (const dir of EVOLVING_DIRS) {
const srcDir = path.join(SRC_PROMPTS_DIR, dir);
const destDir = path.join(paths.promptsDir, dir);
ensureDir(destDir);
if (fs.existsSync(srcDir)) {
const files = fs.readdirSync(srcDir);
let added = 0;
let updated = 0;
let skipped = 0;
for (const file of files) {
const srcFile = path.join(srcDir, file);
const destFile = path.join(destDir, file);
if (force) {
copyFile(srcFile, destFile, false, true, installDirName);
updated++;
} else {
if (!fs.existsSync(destFile)) {
copyFile(srcFile, destFile, false, true, installDirName);
added++;
} else {
skipped++;
}
}
}
console.log(` ${dir}/: ${added} added, ${updated} updated, ${skipped} preserved`);
}
}
console.log('\nUpdate complete.');
}
+290
View File
@@ -0,0 +1,290 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import WebSocket from 'ws';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { getNamespace, getRelays } from './torch-config.mjs';
const PROBE_KIND = 27235;
function withTimeout(promise, timeoutMs, message) {
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
}
function nowIso() {
return new Date().toISOString();
}
export function summarizeHistory(historyEntries, { nowMs = Date.now(), windowMinutes = 60 } = {}) {
const windowStartMs = nowMs - (windowMinutes * 60_000);
const recent = historyEntries.filter((entry) => Date.parse(entry.timestamp || '') >= windowStartMs);
const relayProbeCount = recent.reduce((sum, entry) => sum + (entry.summary?.totalRelays || 0), 0);
const successCount = recent.reduce((sum, entry) => sum + (entry.summary?.healthyRelays || 0), 0);
const successRate = relayProbeCount > 0 ? successCount / relayProbeCount : 1;
let lastHealthyAtMs = null;
for (let i = historyEntries.length - 1; i >= 0; i -= 1) {
if ((historyEntries[i].summary?.healthyRelays || 0) > 0) {
lastHealthyAtMs = Date.parse(historyEntries[i].timestamp || '');
break;
}
}
const allDownDurationMinutes = lastHealthyAtMs === null
? null
: Math.max(0, Math.floor((nowMs - lastHealthyAtMs) / 60_000));
return {
windowMinutes,
sampleCount: recent.length,
successRate,
allDownDurationMinutes,
};
}
export function evaluateAlertThresholds(currentResult, historyEntries, thresholds, { nowMs = Date.now() } = {}) {
const stats = summarizeHistory(historyEntries, { nowMs, windowMinutes: thresholds.windowMinutes });
const alerts = [];
if (currentResult.summary.allRelaysUnhealthy && stats.allDownDurationMinutes !== null && stats.allDownDurationMinutes >= thresholds.allRelaysDownMinutes) {
alerts.push({
type: 'all_relays_down_duration',
thresholdMinutes: thresholds.allRelaysDownMinutes,
actualMinutes: stats.allDownDurationMinutes,
severity: 'critical',
});
}
if (stats.sampleCount > 0 && stats.successRate < thresholds.minSuccessRate) {
alerts.push({
type: 'relay_success_rate_below_threshold',
threshold: thresholds.minSuccessRate,
actual: Number(stats.successRate.toFixed(4)),
windowMinutes: thresholds.windowMinutes,
severity: 'warning',
});
}
return { alerts, stats };
}
async function probeWebSocketReachability(relayUrl, timeoutMs) {
const startedAt = Date.now();
return withTimeout(new Promise((resolve, reject) => {
const ws = new WebSocket(relayUrl);
let settled = false;
const finish = (fn) => (value) => {
if (settled) return;
settled = true;
try {
ws.close();
} catch {
// ignore
}
fn(value);
};
ws.once('open', finish(() => resolve({ ok: true, latencyMs: Date.now() - startedAt })));
ws.once('error', finish((err) => reject(err)));
}), timeoutMs, `WebSocket reachability timeout after ${timeoutMs}ms`);
}
async function probePublishRead(relayUrl, namespace, timeoutMs) {
const ws = new WebSocket(relayUrl);
const sk = generateSecretKey();
const pk = getPublicKey(sk);
const createdAt = Math.floor(Date.now() / 1000);
const nonce = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
const probeTag = `${namespace}-health-probe-${nonce}`;
const event = finalizeEvent({
kind: PROBE_KIND,
created_at: createdAt,
tags: [
['t', `${namespace}-relay-health`],
['d', probeTag],
],
content: JSON.stringify({ type: 'relay_health_probe', nonce, namespace, pubkey: pk }),
}, sk);
const subId = `probe-${nonce}`;
return withTimeout(new Promise((resolve, reject) => {
let okAck = false;
let readSeen = false;
let settled = false;
const settle = (fn) => (value) => {
if (settled) return;
settled = true;
try {
ws.close();
} catch {
// ignore
}
fn(value);
};
ws.once('open', () => {
ws.send(JSON.stringify(['EVENT', event]));
ws.send(JSON.stringify(['REQ', subId, { ids: [event.id], limit: 1 }]));
});
ws.on('message', (raw) => {
let message;
try {
message = JSON.parse(raw.toString());
} catch {
// Ignore malformed messages. The relay might be sending non-JSON data
// or we might have a protocol mismatch. We only care about valid JSON.
return;
}
if (!Array.isArray(message) || message.length < 2) return;
const type = message[0];
if (type === 'OK' && message[1] === event.id) {
okAck = Boolean(message[2]);
if (!okAck) {
settle(reject)(new Error(`Relay rejected health probe event: ${String(message[3] || 'unknown reason')}`));
return;
}
}
if (type === 'EVENT' && message[1] === subId && message[2]?.id === event.id) {
readSeen = true;
}
if (okAck && readSeen) {
ws.send(JSON.stringify(['CLOSE', subId]));
settle(resolve)({ ok: true, eventId: event.id });
}
});
ws.once('error', settle(reject));
}), timeoutMs, `Publish/read probe timeout after ${timeoutMs}ms`);
}
async function readHistory(historyPath) {
try {
const raw = await fs.readFile(historyPath, 'utf8');
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter((entry) => entry && typeof entry === 'object');
} catch (error) {
if (error && error.code === 'ENOENT') return [];
throw error;
}
}
async function appendHistory(historyPath, entry) {
await fs.mkdir(path.dirname(historyPath), { recursive: true });
await fs.appendFile(historyPath, `${JSON.stringify(entry)}\n`, 'utf8');
}
export async function runRelayHealthCheck(options = {}) {
const {
cadence = 'daily',
timeoutMs = 6000,
allRelaysDownMinutes = 10,
minSuccessRate = 0.7,
windowMinutes = 60,
} = options;
let {
relays,
namespace,
historyPath,
} = options;
if (!relays) relays = await getRelays();
if (!namespace) namespace = await getNamespace();
if (!historyPath) historyPath = path.resolve(process.cwd(), 'task-logs', 'relay-health', `${cadence}.jsonl`);
const relayResults = [];
for (const relay of relays) {
const relayResult = {
relay,
websocketReachable: false,
publishReadOk: false,
healthy: false,
checks: {},
};
try {
const wsProbe = await probeWebSocketReachability(relay, timeoutMs);
relayResult.websocketReachable = true;
relayResult.checks.websocket = wsProbe;
} catch (error) {
relayResult.checks.websocket = { ok: false, error: error instanceof Error ? error.message : String(error) };
}
if (relayResult.websocketReachable) {
try {
const prProbe = await probePublishRead(relay, namespace, timeoutMs);
relayResult.publishReadOk = true;
relayResult.checks.publishRead = prProbe;
} catch (error) {
relayResult.checks.publishRead = { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
relayResult.healthy = relayResult.websocketReachable && relayResult.publishReadOk;
relayResults.push(relayResult);
}
const summary = {
totalRelays: relayResults.length,
healthyRelays: relayResults.filter((relay) => relay.healthy).length,
};
summary.unhealthyRelays = summary.totalRelays - summary.healthyRelays;
summary.allRelaysUnhealthy = summary.healthyRelays === 0;
const result = {
ok: !summary.allRelaysUnhealthy,
cadence,
namespace,
timestamp: nowIso(),
relays,
relayResults,
summary,
incidentSignal: null,
alerts: [],
alertStats: null,
historyPath,
};
const historyEntries = await readHistory(historyPath);
const prospectiveHistory = [...historyEntries, result];
const thresholdEval = evaluateAlertThresholds(result, prospectiveHistory, {
allRelaysDownMinutes,
minSuccessRate,
windowMinutes,
});
result.alerts = thresholdEval.alerts;
result.alertStats = thresholdEval.stats;
if (summary.allRelaysUnhealthy) {
const incidentId = `relay-health-${cadence}-${Date.now()}`;
result.incidentSignal = {
id: incidentId,
reason: 'All relays unhealthy before lock acquisition',
severity: 'critical',
suggestedAction: 'Defer scheduler run and investigate relay availability before retrying lock acquisition.',
};
}
await appendHistory(historyPath, result);
return result;
}
+114
View File
@@ -0,0 +1,114 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadTorchConfig } from './torch-config.mjs';
const ROSTER_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'prompts/roster.json');
const USER_ROSTER_FILE = path.resolve(process.cwd(), 'torch/roster.json');
const CWD_ROSTER_FILE = path.resolve(process.cwd(), 'roster.json');
const FALLBACK_ROSTER = {
daily: [
'audit-agent',
'ci-health-agent',
'const-refactor-agent',
'content-audit-agent',
'decompose-agent',
'deps-security-agent',
'design-system-audit-agent',
'docs-agent',
'docs-alignment-agent',
'docs-code-investigator',
'innerhtml-migration-agent',
'known-issues-agent',
'load-test-agent',
'onboarding-audit-agent',
'perf-agent',
'prompt-curator-agent',
'protocol-research-agent',
'scheduler-update-agent',
'style-agent',
'test-audit-agent',
'todo-triage-agent',
],
weekly: [
'bug-reproducer-agent',
'changelog-agent',
'dead-code-agent',
'event-schema-agent',
'frontend-console-debug-agent',
'fuzz-agent',
'interop-agent',
'perf-deepdive-agent',
'perf-optimization-agent',
'pr-review-agent',
'race-condition-agent',
'refactor-agent',
'smoke-agent',
'telemetry-agent',
'test-coverage-agent',
'weekly-synthesis-agent',
],
};
let cachedCanonicalRoster = null;
function loadCanonicalRoster() {
if (cachedCanonicalRoster) return cachedCanonicalRoster;
let rosterPath = ROSTER_FILE;
// Prefer user-managed roster if present
if (fs.existsSync(USER_ROSTER_FILE)) {
rosterPath = USER_ROSTER_FILE;
} else if (fs.existsSync(CWD_ROSTER_FILE)) {
rosterPath = CWD_ROSTER_FILE;
}
try {
const parsed = JSON.parse(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 (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
}
cachedCanonicalRoster = FALLBACK_ROSTER;
return cachedCanonicalRoster;
}
function parseEnvRoster(value) {
if (!value) return null;
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
export async function getRoster(cadence) {
const config = await loadTorchConfig();
const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER);
const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER);
const canonical = loadCanonicalRoster();
const dailyFromConfig = config.nostrLock.dailyRoster;
const weeklyFromConfig = config.nostrLock.weeklyRoster;
if (cadence === 'daily') {
if (dailyFromEnv && dailyFromEnv.length) return dailyFromEnv;
if (dailyFromConfig && dailyFromConfig.length) return dailyFromConfig;
return canonical.daily;
}
if (weeklyFromEnv && weeklyFromEnv.length) return weeklyFromEnv;
if (weeklyFromConfig && weeklyFromConfig.length) return weeklyFromConfig;
return canonical.weekly;
}
+218
View File
@@ -0,0 +1,218 @@
const DEFAULT_MAX_NAMESPACES = 128;
const DEFAULT_MAX_EVENTS_PER_NAMESPACE = 200;
const DEFAULT_MAX_TOTAL_EVENTS = 5000;
function normalizeScope(scope = {}) {
const signerId = typeof scope.signer_id === 'string' && scope.signer_id.trim()
? scope.signer_id.trim()
: 'anonymous-signer';
const sessionId = typeof scope.session_id === 'string' && scope.session_id.trim()
? scope.session_id.trim()
: 'anonymous-session';
return { signer_id: signerId, session_id: sessionId };
}
function getNamespaceKey(scope) {
const normalized = normalizeScope(scope);
return `${normalized.signer_id}::${normalized.session_id}`;
}
function isEventSensitive(event) {
if (!event || typeof event !== 'object') return false;
if (event.is_decrypted === true || event.session_sensitive === true) return true;
const classification = typeof event.classification === 'string' ? event.classification.toLowerCase() : '';
return classification === 'decrypted' || classification === 'session-sensitive';
}
function canPromoteToDurableStorage(event) {
if (!isEventSensitive(event)) return true;
return event.sanitized_for_durable === true;
}
/**
* @param {{ maxNamespaces?: number, maxEventsPerNamespace?: number, maxTotalEvents?: number }} [options]
* @returns {{
* get: (key: string) => unknown,
* set: (key: string, value: unknown) => void,
* delete: (key: string) => boolean,
* clear: () => void,
* setRuntimeEvent: (scope: { signer_id?: string, session_id?: string }, event: Record<string, unknown>, ttlMs?: number) => boolean,
* getRecentRuntimeEvents: (scope: { signer_id?: string, session_id?: string }, params?: { limit?: number, since?: number }) => Record<string, unknown>[],
* clearScope: (scope: { signer_id?: string, session_id?: string }) => void,
* getMetrics: () => Record<string, number>
* }}
*/
export function createMemoryCache(options = {}) {
const store = new Map();
const runtimeNamespaces = new Map();
const maxNamespaces = Number.isFinite(options.maxNamespaces) && options.maxNamespaces > 0
? Math.floor(options.maxNamespaces)
: DEFAULT_MAX_NAMESPACES;
const maxEventsPerNamespace = Number.isFinite(options.maxEventsPerNamespace) && options.maxEventsPerNamespace > 0
? Math.floor(options.maxEventsPerNamespace)
: DEFAULT_MAX_EVENTS_PER_NAMESPACE;
const maxTotalEvents = Number.isFinite(options.maxTotalEvents) && options.maxTotalEvents > 0
? Math.floor(options.maxTotalEvents)
: DEFAULT_MAX_TOTAL_EVENTS;
let totalRuntimeEvents = 0;
const metrics = {
runtime_events_added: 0,
runtime_events_expired: 0,
runtime_events_lru_evicted: 0,
runtime_scopes_cleared: 0,
runtime_sensitive_events_blocked: 0,
};
function touchNamespace(key) {
const bucket = runtimeNamespaces.get(key);
if (!bucket) return;
runtimeNamespaces.delete(key);
runtimeNamespaces.set(key, bucket);
}
function evictNamespace(key) {
const bucket = runtimeNamespaces.get(key);
if (!bucket) return;
totalRuntimeEvents -= bucket.events.length;
metrics.runtime_events_lru_evicted += bucket.events.length;
runtimeNamespaces.delete(key);
}
function evictOldestNamespaceIfNeeded() {
while (runtimeNamespaces.size > maxNamespaces) {
const oldestKey = runtimeNamespaces.keys().next().value;
if (!oldestKey) return;
evictNamespace(oldestKey);
}
}
function removeExpired(now = Date.now()) {
for (const [namespaceKey, bucket] of runtimeNamespaces.entries()) {
if (!bucket.events.length) continue;
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);
}
}
}
function enforceEventBounds(namespaceKey) {
const bucket = runtimeNamespaces.get(namespaceKey);
if (!bucket) return;
while (bucket.events.length > maxEventsPerNamespace) {
bucket.events.shift();
totalRuntimeEvents -= 1;
metrics.runtime_events_lru_evicted += 1;
}
while (totalRuntimeEvents > maxTotalEvents) {
const oldestNamespaceKey = runtimeNamespaces.keys().next().value;
if (!oldestNamespaceKey) return;
const oldestBucket = runtimeNamespaces.get(oldestNamespaceKey);
if (!oldestBucket || oldestBucket.events.length === 0) {
runtimeNamespaces.delete(oldestNamespaceKey);
continue;
}
oldestBucket.events.shift();
totalRuntimeEvents -= 1;
metrics.runtime_events_lru_evicted += 1;
if (oldestBucket.events.length === 0) {
runtimeNamespaces.delete(oldestNamespaceKey);
}
}
}
return {
get(key) {
return store.get(key);
},
set(key, value) {
store.set(key, value);
},
delete(key) {
return store.delete(key);
},
clear() {
store.clear();
runtimeNamespaces.clear();
totalRuntimeEvents = 0;
},
setRuntimeEvent(scope, event, ttlMs = 60_000) {
const now = Date.now();
removeExpired(now);
const safeTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : 60_000;
const expiresAt = now + safeTtlMs;
const namespaceKey = getNamespaceKey(scope);
const bucket = runtimeNamespaces.get(namespaceKey) ?? { events: [] };
bucket.events.push({
event,
createdAt: now,
expiresAt,
durableEligible: canPromoteToDurableStorage(event),
});
totalRuntimeEvents += 1;
metrics.runtime_events_added += 1;
if (!canPromoteToDurableStorage(event)) {
metrics.runtime_sensitive_events_blocked += 1;
}
runtimeNamespaces.set(namespaceKey, bucket);
touchNamespace(namespaceKey);
evictOldestNamespaceIfNeeded();
enforceEventBounds(namespaceKey);
return true;
},
getRecentRuntimeEvents(scope, params = {}) {
const now = Date.now();
removeExpired(now);
const namespaceKey = getNamespaceKey(scope);
const bucket = runtimeNamespaces.get(namespaceKey);
if (!bucket) return [];
touchNamespace(namespaceKey);
const limit = Number.isFinite(params.limit) && params.limit > 0 ? Math.floor(params.limit) : 50;
const since = Number.isFinite(params.since) ? params.since : 0;
return bucket.events
.filter((entry) => entry.createdAt >= since)
.slice(-limit)
.map((entry) => ({
...entry.event,
durable_eligible: entry.durableEligible,
}));
},
clearScope(scope) {
const namespaceKey = getNamespaceKey(scope);
const bucket = runtimeNamespaces.get(namespaceKey);
if (!bucket) return;
totalRuntimeEvents -= bucket.events.length;
metrics.runtime_scopes_cleared += 1;
runtimeNamespaces.delete(namespaceKey);
},
getMetrics() {
return {
...metrics,
runtime_active_namespaces: runtimeNamespaces.size,
runtime_active_events: totalRuntimeEvents,
};
},
};
}
+134
View File
@@ -0,0 +1,134 @@
const DEFAULT_VECTOR_BACKEND = 'inmemory';
const VECTOR_BACKEND_ENV = 'MEMORY_VECTOR_BACKEND';
/**
* @typedef {{
* embedText: (text: string) => Promise<number[]> | number[],
* upsertVector: (input: { id: string, vector: number[], metadata?: Record<string, unknown> }) => Promise<void> | void,
* queryVector: (input: { vector: number[], k?: number, filter?: Record<string, unknown> }) => Promise<Array<{ id: string, score: number, vector: number[], metadata: Record<string, unknown> }>> | Array<{ id: string, score: number, vector: number[], metadata: Record<string, unknown> }>,
* deleteVector: (id: string) => Promise<boolean> | boolean,
* }} EmbedderAdapter
*/
/**
* @param {string} text
* @returns {number[]}
*/
function fallbackEmbedding(_text) {
// Disabled as per configuration: Jules uses text-based memory, not vector embeddings.
return [];
}
function cosineSimilarity(left, right) {
const size = Math.max(left.length, right.length);
let dot = 0;
let leftNorm = 0;
let rightNorm = 0;
for (let index = 0; index < size; index += 1) {
const leftValue = Number(left[index] ?? 0);
const rightValue = Number(right[index] ?? 0);
dot += leftValue * rightValue;
leftNorm += leftValue ** 2;
rightNorm += rightValue ** 2;
}
if (leftNorm === 0 || rightNorm === 0) return 0;
return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
}
/** @implements {EmbedderAdapter} */
class InMemoryEmbedderAdapter {
constructor() {
this.vectors = new Map();
}
/** @param {string} text */
async embedText(text) {
return fallbackEmbedding(text);
}
/** @param {{ id: string, vector: number[], metadata?: Record<string, unknown> }} input */
async upsertVector({ id, vector, metadata = {} }) {
if (typeof id !== 'string' || id.length === 0) {
throw new TypeError('upsertVector requires a non-empty id');
}
if (!Array.isArray(vector) || vector.length === 0) {
// In strict mode this might throw, but since we return [] from embedText,
// ingestor won't call this. If someone calls it manually with [], we can just ignore or warn.
// But adhering to the interface, we should probably throw or just return.
// Given we want to support "no embedding", simply returning is safer than throwing.
return;
}
this.vectors.set(id, { vector: [...vector], metadata: { ...metadata } });
}
/** @param {{ vector: number[], k?: number, filter?: Record<string, unknown> }} input */
async queryVector({ vector, k = 5, filter = {} }) {
if (!Array.isArray(vector) || vector.length === 0) return [];
const matchesFilter = (metadata) => Object.entries(filter).every(([key, value]) => metadata?.[key] === value);
return [...this.vectors.entries()]
.filter(([, value]) => matchesFilter(value.metadata))
.map(([id, value]) => ({
id,
score: cosineSimilarity(vector, value.vector),
vector: [...value.vector],
metadata: { ...value.metadata },
}))
.sort((left, right) => right.score - left.score)
.slice(0, Number.isFinite(k) && k > 0 ? Math.floor(k) : 5);
}
/** @param {string} id */
async deleteVector(id) {
if (typeof id !== 'string' || id.length === 0) return false;
return this.vectors.delete(id);
}
}
const ADAPTER_FACTORIES = {
inmemory: () => new InMemoryEmbedderAdapter(),
local: () => new InMemoryEmbedderAdapter(),
mock: () => new InMemoryEmbedderAdapter(),
};
let defaultAdapter;
/**
* @param {{ backend?: string }} [options]
* @returns {EmbedderAdapter}
*/
export function createEmbedderAdapter(options = {}) {
const configuredBackend = String(options.backend ?? process.env[VECTOR_BACKEND_ENV] ?? DEFAULT_VECTOR_BACKEND).trim().toLowerCase();
const factory = ADAPTER_FACTORIES[configuredBackend];
if (!factory) {
throw new Error(`Unsupported vector backend "${configuredBackend}". Configure ${VECTOR_BACKEND_ENV}=inmemory (default) until provider adapters are added.`);
}
return factory();
}
/**
* @returns {EmbedderAdapter}
*/
export function getDefaultEmbedderAdapter() {
if (!defaultAdapter) {
defaultAdapter = createEmbedderAdapter();
}
return defaultAdapter;
}
/**
* @param {string} text
* @param {{ embedText?: (value: string) => Promise<number[]> | number[], adapter?: EmbedderAdapter }} [options]
* @returns {Promise<number[]>}
*/
export async function embedText(text, options = {}) {
if (typeof options.embedText === 'function') {
return options.embedText(text);
}
const adapter = options.adapter ?? getDefaultEmbedderAdapter();
return adapter.embedText(text);
}
@@ -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';
}
+194
View File
@@ -0,0 +1,194 @@
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;
const MEMORY_CONTEXT_HEADER = '[CONTEXT — Relevant memories]';
function clampString(value, maxChars) {
if (typeof value !== 'string') return '';
if (!Number.isFinite(maxChars) || maxChars <= 0) return '';
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
}
/**
* Fast heuristic (approx 4 chars/token) suitable for guardrail budgeting.
* @param {string} text
*/
export function estimateTokenCount(text) {
const normalized = String(text ?? '');
if (normalized.length === 0) return 0;
return Math.ceil(normalized.length / 4);
}
/**
* @param {import('./schema.js').MemoryRecord} memory
* @param {{ maxSummaryChars?: number, maxContextChars?: number }} [options]
*/
export function formatMemoryBlock(memory, options = {}) {
const summary = clampString(memory.summary ?? '', options.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS);
const shortContext = clampString(memory.content ?? '', options.maxContextChars ?? DEFAULT_MAX_CONTEXT_CHARS);
const timestamp = new Date(memory.created_at).toISOString();
const tags = Array.isArray(memory.tags) ? memory.tags.join(',') : '';
return {
id: memory.id,
timestamp,
importance: Number(memory.importance ?? 0).toFixed(2),
summary,
short_context: shortContext,
tags,
};
}
function fitBlockToBudget(memory, options, maxTokens) {
let maxSummaryChars = options.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS;
let maxContextChars = options.maxContextChars ?? DEFAULT_MAX_CONTEXT_CHARS;
while (maxSummaryChars >= 8 && maxContextChars >= 8) {
const block = formatMemoryBlock(memory, { maxSummaryChars, maxContextChars });
const line = blockToLine(block);
if (estimateTokenCount(line) <= maxTokens) {
return { block, line };
}
if (maxContextChars >= maxSummaryChars) {
maxContextChars = Math.max(8, Math.floor(maxContextChars * 0.7));
} else {
maxSummaryChars = Math.max(8, Math.floor(maxSummaryChars * 0.7));
}
if (maxSummaryChars === 8 && maxContextChars === 8) break;
}
const block = formatMemoryBlock(memory, { maxSummaryChars: 8, maxContextChars: 8 });
return { block, line: blockToLine(block) };
}
function blockToLine(block) {
return `[${block.id}, ${block.timestamp}, ${block.importance}, ${block.summary}, ${block.short_context}, ${block.tags}]`;
}
/**
* @param {import('./schema.js').MemoryRecord[]} memories
* @param {{ k?: number, tokenBudget?: number, maxSummaryChars?: number, maxContextChars?: number }} [options]
*/
export function formatMemoriesForPrompt(memories, options = {}) {
const k = Number.isFinite(options.k) && options.k > 0 ? Math.floor(options.k) : memories.length;
const tokenBudget = Number.isFinite(options.tokenBudget) && options.tokenBudget > 0
? Math.floor(options.tokenBudget)
: DEFAULT_TOKEN_BUDGET;
const blocks = [];
let usedTokens = 0;
for (const memory of memories.slice(0, k)) {
const remainingTokens = tokenBudget - usedTokens;
if (remainingTokens <= 0) break;
const fitted = fitBlockToBudget(memory, options, remainingTokens);
const block = fitted.block;
const line = fitted.line;
const lineTokens = estimateTokenCount(line);
if (usedTokens + lineTokens > tokenBudget) {
break;
}
blocks.push(block);
usedTokens += lineTokens;
}
return {
blocks,
text: blocks.map(blockToLine).join('\n'),
usedTokens,
truncated: blocks.length < Math.min(k, memories.length),
};
}
function toContextString(agentContext) {
if (typeof agentContext === 'string') {
return agentContext.trim();
}
if (agentContext == null) {
return '';
}
if (Array.isArray(agentContext)) {
return agentContext.map((value) => String(value)).join('\n').trim();
}
if (typeof agentContext === 'object') {
try {
return JSON.stringify(agentContext);
} catch {
return String(agentContext).trim();
}
}
return String(agentContext).trim();
}
/**
* Builds a final prompt with retrieved memory context inserted ahead of the base prompt.
*
* @param {{
* basePrompt: string,
* userRequest: string,
* agentContext?: unknown,
* agent_id: string,
* memoryService: { getRelevantMemories: (params: { agent_id: string, query: string, k?: number }) => Promise<import('./schema.js').MemoryRecord[]> | import('./schema.js').MemoryRecord[] },
* retrievalK?: number,
* tokenBudget?: number,
* env?: Record<string, string | undefined>
* }} params
*/
export async function renderPromptWithMemoryContext(params) {
const {
basePrompt,
userRequest,
agentContext,
agent_id,
memoryService,
retrievalK = 5,
tokenBudget = 320,
env = process.env,
} = params;
if (!isMemoryRetrievalEnabled(agent_id, env)) {
return String(basePrompt ?? '');
}
const requestText = String(userRequest ?? '').trim();
const contextText = toContextString(agentContext);
const query = [requestText, contextText].filter(Boolean).join('\n\n');
if (!query || !memoryService?.getRelevantMemories) {
return String(basePrompt ?? '');
}
const memories = await memoryService.getRelevantMemories({
agent_id,
query,
k: retrievalK,
});
if (memoryService?.updateMemoryUsage) {
const now = Date.now();
await Promise.all((memories ?? []).map((memory) => memoryService.updateMemoryUsage(memory.id, now)));
}
const rendered = formatMemoriesForPrompt(memories ?? [], {
k: retrievalK,
tokenBudget,
});
if (!rendered.text) {
return String(basePrompt ?? '');
}
return `${MEMORY_CONTEXT_HEADER}\n${rendered.text}\n\n${String(basePrompt ?? '')}`;
}
+439
View File
@@ -0,0 +1,439 @@
import { createMemoryCache } from './cache.js';
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';
import fs from 'node:fs';
import path from 'node:path';
import util from 'node:util';
const MEMORY_FILE_PATH = path.join(process.cwd(), '.scheduler-memory', 'memory-store.json');
const debug = util.debuglog('torch-memory');
function loadMemoryStore() {
try {
if (fs.existsSync(MEMORY_FILE_PATH)) {
const data = fs.readFileSync(MEMORY_FILE_PATH, 'utf8');
const entries = JSON.parse(data);
if (Array.isArray(entries)) {
return new Map(entries);
}
}
} catch (err) {
console.error('Failed to load memory store:', err);
}
return new Map();
}
function saveMemoryStore(store) {
try {
const dir = path.dirname(MEMORY_FILE_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const entries = [...store.entries()];
fs.writeFileSync(MEMORY_FILE_PATH, JSON.stringify(entries, null, 2), 'utf8');
} catch (err) {
console.error('Failed to save memory store:', err);
}
}
const memoryStore = loadMemoryStore();
const cache = createMemoryCache();
const memoryRepository = {
async insertMemory(memory) {
memoryStore.set(memory.id, memory);
saveMemoryStore(memoryStore);
return memory;
},
async updateMemoryUsage(id, lastSeen = Date.now()) {
const existing = memoryStore.get(id);
if (!existing) return null;
const updated = { ...existing, last_seen: lastSeen };
memoryStore.set(id, updated);
saveMemoryStore(memoryStore);
return updated;
},
async listPruneCandidates({ cutoff }) {
return [...memoryStore.values()].filter((memory) => !memory.pinned && memory.last_seen < cutoff);
},
async markMerged(id, mergedInto) {
const existing = memoryStore.get(id);
if (!existing) return false;
memoryStore.set(id, { ...existing, merged_into: mergedInto, last_seen: Date.now() });
saveMemoryStore(memoryStore);
return true;
},
async setPinned(id, pinned) {
const existing = memoryStore.get(id);
if (!existing) return null;
const updated = { ...existing, pinned, last_seen: Date.now() };
memoryStore.set(id, updated);
saveMemoryStore(memoryStore);
return updated;
},
async getMemoryById(id) {
return memoryStore.get(id) ?? null;
},
async listMemories() {
return [...memoryStore.values()];
},
};
const memoryStatsState = {
ingested: [],
retrieved: [],
pruned: [],
archived: 0,
deleted: 0,
};
const ESTIMATED_INDEX_BYTES_PER_RECORD = 512;
function toSafeTelemetryPayload(payload = {}) {
const blockedFields = new Set(['content', 'summary', 'query', 'raw']);
return Object.entries(payload).reduce((safe, [key, value]) => {
if (blockedFields.has(key)) return safe;
if (typeof value === 'string' && value.length > 300) {
safe[key] = `${value.slice(0, 300)}…`;
return safe;
}
safe[key] = value;
return safe;
}, {});
}
function buildTelemetryEmitter(options = {}) {
if (typeof options.emitTelemetry === 'function') {
return (event, payload) => options.emitTelemetry(event, toSafeTelemetryPayload(payload));
}
if (typeof options.telemetry?.emit === 'function') {
return (event, payload) => options.telemetry.emit(event, toSafeTelemetryPayload(payload));
}
return (event, payload) => {
debug('memory_telemetry', {
event,
payload: toSafeTelemetryPayload(payload),
ts: Date.now(),
});
};
}
function emitMetric(options = {}, metric, payload) {
if (typeof options.emitMetric === 'function') {
options.emitMetric(metric, payload);
return;
}
if (typeof options.metrics?.emit === 'function') {
options.metrics.emit(metric, payload);
return;
}
debug('memory_metric', { metric, payload, ts: Date.now() });
}
function applyMemoryFilters(memories, filters = {}) {
const tags = Array.isArray(filters.tags)
? filters.tags.map((tag) => String(tag).trim()).filter(Boolean)
: [];
return memories.filter((memory) => {
if (filters.agent_id && memory.agent_id !== filters.agent_id) return false;
if (filters.type && memory.type !== filters.type) return false;
if (typeof filters.pinned === 'boolean' && memory.pinned !== filters.pinned) return false;
if (typeof filters.includeMerged === 'boolean' && !filters.includeMerged && memory.merged_into) return false;
if (tags.length > 0) {
const memoryTags = new Set(memory.tags);
if (!tags.every((tag) => memoryTags.has(tag))) return false;
}
return true;
});
}
function recordThroughput(samples, now) {
samples.push(now);
if (samples.length > 10_000) {
samples.splice(0, samples.length - 10_000);
}
}
/**
* Ingests agent events and stores them as memory records.
*
* @param {import('./schema.js').MemoryEvent[]} events - Ordered list of raw events to persist.
* @param {{ maxSummaryLength?: number, embedText?: (value: string) => Promise<number[]> | number[], repository?: typeof memoryRepository }} [options] - Optional ingest tuning.
* @returns {Promise<import('./schema.js').MemoryRecord[]>} Stored memory records.
*/
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,
emitTelemetry: (event, payload) => {
telemetry(event, payload);
if (event === 'memory:ingested') {
recordThroughput(memoryStatsState.ingested, Date.now());
emitMetric(options, 'memory_ingested_total', { count: 1 });
}
},
});
cache.clear();
return records;
}
/**
* Retrieves top-k memories for an agent query.
*
* @param {{ agent_id: string, query?: string, tags?: string[], timeframe?: { from?: number, to?: number }, k?: number, repository?: typeof memoryRepository }} params - Retrieval filter and ranking inputs.
* @returns {Promise<import('./schema.js').MemoryRecord[]>} Sorted list of relevant memories.
*/
export async function getRelevantMemories(params) {
const {
repository = memoryRepository,
ranker = filterAndRankMemories,
cache: cacheProvider = cache,
...queryParams
} = params;
const telemetry = buildTelemetryEmitter(params);
const cacheKey = JSON.stringify(queryParams);
const cached = cacheProvider.get(cacheKey);
if (cached) {
telemetry('memory:retrieved', {
agent_id: queryParams.agent_id,
result_count: cached.length,
cache_hit: true,
query_present: Boolean(queryParams.query),
query_length: typeof queryParams.query === 'string' ? queryParams.query.length : 0,
tags_count: Array.isArray(queryParams.tags) ? queryParams.tags.length : 0,
});
emitMetric(params, 'memory_retrieved_total', { count: cached.length, cache_hit: true });
recordThroughput(memoryStatsState.retrieved, Date.now());
return cached;
}
const source = typeof repository.listMemories === 'function'
? await repository.listMemories(queryParams)
: [...memoryStore.values()];
const ranked = await ranker(source, queryParams);
await updateMemoryUsage(repository, ranked.map((memory) => memory.id));
cacheProvider.set(cacheKey, ranked);
telemetry('memory:retrieved', {
agent_id: queryParams.agent_id,
result_count: ranked.length,
cache_hit: false,
query_present: Boolean(queryParams.query),
query_length: typeof queryParams.query === 'string' ? queryParams.query.length : 0,
tags_count: Array.isArray(queryParams.tags) ? queryParams.tags.length : 0,
});
emitMetric(params, 'memory_retrieved_total', { count: ranked.length, cache_hit: false });
recordThroughput(memoryStatsState.retrieved, Date.now());
return ranked;
}
/**
* Runs a memory pruning pass and removes unpinned stale records.
*
* @param {{ retentionMs?: number, now?: number, scheduleEveryMs?: number, repository?: typeof memoryRepository }} [options] - Prune configuration; if scheduleEveryMs is set, returns a scheduler handle.
* @returns {Promise<{ pruned: import('./schema.js').MemoryRecord[], scheduler?: { stop: () => void } }>} Pruned records and optional scheduler handle.
*/
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,
});
const prunable = dbCandidates.length > 0
? dbCandidates
: selectPrunableMemories([...memoryStore.values()], {
retentionMs,
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);
}
if (prunable.length > 0) {
saveMemoryStore(memoryStore);
}
memoryStatsState.deleted += prunable.length;
recordThroughput(memoryStatsState.pruned, Date.now());
telemetry('memory:pruned', {
pruned_count: prunable.length,
retention_ms: retentionMs,
});
emitMetric(options, 'memory_pruned_total', { count: prunable.length, retention_ms: retentionMs });
cache.clear();
const result = { pruned: prunable };
if (Number.isFinite(options.scheduleEveryMs) && options.scheduleEveryMs > 0) {
result.scheduler = startScheduler(() => runPruneCycle({ retentionMs, repository, env: options.env, pruneMode: options.pruneMode }), {
intervalMs: options.scheduleEveryMs,
});
}
return result;
}
/**
* Pins a memory to prevent pruning and boost retrieval ranking.
*
* @param {string} id - Memory record identifier.
* @returns {Promise<import('./schema.js').MemoryRecord | null>} Updated memory record or null when not found.
*/
export async function pinMemory(id, options = {}) {
const repository = options.repository ?? memoryRepository;
const updated = await repository.setPinned(id, true);
cache.clear();
return updated;
}
/**
* Removes a pin from a memory so it can be pruned normally.
*
* @param {string} id - Memory record identifier.
* @returns {Promise<import('./schema.js').MemoryRecord | null>} Updated memory record or null when not found.
*/
export async function unpinMemory(id, options = {}) {
const repository = options.repository ?? memoryRepository;
const updated = await repository.setPinned(id, false);
cache.clear();
return updated;
}
/**
* Marks one memory as merged into another.
*
* @param {string} id
* @param {string} mergedInto
*/
export async function markMemoryMerged(id, mergedInto, options = {}) {
const repository = options.repository ?? memoryRepository;
const merged = await repository.markMerged(id, mergedInto);
cache.clear();
return merged;
}
export async function listMemories(filters = {}, options = {}) {
const repository = options.repository ?? memoryRepository;
const source = typeof repository.listMemories === 'function'
? await repository.listMemories(filters)
: [...memoryStore.values()];
const filtered = applyMemoryFilters(source, filters)
.sort((left, right) => right.last_seen - left.last_seen);
const offset = Number.isFinite(filters.offset) ? Math.max(0, Number(filters.offset)) : 0;
const limit = Number.isFinite(filters.limit)
? Math.max(1, Math.floor(Number(filters.limit)))
: filtered.length;
return filtered.slice(offset, offset + limit);
}
export async function inspectMemory(id, options = {}) {
const repository = options.repository ?? memoryRepository;
if (typeof repository.getMemoryById === 'function') {
return repository.getMemoryById(id);
}
return memoryStore.get(id) ?? null;
}
export async function triggerPruneDryRun(options = {}) {
const repository = options.repository ?? memoryRepository;
const retentionMs = options.retentionMs ?? (1000 * 60 * 60 * 24 * 30);
const dbCandidates = await listPruneCandidates(repository, {
retentionMs,
now: options.now,
});
const candidates = dbCandidates.length > 0
? dbCandidates
: selectPrunableMemories(await listMemories({}, { repository }), {
retentionMs,
now: options.now,
});
return {
dryRun: true,
retentionMs,
candidateCount: candidates.length,
candidates: candidates.map((memory) => ({
id: memory.id,
agent_id: memory.agent_id,
type: memory.type,
pinned: memory.pinned,
last_seen: memory.last_seen,
merged_into: memory.merged_into,
})),
};
}
export async function memoryStats(options = {}) {
const repository = options.repository ?? memoryRepository;
const now = options.now ?? Date.now();
const windowMs = Number.isFinite(options.windowMs) ? Math.max(1, Number(options.windowMs)) : (60 * 60 * 1000);
const memories = await listMemories({}, { repository });
const countsByType = memories.reduce((acc, memory) => {
acc[memory.type] = (acc[memory.type] ?? 0) + 1;
return acc;
}, {});
const archivedCount = memories.filter((memory) => Boolean(memory.merged_into)).length;
const deletedRate = memoryStatsState.deleted / Math.max(1, memoryStatsState.ingested.length);
const archivedRate = archivedCount / Math.max(1, memories.length);
const ingestedInWindow = memoryStatsState.ingested.filter((timestamp) => timestamp >= (now - windowMs)).length;
const ingestThroughputPerMinute = ingestedInWindow / Math.max(1, (windowMs / 60_000));
return {
countsByType,
totals: {
total: memories.length,
pinned: memories.filter((memory) => memory.pinned).length,
archived: archivedCount,
deletedObserved: memoryStatsState.deleted,
},
rates: {
archivedRate,
deletedRate,
},
indexSizeEstimateBytes: memories.length * ESTIMATED_INDEX_BYTES_PER_RECORD,
ingestThroughputPerMinute,
};
}
+291
View File
@@ -0,0 +1,291 @@
import { createHash, randomUUID } from 'node:crypto';
import { createMemoryRecord, normalizeEvent, validateMemoryItem } from './schema.js';
import { embedText, getDefaultEmbedderAdapter } from './embedder.js';
import { summarizeEvents } from './summarizer.js';
const WINDOW_BUCKET_MS = 60_000;
const MAX_DEDUPE_KEYS = 2000;
const dedupeWindow = new Map();
function pruneDedupeWindow() {
while (dedupeWindow.size > MAX_DEDUPE_KEYS) {
const oldestKey = dedupeWindow.keys().next().value;
if (!oldestKey) break;
dedupeWindow.delete(oldestKey);
}
}
function redactObviousPii(text) {
if (typeof text !== 'string' || text.length === 0) return '';
return text
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted:email]')
.replace(/\b(?:\+?\d{1,2}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b/g, '[redacted:phone]')
.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[redacted:ssn]');
}
function estimateTokens(text) {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
function chunkEvents(events, options = {}) {
const maxChunkChars = Number.isFinite(options.maxChunkChars) && options.maxChunkChars > 0
? Math.floor(options.maxChunkChars)
: 1_200;
const maxChunkTokens = Number.isFinite(options.maxChunkTokens) && options.maxChunkTokens > 0
? Math.floor(options.maxChunkTokens)
: 300;
const chunks = [];
let currentChunk = [];
let currentChars = 0;
let currentTokens = 0;
const flush = () => {
if (currentChunk.length > 0) {
chunks.push(currentChunk);
currentChunk = [];
currentChars = 0;
currentTokens = 0;
}
};
for (const event of events) {
const eventChars = event.content.length;
const eventTokens = estimateTokens(event.content);
const overBudget = (currentChars + eventChars > maxChunkChars) || (currentTokens + eventTokens > maxChunkTokens);
if (overBudget) {
flush();
}
currentChunk.push(event);
currentChars += eventChars;
currentTokens += eventTokens;
}
flush();
return chunks;
}
function hashIngestionWindow({ agentId, bucket, content }) {
return createHash('sha256').update(`${agentId}|${bucket}|${content}`).digest('hex');
}
async function isDuplicateWindow(repository, dedupeKey) {
if (dedupeWindow.has(dedupeKey)) return true;
if (typeof repository?.hasIngestionFingerprint === 'function') {
const exists = await repository.hasIngestionFingerprint(dedupeKey);
if (exists) return true;
}
return false;
}
async function rememberWindowFingerprint(repository, dedupeKey, payload) {
dedupeWindow.set(dedupeKey, payload);
pruneDedupeWindow();
if (typeof repository?.storeIngestionFingerprint === 'function') {
await repository.storeIngestionFingerprint(dedupeKey, payload);
}
}
function compactContextExcerpt(text, maxChars = 280) {
if (typeof text !== 'string' || text.length === 0) return '';
const compact = text.replace(/\s+/g, ' ').trim();
if (compact.length <= maxChars) return compact;
return `${compact.slice(0, maxChars - 1)}…`;
}
function buildTelemetryEmitter(options = {}) {
if (typeof options.emitTelemetry === 'function') return options.emitTelemetry;
if (typeof options.telemetry?.emit === 'function') {
return (event, payload) => options.telemetry.emit(event, payload);
}
return () => {};
}
/**
* @param {import('./schema.js').MemoryEvent[]} events
* @returns {import('./schema.js').MemoryEvent[]}
*/
export function normalizeEvents(events) {
return events.map(normalizeEvent);
}
/**
* @param {{
* events?: import('./schema.js').MemoryEvent[],
* runtimeCache?: { getRecentRuntimeEvents?: (scope: Record<string, unknown>, params?: { since?: number, limit?: number }) => Record<string, unknown>[] },
* runtimeScope?: Record<string, unknown>,
* logSource?: { getEvents?: (params: { from: number, to: number, agent_id?: string }) => Promise<Record<string, unknown>[]> | Record<string, unknown>[], fetchEvents?: (params: { from: number, to: number, agent_id?: string }) => Promise<Record<string, unknown>[]> | Record<string, unknown>[] },
* agent_id?: string,
* windowStart?: number,
* windowEnd?: number,
* }} input
* @param {{
* repository?: { insertMemory: (memory: import('./schema.js').MemoryRecord) => Promise<unknown>, hasIngestionFingerprint?: (dedupeKey: string) => Promise<boolean>, storeIngestionFingerprint?: (dedupeKey: string, payload: Record<string, unknown>) => Promise<void>, linkEmbedding?: (memoryId: string, embedding: { id: string, vector: number[] }) => Promise<unknown> },
* maxSummaryLength?: number,
* maxChunkChars?: number,
* maxChunkTokens?: number,
* embedText?: (value: string) => Promise<number[]> | number[],
* adapter?: import('./embedder.js').EmbedderAdapter,
* embedderAdapter?: import('./embedder.js').EmbedderAdapter,
* contextExcerptChars?: number,
* emitTelemetry?: (event: string, payload: Record<string, unknown>) => void,
* telemetry?: { emit: (event: string, payload: Record<string, unknown>) => void },
* now?: number,
* windowBucketMs?: number,
* }} options
* @returns {Promise<import('./schema.js').MemoryRecord[]>}
*/
export async function ingestMemoryWindow(input = {}, options = {}) {
const repository = options.repository;
if (!repository || typeof repository.insertMemory !== 'function') {
throw new TypeError('Memory ingest requires a repository with insertMemory');
}
const emitTelemetry = buildTelemetryEmitter(options);
const windowStart = Number.isFinite(input.windowStart) ? Number(input.windowStart) : 0;
const windowEnd = Number.isFinite(input.windowEnd) ? Number(input.windowEnd) : (Number.isFinite(options.now) ? Number(options.now) : Date.now());
const runtimeEvents = typeof input.runtimeCache?.getRecentRuntimeEvents === 'function'
? input.runtimeCache.getRecentRuntimeEvents(input.runtimeScope ?? {}, { since: windowStart, limit: 500 })
: [];
const logReader = input.logSource?.fetchEvents ?? input.logSource?.getEvents;
const logEvents = typeof logReader === 'function'
? await logReader({ from: windowStart, to: windowEnd, agent_id: input.agent_id })
: [];
const normalizedEvents = normalizeEvents([
...(Array.isArray(input.events) ? input.events : []),
...(Array.isArray(runtimeEvents) ? runtimeEvents : []),
...(Array.isArray(logEvents) ? logEvents : []),
]);
const redactedEvents = normalizedEvents.map((event) => ({
...event,
content: redactObviousPii(event.content),
}));
const eventsByAgent = new Map();
for (const event of redactedEvents) {
const events = eventsByAgent.get(event.agent_id) ?? [];
events.push(event);
eventsByAgent.set(event.agent_id, events);
}
const insertedRecords = [];
const bucketMs = Number.isFinite(options.windowBucketMs) && options.windowBucketMs > 0
? Math.floor(options.windowBucketMs)
: WINDOW_BUCKET_MS;
const bucket = Math.floor(windowEnd / bucketMs);
for (const [agentId, agentEvents] of eventsByAgent.entries()) {
if (!agentEvents.length) continue;
const sorted = [...agentEvents].sort((a, b) => a.timestamp - b.timestamp);
const dedupeContent = sorted.map((event) => `${event.timestamp}:${event.content}`).join('\n');
const dedupeKey = hashIngestionWindow({ agentId, bucket, content: dedupeContent });
if (await isDuplicateWindow(repository, dedupeKey)) {
continue;
}
const chunks = chunkEvents(sorted, options);
for (const chunk of chunks) {
const summarization = await summarizeEvents(chunk, options);
const contextText = chunk.map((event) => event.content).join('\n');
const summaryEmbedding = await embedText(summarization.summary, options);
const contextExcerpt = compactContextExcerpt(contextText, options.contextExcerptChars);
const chunkStart = Math.min(...chunk.map((event) => event.timestamp));
const chunkEnd = Math.max(...chunk.map((event) => event.timestamp));
const record = createMemoryRecord({
agent_id: agentId,
session_id: typeof chunk[0]?.metadata?.session_id === 'string' ? chunk[0].metadata.session_id : 'unknown',
type: 'batch_window',
content: contextText,
summary: summarization.summary,
tags: [...new Set(chunk.flatMap((event) => event.tags))],
importance: Number.isFinite(chunk[0]?.metadata?.importance)
? Number(chunk[0].metadata.importance)
: summarization.importance,
embedding_id: Array.isArray(summaryEmbedding) && summaryEmbedding.length > 0 ? randomUUID() : null,
created_at: chunkStart,
last_seen: chunkEnd,
source: typeof chunk[0]?.metadata?.source === 'string' ? chunk[0].metadata.source : 'ingest',
ttl_days: Number.isFinite(chunk[0]?.metadata?.ttl_days) ? Number(chunk[0].metadata.ttl_days) : null,
merged_into: null,
pinned: false,
});
const validation = validateMemoryItem(record);
if (!validation.valid) {
console.error('memory_validation_error', {
stage: 'ingest',
reason: 'record_failed_schema_validation',
fields: validation.errors,
item: record,
});
throw new TypeError('Memory ingest rejected: invalid durable record format');
}
await insertMemory(repository, record);
insertedRecords.push(record);
if (record.embedding_id) {
const vectorMetadata = {
memory_id: record.id,
agent_id: record.agent_id,
summary: record.summary,
context_excerpt: contextExcerpt,
source: record.source,
created_at: record.created_at,
last_seen: record.last_seen,
};
if (typeof repository.linkEmbedding === 'function') {
await repository.linkEmbedding(record.id, {
id: record.embedding_id,
vector: summaryEmbedding,
metadata: vectorMetadata,
});
}
const adapter = options.embedderAdapter ?? options.adapter ?? getDefaultEmbedderAdapter();
if (typeof adapter?.upsertVector === 'function') {
await adapter.upsertVector({
id: record.embedding_id,
vector: summaryEmbedding,
metadata: vectorMetadata,
});
}
}
emitTelemetry('memory:ingested', {
memory_id: record.id,
agent_id: record.agent_id,
dedupe_key: dedupeKey,
chunk_events: chunk.length,
window_start: windowStart,
window_end: windowEnd,
});
}
await rememberWindowFingerprint(repository, dedupeKey, {
agent_id: agentId,
window_start: windowStart,
window_end: windowEnd,
});
}
return insertedRecords;
}
/**
* @param {{ insertMemory: (memory: import('./schema.js').MemoryRecord) => Promise<unknown> }} repository
* @param {import('./schema.js').MemoryRecord} memory
*/
export async function insertMemory(repository, memory) {
return repository.insertMemory(memory);
}
+313
View File
@@ -0,0 +1,313 @@
import { loadMemoryPromptTemplates } from './summarizer.js';
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* @typedef {'keep' | 'archive' | 'delete'} LifecycleAction
*/
/**
* @param {number[] | null | undefined} a
* @param {number[] | null | undefined} b
*/
function cosineSimilarity(a, b) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) {
return 0;
}
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i += 1) {
const av = Number(a[i]) || 0;
const bv = Number(b[i]) || 0;
dot += av * bv;
normA += av * av;
normB += bv * bv;
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom > 0 ? dot / denom : 0;
}
/**
* @param {string[]} left
* @param {string[]} right
*/
function hasTagOverlap(left = [], right = []) {
if (!left.length || !right.length) return false;
const rightSet = new Set(right);
return left.some((tag) => rightSet.has(tag));
}
/**
* @param {import('./schema.js').MemoryRecord} memory
* @param {{ now: number, retentionMs: number, lowImportanceThreshold: number, recentUsageMs: number }} policy
*/
function evaluateCandidate(memory, policy) {
const ttlExpired = Number.isInteger(memory.ttl_days)
? memory.created_at + (memory.ttl_days * DAY_MS) <= policy.now
: false;
const staleByRetention = memory.last_seen < (policy.now - policy.retentionMs);
const lowImportance = memory.importance <= policy.lowImportanceThreshold;
const lowRecentUsage = memory.last_seen <= (policy.now - policy.recentUsageMs);
return {
ttlExpired,
staleByRetention,
lowImportance,
lowRecentUsage,
candidate: !memory.pinned && (ttlExpired || staleByRetention) && lowImportance && lowRecentUsage,
};
}
/**
* @param {import('./schema.js').MemoryRecord[]} memories
* @param {{ getEmbedding?: (memory: import('./schema.js').MemoryRecord) => number[] | null | undefined, similarityThreshold: number, duplicateWindowMs: number }} options
*/
function groupNearDuplicates(memories, options) {
const groups = [];
const consumed = new Set();
for (let i = 0; i < memories.length; i += 1) {
const base = memories[i];
if (consumed.has(base.id) || base.merged_into) continue;
const baseEmbedding = options.getEmbedding?.(base);
const group = [base];
for (let j = i + 1; j < memories.length; j += 1) {
const probe = memories[j];
if (consumed.has(probe.id) || probe.merged_into || probe.id === base.id) continue;
const withinWindow = Math.abs(base.created_at - probe.created_at) <= options.duplicateWindowMs;
if (!withinWindow || !hasTagOverlap(base.tags, probe.tags)) continue;
const similarity = cosineSimilarity(baseEmbedding, options.getEmbedding?.(probe));
if (similarity >= options.similarityThreshold) {
group.push(probe);
}
}
if (group.length > 1) {
group.forEach((item) => consumed.add(item.id));
groups.push(group.sort((a, b) => b.last_seen - a.last_seen));
}
}
return groups;
}
/**
* @param {import('./schema.js').MemoryRecord[]} group
* @param {(prompt: string) => Promise<string> | string} [generateSummary]
* @param {string} condenseTemplate
*/
async function condenseGroup(group, generateSummary, condenseTemplate) {
const [primary, ...rest] = group;
const fallbackSummary = group.map((item) => item.summary || item.content).filter(Boolean).join(' ').slice(0, 280);
const fallback = {
summary: fallbackSummary || primary.summary,
importance: Math.max(...group.map((item) => item.importance)),
};
if (typeof generateSummary !== 'function') {
return { merged: { ...primary, summary: fallback.summary, importance: fallback.importance }, mergedIds: rest.map((item) => item.id), usedFallback: true };
}
const sourceBlock = group
.map((item) => `- id=${item.id}; tags=${item.tags.join(',')}; ts=${item.created_at}; summary=${item.summary || item.content}`)
.join('\n');
const prompt = [
'Condense the following near-duplicate memories into one factual summary.',
'Return STRICT JSON: {"summary":"...","importance":0.0}',
'Do not invent facts.',
'',
`Memories:\n${sourceBlock}`,
'',
'If your first response is malformed, repair it using this policy:',
condenseTemplate,
].join('\n');
try {
const raw = await generateSummary(prompt);
const parsed = JSON.parse(raw);
const summary = typeof parsed?.summary === 'string' && parsed.summary.trim() ? parsed.summary.trim() : fallback.summary;
const importance = Number.isFinite(Number(parsed?.importance))
? Math.max(0, Math.min(1, Number(parsed.importance)))
: fallback.importance;
return {
merged: {
...primary,
summary,
importance,
tags: [...new Set(group.flatMap((item) => item.tags))],
last_seen: Math.max(...group.map((item) => item.last_seen)),
},
mergedIds: rest.map((item) => item.id),
usedFallback: false,
};
} catch {
return { merged: { ...primary, summary: fallback.summary, importance: fallback.importance }, mergedIds: rest.map((item) => item.id), usedFallback: true };
}
}
/**
* @param {import('./schema.js').MemoryRecord[]} memories
* @param {{
* retentionMs: number,
* now?: number,
* lowImportanceThreshold?: number,
* deleteImportanceThreshold?: number,
* recentUsageMs?: number,
* duplicateWindowMs?: number,
* similarityThreshold?: number,
* getEmbedding?: (memory: import('./schema.js').MemoryRecord) => number[] | null | undefined,
* generateSummary?: (prompt: string) => Promise<string> | string,
* templateDir?: string,
* }} options
*/
export async function createLifecyclePlan(memories, options) {
const now = options.now ?? Date.now();
const policy = {
now,
retentionMs: options.retentionMs,
lowImportanceThreshold: options.lowImportanceThreshold ?? 0.35,
deleteImportanceThreshold: options.deleteImportanceThreshold ?? 0.12,
recentUsageMs: options.recentUsageMs ?? Math.max(Math.floor(options.retentionMs / 2), DAY_MS),
duplicateWindowMs: options.duplicateWindowMs ?? (7 * DAY_MS),
similarityThreshold: options.similarityThreshold ?? 0.93,
};
const candidateById = new Map(memories.map((memory) => [memory.id, evaluateCandidate(memory, policy)]));
const groups = groupNearDuplicates(memories.filter((memory) => !memory.pinned), {
getEmbedding: options.getEmbedding,
similarityThreshold: policy.similarityThreshold,
duplicateWindowMs: policy.duplicateWindowMs,
});
const templates = loadMemoryPromptTemplates({ templateDir: options.templateDir });
const condensedGroups = [];
const mergedIntoMap = new Map();
for (const group of groups) {
const condensed = await condenseGroup(group, options.generateSummary, templates.condense);
condensedGroups.push(condensed);
for (const mergedId of condensed.mergedIds) {
mergedIntoMap.set(mergedId, condensed.merged.id);
}
}
const protectedFromDelete = new Set(mergedIntoMap.keys());
const actions = [];
const policyLogs = [];
for (const memory of memories) {
const candidate = candidateById.get(memory.id) ?? evaluateCandidate(memory, policy);
/** @type {LifecycleAction} */
let action = 'keep';
let reason = 'retained: does not satisfy low-value prune gates';
if (memory.pinned) {
action = 'keep';
reason = 'retained: pinned memories are never deleted';
} else if (mergedIntoMap.has(memory.id)) {
action = 'archive';
reason = `archived: merged into ${mergedIntoMap.get(memory.id)} and retained as source provenance`;
} else if (candidate.candidate) {
const veryLowImportance = memory.importance <= policy.deleteImportanceThreshold;
const veryOld = memory.last_seen < (now - (policy.retentionMs * 2));
const decisiveDelete = candidate.ttlExpired && candidate.staleByRetention && veryLowImportance && veryOld;
if (protectedFromDelete.has(memory.id)) {
action = 'archive';
reason = 'archived: referenced by retained merged summary';
} else if (decisiveDelete) {
action = 'delete';
reason = 'deleted: expired, very low importance, and long-unseen beyond retention grace';
} else {
action = 'archive';
reason = 'archived: borderline low-value candidate; archive preferred over delete';
}
}
actions.push({ id: memory.id, action, merged_into: mergedIntoMap.get(memory.id) ?? null, reason });
policyLogs.push({
id: memory.id,
action,
reason,
signals: {
ttlExpired: candidate.ttlExpired,
staleByRetention: candidate.staleByRetention,
lowImportance: candidate.lowImportance,
lowRecentUsage: candidate.lowRecentUsage,
},
});
}
return {
actions,
policyLogs,
condensedGroups,
};
}
/**
* @param {{
* archiveMemory?: (id: string, reason: string) => Promise<unknown> | unknown,
* deleteMemory?: (id: string, reason: string) => Promise<unknown> | unknown,
* keepMemory?: (id: string, reason: string) => Promise<unknown> | unknown,
* markMerged?: (id: string, mergedInto: string) => Promise<unknown> | unknown,
* }} repository
* @param {Awaited<ReturnType<typeof createLifecyclePlan>>} plan
*/
export async function applyLifecycleActions(repository, plan) {
const results = [];
for (const decision of plan.actions) {
if (decision.merged_into && typeof repository.markMerged === 'function') {
await repository.markMerged(decision.id, decision.merged_into);
}
if (decision.action === 'archive') {
await repository.archiveMemory?.(decision.id, decision.reason);
} else if (decision.action === 'delete') {
await repository.deleteMemory?.(decision.id, decision.reason);
} else {
await repository.keepMemory?.(decision.id, decision.reason);
}
results.push(decision);
}
return {
applied: results,
policyLogs: plan.policyLogs,
};
}
/**
* @param {import('./schema.js').MemoryRecord[]} memories
* @param {{ retentionMs: number, now?: number }} options
* @returns {import('./schema.js').MemoryRecord[]}
*/
export function selectPrunableMemories(memories, options) {
const now = options.now ?? Date.now();
const cutoff = now - options.retentionMs;
return memories.filter((memory) => !memory.pinned && memory.last_seen < cutoff);
}
/**
* @param {{ listPruneCandidates: (options: { cutoff: number, limit?: number }) => Promise<import('./schema.js').MemoryRecord[]> }} repository
* @param {{ retentionMs: number, now?: number, limit?: number }} options
*/
export async function listPruneCandidates(repository, options) {
const now = options.now ?? Date.now();
const cutoff = now - options.retentionMs;
return repository.listPruneCandidates({ cutoff, limit: options.limit });
}
+181
View File
@@ -0,0 +1,181 @@
const DEFAULT_WEIGHTS = {
w_sim: 0.6,
w_imp: 0.25,
w_rec: 0.15,
pinBoost: 0.15,
};
const DEFAULT_RECENCY_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 7;
function cosineSimilarity(left = [], right = []) {
const size = Math.max(left.length, right.length);
let dot = 0;
let leftNorm = 0;
let rightNorm = 0;
for (let index = 0; index < size; index += 1) {
const leftValue = Number(left[index] ?? 0);
const rightValue = Number(right[index] ?? 0);
dot += leftValue * rightValue;
leftNorm += leftValue ** 2;
rightNorm += rightValue ** 2;
}
if (leftNorm === 0 || rightNorm === 0) return 0;
return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
}
function computeRecencyDecay(timestamp, now, halfLifeMs) {
if (!Number.isFinite(timestamp) || !Number.isFinite(now) || halfLifeMs <= 0) return 0;
const age = Math.max(0, now - timestamp);
return Math.exp(-(age / halfLifeMs));
}
function textMatchSimilarity(query, memory) {
const normalizedQuery = String(query ?? '').trim().toLowerCase();
if (!normalizedQuery) return 0;
const haystack = `${memory.summary} ${memory.content}`.toLowerCase();
if (haystack.includes(normalizedQuery)) return 1;
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
if (queryTerms.length === 0) return 0;
const matchedTerms = queryTerms.filter((term) => haystack.includes(term)).length;
return matchedTerms / queryTerms.length;
}
/**
* @param {import('./schema.js').MemoryRecord} memory
* @param {{ tags: string[], timeframe?: { from?: number, to?: number }, pinnedPreference?: 'prefer' | 'only' | 'exclude' }} params
*/
function matchesMetadataFilters(memory, params) {
const inTimeframe = !params.timeframe
|| ((params.timeframe.from == null || memory.created_at >= params.timeframe.from)
&& (params.timeframe.to == null || memory.created_at <= params.timeframe.to));
if (!inTimeframe) return false;
if (params.tags.length > 0) {
const memoryTags = new Set(memory.tags);
const matchesAllTags = params.tags.every((tag) => memoryTags.has(tag));
if (!matchesAllTags) return false;
}
if (params.pinnedPreference === 'only' && !memory.pinned) return false;
if (params.pinnedPreference === 'exclude' && memory.pinned) return false;
return true;
}
/**
* @param {import('./schema.js').MemoryRecord[]} memories
* @param {{ query: string, agent_id: string, k: number, vectorAdapter?: { embedText: (text: string) => Promise<number[]> | number[], queryVector: (input: { vector: number[], k?: number, filter?: Record<string, unknown> }) => Promise<Array<{ id: string, score: number }>> | Array<{ id: string, score: number }> }, embedText?: (value: string) => Promise<number[]> | number[] }} params
*/
async function buildSimilarityIndex(memories, params) {
const similarityById = new Map();
const normalizedQuery = params.query.trim();
if (!normalizedQuery) {
return similarityById;
}
if (params.vectorAdapter && typeof params.vectorAdapter.queryVector === 'function') {
const queryVector = typeof params.embedText === 'function'
? await params.embedText(normalizedQuery)
: await params.vectorAdapter.embedText(normalizedQuery);
const vectorHits = await params.vectorAdapter.queryVector({
vector: queryVector,
k: Math.max(params.k * 4, memories.length),
filter: { agent_id: params.agent_id },
});
for (const hit of vectorHits) {
if (typeof hit.id === 'string' && Number.isFinite(hit.score)) {
similarityById.set(hit.id, Math.max(0, Number(hit.score)));
}
}
if (similarityById.size > 0) {
return similarityById;
}
}
for (const memory of memories) {
const lexicalScore = textMatchSimilarity(normalizedQuery, memory);
if (lexicalScore > 0) {
similarityById.set(memory.id, lexicalScore);
}
}
return similarityById;
}
/**
* @param {import('./schema.js').MemoryRecord[]} memories
* @param {{ agent_id: string, query?: string, tags?: string[], timeframe?: { from?: number, to?: number }, k?: number, pinnedPreference?: 'prefer' | 'only' | 'exclude', weights?: { w_sim?: number, w_imp?: number, w_rec?: number, pinBoost?: number }, now?: number, recencyHalfLifeMs?: number, vectorAdapter?: { embedText: (text: string) => Promise<number[]> | number[], queryVector: (input: { vector: number[], k?: number, filter?: Record<string, unknown> }) => Promise<Array<{ id: string, score: number }>> | Array<{ id: string, score: number }> }, embedText?: (value: string) => Promise<number[]> | number[] }} params
* @returns {Promise<import('./schema.js').MemoryRecord[]>}
*/
export async function filterAndRankMemories(memories, params) {
const {
agent_id,
query = '',
tags = [],
timeframe,
k = 10,
pinnedPreference = 'prefer',
weights = {},
now = Date.now(),
recencyHalfLifeMs = DEFAULT_RECENCY_HALF_LIFE_MS,
vectorAdapter,
embedText,
} = params;
const mergedWeights = {
...DEFAULT_WEIGHTS,
...weights,
};
const filtered = memories.filter((memory) => {
if (memory.agent_id !== agent_id) return false;
return matchesMetadataFilters(memory, { tags, timeframe, pinnedPreference });
});
const similarityById = await buildSimilarityIndex(filtered, {
query,
agent_id,
k,
vectorAdapter,
embedText,
});
return filtered
.map((memory) => {
const memoryTimestamp = Number.isFinite(memory.last_seen) ? memory.last_seen : memory.created_at;
const recencyDecay = computeRecencyDecay(memoryTimestamp, now, recencyHalfLifeMs);
const similarity = similarityById.get(memory.id) ?? 0;
const pinBoost = memory.pinned && pinnedPreference !== 'exclude' ? mergedWeights.pinBoost : 0;
const score = (similarity * mergedWeights.w_sim)
+ (memory.importance * mergedWeights.w_imp)
+ (recencyDecay * mergedWeights.w_rec)
+ pinBoost;
return { memory, score };
})
.sort((left, right) => right.score - left.score)
.slice(0, k)
.map((entry) => entry.memory);
}
/**
* @param {{ updateMemoryUsage: (id: string, lastSeen?: number) => Promise<unknown> }} repository
* @param {string[]} ids
* @param {number} [lastSeen]
*/
export async function updateMemoryUsage(repository, ids, lastSeen = Date.now()) {
await Promise.all(ids.map((id) => repository.updateMemoryUsage(id, lastSeen)));
}
export { cosineSimilarity };
+317
View File
@@ -0,0 +1,317 @@
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;
const WEEK_MS = 7 * DAY_MS;
const JOBS = {
ingestRecentRuntimeEvents: {
lockKey: 'memory:job:ingest-recent-runtime-events',
minIntervalMs: MINUTE_MS,
maxIntervalMs: 5 * MINUTE_MS,
},
consolidateObservations: {
lockKey: 'memory:job:consolidate-observations',
intervalMs: HOUR_MS,
},
pruningCycle: {
lockKey: 'memory:job:pruning-cycle',
intervalMs: DAY_MS,
},
deepMergeArchivalMaintenance: {
lockKey: 'memory:job:deep-merge-archival-maintenance',
intervalMs: WEEK_MS,
},
};
/**
* @param {() => Promise<unknown> | unknown} task
* @param {{ intervalMs: number }} options
* @returns {{ stop: () => void }}
*/
export function startScheduler(task, options) {
const handle = setInterval(() => {
void task();
}, options.intervalMs);
return {
stop() {
clearInterval(handle);
},
};
}
function normalizeItemCount(result) {
if (typeof result === 'number' && Number.isFinite(result)) {
return Math.max(0, Math.floor(result));
}
if (result && typeof result === 'object') {
const count = result.itemCount ?? result.count ?? result.processed ?? result.rows;
if (typeof count === 'number' && Number.isFinite(count)) {
return Math.max(0, Math.floor(count));
}
}
return 0;
}
/**
* @param {string} key
*/
function hashLockKey(key) {
let hash = 0;
for (let i = 0; i < key.length; i += 1) {
hash = ((hash * 31) + key.charCodeAt(i)) | 0;
}
return hash;
}
/**
* @param {{ query: (sql: string, params?: unknown[]) => Promise<{ rows?: any[] }> }} db
* @param {{ namespace?: string }} [options]
*/
export function createDbAdvisoryLockProvider(db, options = {}) {
const namespace = options.namespace ?? 'torch-memory-jobs';
return {
/**
* @param {string} key
* @param {() => Promise<unknown>} task
*/
async withLock(key, task) {
const lockId = `${namespace}:${key}`;
const keyA = hashLockKey(namespace);
const keyB = hashLockKey(lockId);
const acquiredResult = await db.query('SELECT pg_try_advisory_lock($1, $2) AS locked', [keyA, keyB]);
const acquired = Boolean(acquiredResult?.rows?.[0]?.locked);
if (!acquired) {
return { acquired: false, result: null };
}
try {
const result = await task();
return { acquired: true, result };
} finally {
await db.query('SELECT pg_advisory_unlock($1, $2)', [keyA, keyB]);
}
},
};
}
function createInMemoryLockProvider() {
const locks = new Set();
return {
async withLock(key, task) {
if (locks.has(key)) {
return { acquired: false, result: null };
}
locks.add(key);
try {
const result = await task();
return { acquired: true, result };
} finally {
locks.delete(key);
}
},
};
}
/**
* @param {{
* jobName: string,
* lockKey: string,
* handler: () => Promise<unknown> | unknown,
* lockProvider: { withLock: (key: string, task: () => Promise<unknown>) => Promise<{ acquired: boolean, result: unknown }> },
* maxRetries: number,
* retryDelayMs: number,
* emitMetric?: (metricName: string, payload: Record<string, unknown>) => void,
* }} options
*/
async function runScheduledJob(options) {
const startedAt = Date.now();
let failures = 0;
let retries = 0;
for (let attempt = 0; attempt <= options.maxRetries; attempt += 1) {
try {
const lockRun = await options.lockProvider.withLock(options.lockKey, async () => options.handler());
if (!lockRun.acquired) {
options.emitMetric?.('memory_scheduler_job', {
job: options.jobName,
status: 'skipped_lock_unavailable',
durationMs: Date.now() - startedAt,
itemCount: 0,
failures,
retries,
});
return;
}
options.emitMetric?.('memory_scheduler_job', {
job: options.jobName,
status: 'success',
durationMs: Date.now() - startedAt,
itemCount: normalizeItemCount(lockRun.result),
failures,
retries,
});
return;
} catch (error) {
failures += 1;
if (attempt < options.maxRetries) {
retries += 1;
await new Promise((resolve) => {
setTimeout(resolve, options.retryDelayMs);
});
continue;
}
options.emitMetric?.('memory_scheduler_job', {
job: options.jobName,
status: 'failed',
durationMs: Date.now() - startedAt,
itemCount: 0,
failures,
retries,
error: error instanceof Error ? error.message : String(error),
});
return;
}
}
}
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: {
* ingestRecentRuntimeEvents: () => Promise<unknown> | unknown,
* consolidateObservations: () => Promise<unknown> | unknown,
* pruningCycle: () => Promise<unknown> | unknown,
* deepMergeArchivalMaintenance: () => Promise<unknown> | unknown,
* },
* lockProvider?: { withLock: (key: string, task: () => Promise<unknown>) => Promise<{ acquired: boolean, result: unknown }> },
* emitMetric?: (metricName: string, payload: Record<string, unknown>) => void,
* maxRetries?: number,
* retryDelayMs?: number,
* runImmediately?: boolean,
* random?: () => number,
* }} options
*/
export function startMemoryMaintenanceScheduler(options) {
const lockProvider = options.lockProvider ?? createInMemoryLockProvider();
const maxRetries = Number.isFinite(options.maxRetries) ? Math.max(0, Math.floor(options.maxRetries)) : 2;
const retryDelayMs = Number.isFinite(options.retryDelayMs) ? Math.max(0, Math.floor(options.retryDelayMs)) : 1_000;
const random = typeof options.random === 'function' ? options.random : Math.random;
const handles = [];
const timeouts = new Set();
const runJob = async (jobName, definition, handler) => runScheduledJob({
jobName,
lockKey: definition.lockKey,
handler,
lockProvider,
maxRetries,
retryDelayMs,
emitMetric: options.emitMetric,
});
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);
};
if (options.runImmediately !== false) run();
const intervalHandle = setInterval(run, definition.intervalMs);
handles.push(() => clearInterval(intervalHandle));
};
const startRandomIntervalJob = (jobName, definition, handler) => {
const scheduleNext = () => {
const jitterRatio = Math.min(1, Math.max(0, random()));
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);
timeouts.add(timeout);
};
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();
});
} else {
scheduleNext();
}
};
startRandomIntervalJob('ingestRecentRuntimeEvents', JOBS.ingestRecentRuntimeEvents, options.handlers.ingestRecentRuntimeEvents);
startFixedIntervalJob('consolidateObservations', JOBS.consolidateObservations, options.handlers.consolidateObservations);
startFixedIntervalJob('pruningCycle', JOBS.pruningCycle, options.handlers.pruningCycle);
startFixedIntervalJob('deepMergeArchivalMaintenance', JOBS.deepMergeArchivalMaintenance, options.handlers.deepMergeArchivalMaintenance);
return {
stop() {
for (const stopHandle of handles) {
stopHandle();
}
for (const timeout of timeouts) {
clearTimeout(timeout);
}
timeouts.clear();
},
};
}
+134
View File
@@ -0,0 +1,134 @@
/**
* @typedef {Object} MemoryEvent
* @property {string} agent_id
* @property {string} content
* @property {number} [timestamp]
* @property {string[]} [tags]
* @property {Record<string, unknown>} [metadata]
*/
export const SCHEMA_VERSION = 1;
/**
* @typedef {Object} MemoryRecord
* @property {number} schema_version
* @property {string} id
* @property {string} agent_id
* @property {string} session_id
* @property {string} type
* @property {string} content
* @property {string} summary
* @property {string[]} tags
* @property {number} importance
* @property {string | null} embedding_id
* @property {number} created_at
* @property {number} last_seen
* @property {string} source
* @property {number | null} ttl_days
* @property {string | null} merged_into
* @property {boolean} pinned
*/
/**
* @param {MemoryEvent} event
* @returns {MemoryEvent}
*/
export function normalizeEvent(event) {
return {
...event,
timestamp: Number.isFinite(event?.timestamp) ? Number(event.timestamp) : Date.now(),
tags: Array.isArray(event?.tags)
? event.tags.map((tag) => String(tag)).filter((tag) => tag.length > 0)
: [],
metadata: event?.metadata && typeof event.metadata === 'object' ? event.metadata : {},
};
}
/**
* @param {Partial<MemoryRecord>} input
* @returns {MemoryRecord}
*/
export function normalizeMemoryItem(input = {}) {
const now = Date.now();
return {
schema_version: SCHEMA_VERSION,
id: typeof input.id === 'string' && input.id.trim() ? input.id.trim() : crypto.randomUUID(),
agent_id: typeof input.agent_id === 'string' ? input.agent_id.trim() : '',
session_id: typeof input.session_id === 'string' ? input.session_id.trim() : 'unknown',
type: typeof input.type === 'string' && input.type.trim() ? input.type.trim() : 'event',
content: typeof input.content === 'string' ? input.content : '',
summary: typeof input.summary === 'string' ? input.summary : '',
tags: Array.isArray(input.tags)
? input.tags.map((tag) => String(tag).trim()).filter((tag) => tag.length > 0)
: [],
importance: Number.isFinite(input.importance)
? Math.max(0, Math.min(1, Number(input.importance)))
: 0.5,
embedding_id: typeof input.embedding_id === 'string' && input.embedding_id.trim()
? input.embedding_id.trim()
: null,
created_at: Number.isFinite(input.created_at) ? Number(input.created_at) : now,
last_seen: Number.isFinite(input.last_seen) ? Number(input.last_seen) : now,
source: typeof input.source === 'string' && input.source.trim() ? input.source.trim() : 'ingest',
ttl_days: Number.isFinite(input.ttl_days) ? Math.max(0, Math.floor(Number(input.ttl_days))) : null,
merged_into: typeof input.merged_into === 'string' && input.merged_into.trim()
? input.merged_into.trim()
: null,
pinned: Boolean(input.pinned),
};
}
/**
* @param {unknown} item
* @returns {{ valid: boolean, errors: Array<{ field: string, expected: string, received: string, value: unknown }> }}
*/
export function validateMemoryItem(item) {
const errors = [];
const check = (condition, field, expected, value) => {
if (!condition) {
errors.push({
field,
expected,
received: Array.isArray(value) ? 'array' : typeof value,
value,
});
}
};
check(item && typeof item === 'object', 'item', 'object', item);
if (!item || typeof item !== 'object') {
return { valid: false, errors };
}
check(item.schema_version === SCHEMA_VERSION, 'schema_version', `number (${SCHEMA_VERSION})`, item.schema_version);
check(typeof item.id === 'string' && item.id.length > 0, 'id', 'non-empty string', item.id);
check(typeof item.agent_id === 'string' && item.agent_id.length > 0, 'agent_id', 'non-empty string', item.agent_id);
check(typeof item.session_id === 'string' && item.session_id.length > 0, 'session_id', 'non-empty string', item.session_id);
check(typeof item.type === 'string' && item.type.length > 0, 'type', 'non-empty string', item.type);
check(typeof item.content === 'string', 'content', 'string', item.content);
check(typeof item.summary === 'string', 'summary', 'string', item.summary);
check(Array.isArray(item.tags) && item.tags.every((tag) => typeof tag === 'string'), 'tags', 'string[]', item.tags);
check(Number.isFinite(item.importance) && item.importance >= 0 && item.importance <= 1, 'importance', 'number between 0 and 1', item.importance);
check(item.embedding_id === null || (typeof item.embedding_id === 'string' && item.embedding_id.length > 0), 'embedding_id', 'null or non-empty string', item.embedding_id);
check(Number.isFinite(item.created_at), 'created_at', 'finite number', item.created_at);
check(Number.isFinite(item.last_seen), 'last_seen', 'finite number', item.last_seen);
check(typeof item.source === 'string' && item.source.length > 0, 'source', 'non-empty string', item.source);
check(item.ttl_days === null || (Number.isInteger(item.ttl_days) && item.ttl_days >= 0), 'ttl_days', 'null or non-negative integer', item.ttl_days);
check(item.merged_into === null || (typeof item.merged_into === 'string' && item.merged_into.length > 0), 'merged_into', 'null or non-empty string', item.merged_into);
check(typeof item.pinned === 'boolean', 'pinned', 'boolean', item.pinned);
return {
valid: errors.length === 0,
errors,
};
}
/**
* @param {Partial<MemoryRecord>} record
* @returns {MemoryRecord}
*/
export function createMemoryRecord(record) {
return normalizeMemoryItem(record);
}
+151
View File
@@ -0,0 +1,151 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_TEMPLATE_DIR = path.resolve(__dirname, '../../prompts/memory');
const TEMPLATE_FILES = {
summarize: 'summarize_prompt.txt',
condense: 'condense_prompt.txt',
score: 'score_prompt.txt',
prune: 'prune_prompt.txt',
};
const templateCache = new Map();
function redactObviousPii(text) {
if (typeof text !== 'string' || text.length === 0) return '';
return text
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted:email]')
.replace(/\b(?:\+?\d{1,2}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b/g, '[redacted:phone]')
.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[redacted:ssn]');
}
function renderTemplate(template, replacements) {
return Object.entries(replacements).reduce(
(output, [key, value]) => output.replaceAll(`{{${key}}}`, String(value ?? '')),
template
);
}
export function loadMemoryPromptTemplates(options = {}) {
const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR;
if (templateCache.has(templateDir)) {
return templateCache.get(templateDir);
}
const templates = Object.fromEntries(
Object.entries(TEMPLATE_FILES).map(([key, fileName]) => {
const filePath = path.join(templateDir, fileName);
const content = readFileSync(filePath, 'utf8').trim();
return [key, content];
})
);
templateCache.set(templateDir, templates);
return templates;
}
function parseSummaryPayload(rawResponse) {
const parsed = JSON.parse(rawResponse);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new TypeError('Response must be a JSON object');
}
const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : '';
if (!summary) {
throw new TypeError('Response.summary must be a non-empty string');
}
const rawImportance = Number(parsed.importance);
const importance = Number.isFinite(rawImportance)
? Math.min(1, Math.max(0, rawImportance))
: 0.25;
return { summary, importance };
}
function deterministicFallback(events, maxSummaryLength) {
const joined = events.map((event) => redactObviousPii(event.content)).join(' ').trim();
const bounded = joined.length > maxSummaryLength ? `${joined.slice(0, maxSummaryLength - 1)}…` : joined;
return { summary: bounded, importance: 0.25 };
}
function isLikelyFactual(summary, sourceText) {
const sourceTokens = new Set(
sourceText
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 3)
);
const summaryTokens = summary
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 3);
if (!summaryTokens.length) return true;
const overlap = summaryTokens.filter((token) => sourceTokens.has(token)).length;
return overlap / summaryTokens.length >= 0.35;
}
function sanitizeResult(parsed, sourceText, maxSummaryLength, fallback) {
const sanitizedSummary = redactObviousPii(parsed.summary);
if (!isLikelyFactual(sanitizedSummary, sourceText)) {
return fallback;
}
return {
summary: sanitizedSummary.length > maxSummaryLength
? `${sanitizedSummary.slice(0, maxSummaryLength - 1)}…`
: sanitizedSummary,
importance: parsed.importance,
};
}
/**
* @param {import('./schema.js').MemoryEvent[]} events
* @param {{
* maxSummaryLength?: number,
* generateSummary?: (prompt: string) => Promise<string> | string,
* templateDir?: string,
* }} [options]
* @returns {Promise<{ summary: string, importance: number }>}
*/
export async function summarizeEvents(events, options = {}) {
const maxSummaryLength = options.maxSummaryLength ?? 280;
const fallback = deterministicFallback(events, maxSummaryLength);
if (!fallback.summary) return fallback;
const templates = loadMemoryPromptTemplates(options);
const sourceText = events.map((event) => redactObviousPii(event.content)).join('\n').trim();
const generateSummary = options.generateSummary;
if (typeof generateSummary !== 'function') {
return fallback;
}
const summaryPrompt = renderTemplate(templates.summarize, {
EVENTS: sourceText,
MAX_SUMMARY_LENGTH: maxSummaryLength,
});
let firstResponse = '';
try {
firstResponse = await generateSummary(summaryPrompt);
const parsed = parseSummaryPayload(firstResponse);
return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback);
} catch (firstError) {
try {
const repairPrompt = renderTemplate(templates.condense, {
RAW_RESPONSE: firstResponse,
PARSE_ERROR: firstError instanceof Error ? firstError.message : 'unknown_parse_error',
});
const repairedResponse = await generateSummary(repairPrompt);
const parsed = parseSummaryPayload(repairedResponse);
return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback);
} catch {
return fallback;
}
}
}
@@ -0,0 +1,12 @@
# TODO — Rendering Migration (Example)
## Pending
- [ ] Replace remaining string-based rendering paths.
- [ ] Add safe helper utilities where needed.
- [ ] Update related tests.
## Completed
- [x] Audited current rendering entry points.
## Blocked
- [ ] Confirm final acceptance criteria with maintainers.
+11
View File
@@ -0,0 +1,11 @@
# TODO Checklist
- [ ] Update `src/constants.mjs` with defaults.
- [ ] Update `src/torch-config.mjs` with config getters.
- [ ] Create `src/roster.mjs` with roster logic.
- [ ] Create `src/lock-ops.mjs` with lock operations.
- [ ] Create `src/dashboard.mjs` with dashboard logic.
- [ ] Update `src/lib.mjs` to import new modules.
- [ ] Verify `npm run lint` passes.
- [ ] Verify `npm test` passes.
- [ ] Update baseline using `scripts/check-file-size.mjs --update`.
@@ -0,0 +1 @@
# Todo
@@ -0,0 +1,6 @@
# Audit Run TODO
- [ ] Run check-file-size.mjs
- [ ] Run check-innerhtml.mjs
- [ ] Run npm run lint
- [ ] Create summary
@@ -0,0 +1,5 @@
# Todo: Content Audit 2026-02-14T13-01-37Z
- [ ] Check if /content directory exists
- [ ] Inventory claims if content exists
- [ ] Verify claims if content exists
@@ -0,0 +1,11 @@
# Decomposition TODO
- [x] Create `src/errors.mjs` with `ExitError`.
- [x] Create `src/utils.mjs` with `todayDateStr`, `nowUnix`.
- [x] Create `src/cmd-check.mjs`.
- [x] Create `src/cmd-lock.mjs`.
- [x] Create `src/cmd-list.mjs`.
- [x] Update `src/lib.mjs` to import new modules.
- [x] Verify with lint.
- [ ] Verify with tests.
- [ ] Update file size baseline.
@@ -0,0 +1,10 @@
# TODO: dashboard/index.html innerHTML Migration
- [ ] `summaryBar.innerHTML = cards.map(...).join('')` (Line ~436)
- Strategy: Replace with `document.createElement` loop and `replaceChildren`.
- [ ] `lockGrid.innerHTML = ''` (Line ~448)
- Strategy: `lockGrid.replaceChildren()` + `appendChild(emptyState)`.
- [ ] `lockGrid.innerHTML = locks.map(...).join('')` (Line ~455)
- Strategy: Create `createLockCard` function using `document.createElement`, then `lockGrid.replaceChildren(...cards)`.
- [ ] `rawLog.innerHTML = ''` (Line ~534)
- Strategy: `rawLog.replaceChildren()`.
+19
View File
@@ -0,0 +1,19 @@
# Todo List (2026-02-15) - Protocol Research Agent
- **agent**: protocol-research-agent
- **cadence**: daily
- **run-start**: 2026-02-15T00:00:00Z
- **prompt**: src/prompts/daily/protocol-research-agent.md
## Pending Tasks
- [x] Scan for protocol references.
- [x] Create `PROTOCOL_INVENTORY.md`.
- [x] Create `protocol-report-2026-02-15.md`.
## Completed Tasks
- Scanned repository for `nostr-tools` and `ws` (executed by `protocol-research-agent`).
- Created inventory of protocols.
- Generated protocol report.
## Blocked Tasks
- None.
+2 -1
View File
@@ -1,4 +1,5 @@
# TODO for Perf Agent Run 2026-02-15T01-22-33Z
- [x] Scan codebase
- [x] Fix linting and formatting issues
- [x] Generate report
- [ ] Fix critical issues (none identified yet)
@@ -0,0 +1,14 @@
---
agent: onboarding-audit-agent
cadence: daily
run-start: 2026-02-15T01:03:31Z
prompt: src/prompts/daily/onboarding-audit-agent.md
---
# TODO Update - 2026-02-15T01:03:31Z
Agent: onboarding-audit-agent
Prompt: src/prompts/daily/onboarding-audit-agent.md
## Pending Tasks
- [ ] Fix prompt contract violations in `src/prompts/` files.
@@ -0,0 +1,18 @@
---
agent: prompt-fixer-agent
cadence: weekly
run-start: 2026-02-15T05:03:00Z
prompt: src/prompts/weekly/prompt-fixer-agent.md
---
# Todo - Prompt Safety Fixes
## Completed Tasks
- [x] Read `artifacts/prompt-safety-audit.md`
- [x] Identify agents needing fixes (24 total)
- [x] Create `fix_prompts.py` script to automate fixes
- [x] Apply fixes to 23 agents via script
- [x] Manually fix `protocol-research-agent.md` (no-op logic insertion)
- [x] Verify changes (checked `const-refactor-agent.md` and script output)
- [x] Generate run artifacts
- [ ] Run lint checks
- [ ] Complete lock
@@ -0,0 +1 @@
# Todo
@@ -0,0 +1,17 @@
---
agent: race-condition-agent
cadence: weekly
run-start: 2026-02-15T15:22:42Z
prompt: src/prompts/weekly/race-condition-agent.md
---
## Completed Tasks
- [x] Audit lock logic.
- [x] Implement fix in `src/lib.mjs`.
- [x] Add regression test in `test/lib.test.mjs`.
- [x] Verify tests pass.
- [x] Create weekly report.
- [x] Complete memory storage.
## Pending Tasks
- [ ] Verify artifacts.
- [ ] Complete lock.
@@ -0,0 +1,15 @@
---
agent: test-audit-agent
cadence: daily
run-start: 2026-02-15T16-15-05Z
prompt: src/prompts/daily/test-audit-agent.md
---
# Pending tasks
- [ ] Run tests with coverage
- [ ] Detect flakiness
- [ ] Run static analysis
- [ ] Generate report
# Completed tasks
- [x] Initialize artifacts
@@ -0,0 +1,16 @@
---
agent: todo-triage-agent
cadence: daily
run-start: 2026-02-15T18:00:00Z
prompt: src/prompts/daily/todo-triage-agent.md
---
# Todo
## Pending tasks
- None (Scan complete, no actionable items found).
## Completed tasks
- Scan repository for TODOs.
- Generate inventory `artifacts/todos.txt`.
- Analyze findings (0 actionable items).
@@ -0,0 +1,12 @@
---
agent: audit-agent
cadence: daily
run-start: 2026-02-15T18-51-10Z
prompt: src/prompts/daily/audit-agent.md
---
# Pending Tasks
- [ ] Run file size check
- [ ] Run innerHTML check
- [ ] Run lint check
- [ ] Synthesize summary
@@ -0,0 +1,14 @@
---
agent: ci-health-agent
cadence: daily
run-start: 2026-02-15T19-12-02Z
prompt: src/prompts/daily/ci-health-agent.md
---
# Todo for ci-health-agent
## Pending
- None
## Completed
- Run tests (5 loops)
- Run lint
Prompt: src/prompts/daily/ci-health-agent.md
@@ -0,0 +1,2 @@
# Todo
- [x] Replace 30078 with KIND_APP_DATA in src/cmd-lock.mjs
@@ -0,0 +1,16 @@
---
agent: decompose-agent
cadence: daily
run-start: 2026-02-15T23:20:19Z
prompt: src/prompts/daily/decompose-agent.md
---
# Pending tasks
- [ ] Run `scripts/check-file-size.mjs --report`
- [ ] Select largest file
- [ ] Verify recent changes via git log
- [ ] Plan decomposition blocks
- [ ] Create extraction plan
# Completed tasks
- [x] Initialized artifacts
+10
View File
@@ -0,0 +1,10 @@
# TODO: perf-agent (2026-02-16)
- [ ] Create context file.
- [ ] Run grep searches.
- [ ] Create hits file.
- [ ] Implement visibility gating in `dashboard/index.html`.
- [ ] Document decisions.
- [ ] Create test logs.
- [ ] Create daily report.
- [ ] Commit and push.
@@ -0,0 +1,13 @@
# Todo
**Agent**: docs-alignment-agent
**Cadence**: daily
**Session**: session-1771204722
**Run-Start**: 2026-02-16T01-18-43Z
**Prompt**: src/prompts/daily/docs-alignment-agent.md
## Pending Tasks
- [ ] Update other docs
## Completed Tasks
- [x] Check README.md vs package.json
@@ -0,0 +1,12 @@
# Todo
- Agent: docs-code-investigator
- Cadence: daily
- Run-Start: 2026-02-16T02:11:24Z
- Prompt Path: src/prompts/daily/docs-code-investigator.md
## Pending Tasks
- [ ] [pending] Review `src/lock-ops.mjs`
## Completed Tasks
- [x] [completed] Select candidate file (`src/lock-ops.mjs`)
- [x] [completed] Create overview document
@@ -0,0 +1,15 @@
---
agent: perf-agent
cadence: daily
run-start: 2026-02-17T00-00-00Z
prompt: src/prompts/daily/perf-agent.md
---
# Tasks
## Pending Tasks
- [x] Analyze search results
## Completed Tasks
- [x] Run search patterns
References: src/prompts/daily/perf-agent.md
@@ -0,0 +1,5 @@
# TODO for Scheduler Update Agent
- [x] Scan prompt directories
- [x] Update roster.json
- [x] Update scheduler markdown tables
@@ -0,0 +1,10 @@
# TODO for Scheduler Update Agent
**Agent**: scheduler-update-agent
**Cadence**: daily
**Session**: manual-jules-1771293773
Prompt: src/prompts/daily/scheduler-update-agent.md
- [x] Scan prompt directories
- [x] Update roster.json
- [x] Update scheduler markdown tables
@@ -0,0 +1,15 @@
# Todo
- Agent: changelog-agent
- Cadence: weekly
- Session: 2026-02-17T05-04-24Z
- Prompt Path: src/prompts/weekly/changelog-agent.md
## Pending Tasks
- [x] Read git log
- [x] Classify changes
- [x] Create release draft
- [x] Create memory update
## Completed Tasks
- Generated changelog draft
- Updated artifacts
@@ -0,0 +1,17 @@
# Todo - Feature Proposer Agent Run
**Agent**: feature-proposer-agent
**Cadence**: weekly
**Run-Start**: 2026-02-17T15:00:00Z
**Prompt-Path**: src/prompts/weekly/feature-proposer-agent.md
## Completed
- [x] Analyze repository for potential features.
- [x] Select `log-summary.mjs` as the target feature.
- [x] Create `features/` directory.
- [x] Implement `features/log-summary.mjs`.
- [x] Verify the script functionality.
- [x] Follow instructions in `src/prompts/weekly/feature-proposer-agent.md`.
## Pending
- [ ] Incorporate into main CLI (future work).
@@ -0,0 +1,13 @@
---
agent: torch-garbage-collection-agent
cadence: daily
session: 2026-02-17T16-37-11Z
run-start: 2026-02-17T16-37-11Z
---
# TODO for torch-garbage-collection-agent
Active prompt: src/prompts/daily/torch-garbage-collection-agent.md
## Completed
- [x] Scan for stale files
- [x] Verify no stale files exist
@@ -0,0 +1,18 @@
# Todo
<!--
Agent: audit-agent
Cadence: daily
Session: 2026-02-17T18-04-00Z
-->
## Pending tasks
- [ ] Run memory retrieval
- [ ] Run audit scripts
- [ ] Parse results
- [ ] Generate summary
- [ ] Run memory storage
## Completed tasks
- [x] Initialize artifacts
- [x] Run `src/prompts/daily/audit-agent.md` workflow
@@ -0,0 +1,10 @@
# Pending Tasks
- [ ] Run `npm test` to identify flakes.
- [ ] Fix identified flakes.
- [ ] Document findings.
- agent: ci-health-agent
- cadence: daily
- session: 2026-02-17T19-36-32Z
- run-start: 2026-02-17T19-36-32Z
- prompt-path: src/prompts/daily/ci-health-agent.md
@@ -0,0 +1 @@
- [x] Extract lock-ops constants to src/constants.mjs
@@ -0,0 +1,13 @@
---
agent: content-audit-agent
cadence: daily
run-start: 2026-02-17T21:29:25Z
prompt-path: src/prompts/daily/content-audit-agent.md
---
# Audit Tasks
## Completed Tasks
- [x] Check for existence of `/content` directory
- [x] If exists, inventory claims (Skipped - missing content)
- [x] If missing, report finding and exit (Done)
@@ -0,0 +1,20 @@
# Todo: Decompose scripts/agent/run-scheduler-cycle.mjs
- [ ] Extract `scripts/agent/scheduler-utils.mjs`
- [ ] `runCommand`
- [ ] `getSafeEnv`
- [ ] `sleep`
- [ ] `parseJsonFromOutput`
- [ ] `parseJsonEventsFromOutput`
- [ ] `redactSensitive`
- [ ] `excerptText`
- [ ] `parseNonNegativeInt`
- [ ] `parseBooleanFlag`
- [ ] `readJson`
- [ ] `normalizeStringList`
- [ ] Extract `scripts/agent/scheduler-lock.mjs`
- [ ] `acquireLockWithRetry`
- [ ] `summarizeLockFailureReasons`
- [ ] `classifyLockBackendError`
- [ ] `buildLockBackendRemediation`
- [ ] `runLockHealthPreflight`
@@ -0,0 +1,11 @@
# Tasks
- Agent: deps-security-agent
- Cadence: daily
- Run Start: 2026-02-17T23-13-04Z
- Prompt: src/prompts/daily/deps-security-agent.md
## Completed Tasks
- [x] Document `nostr-tools` update availability in report.
- [x] Document `eslint` vulnerabilities in report.
- [x] Create `artifacts/deps-report.md`.
+7
View File
@@ -0,0 +1,7 @@
# Todo: Docs Agent Run 2026-02-18
- [ ] Create `AGENTS.md` defining repo-wide policies.
- [ ] Create `docs/event-schemas.md`.
- [ ] Create `docs/runtime-fallback.md`.
- [ ] Add `npm run format` script (e.g., using Prettier) to `package.json`.
- [ ] Investigate why `test:unit` is not standardized in `package.json`.
+16
View File
@@ -0,0 +1,16 @@
---
agent: style-agent
cadence: daily
run-start: 2026-02-15T08:00:00Z
prompt: src/prompts/daily/style-agent.md
---
# Todo: Style Agent Daily Run
## Pending tasks
## Completed tasks
- [x] Run `npm run lint`
- [x] Run `npm run lint -- --fix` (not needed)
- [x] Run `node scripts/check-innerhtml.mjs`
- [x] Document results in test log
@@ -0,0 +1,14 @@
# TODO for Scheduler Update Agent
**Agent**: scheduler-update-agent
**Cadence**: daily
**Session**: manual-jules-1771293773
Prompt: src/prompts/daily/scheduler-update-agent.md
## Pending Tasks
- None
## Completed Tasks
- [x] Scan prompt directories
- [x] Update roster.json
- [x] Update scheduler markdown tables
@@ -0,0 +1,10 @@
# Todo: Weekly Synthesis Agent
- [x] Acquire lock.
- [x] Retrieve memory.
- [x] Gather git logs.
- [x] Gather task logs.
- [x] Analyze data.
- [x] Generate weekly report.
- [ ] Store memory.
- [ ] Publish completion.
+311
View File
@@ -0,0 +1,311 @@
import fs from 'node:fs';
import path from 'node:path';
import {
DEFAULT_RELAYS,
DEFAULT_TTL,
DEFAULT_NAMESPACE,
DEFAULT_QUERY_TIMEOUT_MS,
DEFAULT_PUBLISH_TIMEOUT_MS,
DEFAULT_MIN_SUCCESSFUL_PUBLISHES,
DEFAULT_MIN_ACTIVE_RELAY_POOL,
} from './constants.mjs';
const DEFAULT_CONFIG_PATH = 'torch-config.json';
let cachedConfig = null;
const MIN_TIMEOUT_MS = 100;
const MAX_TIMEOUT_MS = 120_000;
function parsePositiveInteger(value) {
if (!Number.isFinite(value) || value <= 0) return null;
return Math.floor(value);
}
function parseStringList(value) {
if (!Array.isArray(value)) return null;
const parsed = value.map((item) => String(item).trim()).filter(Boolean);
return parsed.length > 0 ? parsed : null;
}
function assertValidRelayUrl(relay, sourceLabel) {
let parsed;
try {
parsed = new URL(relay);
} catch {
throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (must be an absolute ws:// or wss:// URL)`);
}
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (protocol must be ws:// or wss://)`);
}
}
function assertTimeoutInRange(value, sourceLabel) {
if (!Number.isInteger(value) || value < MIN_TIMEOUT_MS || value > MAX_TIMEOUT_MS) {
throw new Error(
`Invalid ${sourceLabel}: ${value} (must be an integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} ms)`,
);
}
}
function assertPositiveCount(value, sourceLabel) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`Invalid ${sourceLabel}: ${value} (must be a positive integer)`);
}
}
function normalizeCadence(value, fallback) {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'daily' || normalized === 'weekly' || normalized === 'all' ? normalized : fallback;
}
function normalizeStatus(value, fallback) {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'active' || normalized === 'all' ? normalized : fallback;
}
export function getTorchConfigPath() {
const explicitPath = (process.env.TORCH_CONFIG_PATH || '').trim();
if (explicitPath) return path.resolve(process.cwd(), explicitPath);
const localPath = path.resolve(process.cwd(), DEFAULT_CONFIG_PATH);
if (fs.existsSync(localPath)) return localPath;
const parentPath = path.resolve(process.cwd(), '..', DEFAULT_CONFIG_PATH);
if (fs.existsSync(parentPath)) return parentPath;
return localPath;
}
export function parseTorchConfig(raw, configPath = null) {
const nostrLock = raw.nostrLock || {};
const dashboard = raw.dashboard || {};
const scheduler = raw.scheduler || {};
const firstPromptByCadence = scheduler.firstPromptByCadence || {};
const paused = scheduler.paused || {};
return {
configPath,
raw,
nostrLock: {
namespace: typeof nostrLock.namespace === 'string' ? nostrLock.namespace.trim() : null,
relays: parseStringList(nostrLock.relays),
relayFallbacks: parseStringList(nostrLock.relayFallbacks),
ttlSeconds: Number.isFinite(nostrLock.ttlSeconds) && nostrLock.ttlSeconds > 0
? Math.floor(nostrLock.ttlSeconds)
: null,
queryTimeoutMs: parsePositiveInteger(nostrLock.queryTimeoutMs),
publishTimeoutMs: parsePositiveInteger(nostrLock.publishTimeoutMs),
minSuccessfulRelayPublishes: parsePositiveInteger(nostrLock.minSuccessfulRelayPublishes),
minActiveRelayPool: parsePositiveInteger(nostrLock.minActiveRelayPool),
dailyRoster: parseStringList(nostrLock.dailyRoster),
weeklyRoster: parseStringList(nostrLock.weeklyRoster),
},
dashboard: {
defaultCadenceView: normalizeCadence(dashboard.defaultCadenceView, 'daily'),
defaultStatusView: normalizeStatus(dashboard.defaultStatusView, 'active'),
relays: parseStringList(dashboard.relays),
namespace: typeof dashboard.namespace === 'string' ? dashboard.namespace.trim() : null,
hashtag: typeof dashboard.hashtag === 'string' ? dashboard.hashtag.trim() : null,
auth: typeof dashboard.auth === 'string' ? dashboard.auth.trim() : null,
},
scheduler: {
firstPromptByCadence: {
daily: typeof firstPromptByCadence.daily === 'string' ? firstPromptByCadence.daily.trim() : null,
weekly: typeof firstPromptByCadence.weekly === 'string' ? firstPromptByCadence.weekly.trim() : null,
},
paused: {
daily: parseStringList(paused.daily) || [],
weekly: parseStringList(paused.weekly) || [],
},
},
};
}
/** @internal */
export function _resetTorchConfigCache() {
cachedConfig = null;
}
export async function loadTorchConfig(fileSystem = fs) {
if (cachedConfig) return cachedConfig;
const configPath = getTorchConfigPath();
let raw = {};
try {
const content = await fileSystem.promises.readFile(configPath, 'utf8');
raw = JSON.parse(content);
} catch (err) {
if (err.code !== 'ENOENT') {
throw new Error(`Failed to parse ${configPath}: ${err.message}`, { cause: err });
}
// If file missing (ENOENT), we use empty object defaults
}
cachedConfig = parseTorchConfig(raw, configPath);
validateLockBackendConfig(cachedConfig);
return cachedConfig;
}
function validateLockBackendConfig(config) {
const relays = config.nostrLock.relays || [];
const relayFallbacks = config.nostrLock.relayFallbacks || [];
for (const relay of relays) {
assertValidRelayUrl(relay, 'nostrLock.relays');
}
for (const relay of relayFallbacks) {
assertValidRelayUrl(relay, 'nostrLock.relayFallbacks');
}
if (config.nostrLock.queryTimeoutMs !== null) {
assertTimeoutInRange(config.nostrLock.queryTimeoutMs, 'nostrLock.queryTimeoutMs');
}
if (config.nostrLock.publishTimeoutMs !== null) {
assertTimeoutInRange(config.nostrLock.publishTimeoutMs, 'nostrLock.publishTimeoutMs');
}
if (config.nostrLock.minSuccessfulRelayPublishes !== null) {
assertPositiveCount(config.nostrLock.minSuccessfulRelayPublishes, 'nostrLock.minSuccessfulRelayPublishes');
}
if (config.nostrLock.minActiveRelayPool !== null) {
assertPositiveCount(config.nostrLock.minActiveRelayPool, 'nostrLock.minActiveRelayPool');
}
}
function parseEnvRelayList(envValue, envName) {
const relays = envValue.split(',').map((r) => r.trim()).filter(Boolean);
for (const relay of relays) {
assertValidRelayUrl(relay, envName);
}
return relays;
}
function parseEnvInteger(envValue, envName) {
const parsed = parseInt(envValue, 10);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid ${envName}: "${envValue}" (must be an integer)`);
}
return parsed;
}
function resolveIntegerConfig(envKey, configValue, defaultValue, validator, configLabel) {
const envValue = process.env[envKey];
if (envValue) {
const parsed = parseEnvInteger(envValue, envKey);
validator(parsed, envKey);
return parsed;
}
const value = configValue || defaultValue;
validator(value, `effective ${configLabel}`);
return value;
}
export async function getRelays() {
const config = await loadTorchConfig();
const envRelays = process.env.NOSTR_LOCK_RELAYS;
if (envRelays) {
return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAYS');
}
if (config.nostrLock.relays?.length) {
return config.nostrLock.relays;
}
for (const relay of DEFAULT_RELAYS) {
assertValidRelayUrl(relay, 'DEFAULT_RELAYS');
}
return DEFAULT_RELAYS;
}
export async function getRelayFallbacks() {
const config = await loadTorchConfig();
const envRelays = process.env.NOSTR_LOCK_RELAY_FALLBACKS;
if (envRelays) {
return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAY_FALLBACKS');
}
return config.nostrLock.relayFallbacks || [];
}
export async function getNamespace() {
const config = await loadTorchConfig();
const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim();
return namespace || DEFAULT_NAMESPACE;
}
export async function getTtl() {
const config = await loadTorchConfig();
const envTtl = process.env.NOSTR_LOCK_TTL;
if (envTtl) {
const parsed = parseInt(envTtl, 10);
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
}
if (config.nostrLock.ttlSeconds) {
return config.nostrLock.ttlSeconds;
}
return DEFAULT_TTL;
}
export async function getQueryTimeoutMs() {
const config = await loadTorchConfig();
return resolveIntegerConfig(
'NOSTR_LOCK_QUERY_TIMEOUT_MS',
config.nostrLock.queryTimeoutMs,
DEFAULT_QUERY_TIMEOUT_MS,
assertTimeoutInRange,
'query timeout',
);
}
export async function getPublishTimeoutMs() {
const config = await loadTorchConfig();
return resolveIntegerConfig(
'NOSTR_LOCK_PUBLISH_TIMEOUT_MS',
config.nostrLock.publishTimeoutMs,
DEFAULT_PUBLISH_TIMEOUT_MS,
assertTimeoutInRange,
'publish timeout',
);
}
export async function getMinSuccessfulRelayPublishes() {
const config = await loadTorchConfig();
return resolveIntegerConfig(
'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES',
config.nostrLock.minSuccessfulRelayPublishes,
DEFAULT_MIN_SUCCESSFUL_PUBLISHES,
assertPositiveCount,
'min successful relay publishes',
);
}
export async function getMinActiveRelayPool() {
const config = await loadTorchConfig();
return resolveIntegerConfig(
'NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL',
config.nostrLock.minActiveRelayPool,
DEFAULT_MIN_ACTIVE_RELAY_POOL,
assertPositiveCount,
'min active relay pool',
);
}
export async function getHashtag() {
const config = await loadTorchConfig();
const envValue = process.env.NOSTR_LOCK_HASHTAG;
if (envValue) {
return envValue.trim();
}
if (config.dashboard.hashtag) {
return config.dashboard.hashtag;
}
const namespace = await getNamespace();
return `${namespace}-agent-lock`;
}
export async function getDashboardAuth() {
const config = await loadTorchConfig();
const envValue = process.env.TORCH_DASHBOARD_AUTH;
if (envValue) {
return envValue.trim();
}
return config.dashboard.auth;
}
+36
View File
@@ -0,0 +1,36 @@
export function todayDateStr() {
return new Date().toISOString().slice(0, 10);
}
export function nowUnix() {
return Math.floor(Date.now() / 1000);
}
export function getIsoWeekStr(dateInput) {
// Parse input to ensure we work with a Date object
// If dateInput is YYYY-MM-DD string, new Date() treats it as UTC.
const d = dateInput ? new Date(dateInput) : new Date();
// If valid date, use UTC components to avoid local timezone issues
if (isNaN(d.getTime())) return '';
// Copy date so we don't mutate the original if passed by reference (though new Date() handles that)
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
// ISO week date system: Thursday determines the year
// day: 0 (Sun) -> 6 (Sat). We want 1 (Mon) -> 7 (Sun)
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
return `${date.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
export function detectPlatform() {
if (process.env.JULES_API_KEY || process.env.JULES_SESSION_ID) return 'jules';
if (process.env.CODEX_API_KEY || process.env.CODEX_SESSION_ID) return 'codex';
if (process.env.GOOSE_API_KEY || process.env.GOOSE_SESSION_ID) return 'goose';
return null;
}