mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
removed torch
This commit is contained in:
1 parent
39d3b72e66
commit
78cef9dfe7
74 files changed
-9688
No files matched your search
Generated
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "SeedPass",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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'],
|
||||
},
|
||||
];
|
||||
Generated
-170
@@ -1,170 +0,0 @@
|
||||
{
|
||||
"name": "torch",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"torch-lock": "https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
|
||||
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.1.0",
|
||||
"@noble/hashes": "~1.3.1",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
|
||||
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "~1.3.0",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools": {
|
||||
"version": "2.19.4",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
|
||||
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^0.5.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1",
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-wasm": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/torch-lock": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz",
|
||||
"integrity": "sha512-DiVKNkw/zAdIet5W5l9URVyCZwYRGRe1ji2WmkyG5JekIwG2AsM+WpuL1KBFINnsQx5LAJvUo8gonSBVC/k7VA==",
|
||||
"dependencies": {
|
||||
"nostr-tools": "2.19.4",
|
||||
"ws": "8.19.0"
|
||||
},
|
||||
"bin": {
|
||||
"torch-lock": "bin/torch-lock.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"torch-lock": "https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz"
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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)'}`);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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)`;
|
||||
@@ -1,210 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// Custom Error class for controlled exits
|
||||
export class ExitError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -1,684 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,697 +0,0 @@
|
||||
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;
|
||||
@@ -1,49 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
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.');
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# CI Health Agent
|
||||
|
||||
This agent is responsible for ensuring the repository is healthy by running tests and linters.
|
||||
|
||||
1. *Run repository checks.*
|
||||
- Execute `flake8 .` from the root directory to check code style.
|
||||
- Execute `pytest` from the root directory to run tests.
|
||||
2. *Report status.*
|
||||
- Log the output of the checks.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"daily": [
|
||||
"ci-health-agent"
|
||||
]
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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';
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
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 ?? '')}`;
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
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 };
|
||||
@@ -1,317 +0,0 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* @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}
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1 +0,0 @@
|
||||
# Todo
|
||||
@@ -1,6 +0,0 @@
|
||||
# Audit Run TODO
|
||||
|
||||
- [ ] Run check-file-size.mjs
|
||||
- [ ] Run check-innerhtml.mjs
|
||||
- [ ] Run npm run lint
|
||||
- [ ] Create summary
|
||||
@@ -1,5 +0,0 @@
|
||||
# Todo: Content Audit 2026-02-14T13-01-37Z
|
||||
|
||||
- [ ] Check if /content directory exists
|
||||
- [ ] Inventory claims if content exists
|
||||
- [ ] Verify claims if content exists
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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()`.
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
# TODO for Perf Agent Run 2026-02-15T01-22-33Z
|
||||
|
||||
- [x] Scan codebase
|
||||
- [x] Generate report
|
||||
- [ ] Fix critical issues (none identified yet)
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
# Todo
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
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).
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,2 +0,0 @@
|
||||
# Todo
|
||||
- [x] Replace 30078 with KIND_APP_DATA in src/cmd-lock.mjs
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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
|
||||
@@ -1,5 +0,0 @@
|
||||
# TODO: bug-reproducer-agent run 2026-02-16T11:47:38Z
|
||||
|
||||
- Agent: bug-reproducer-agent
|
||||
- Status: Completed
|
||||
- Resolution: Fixed XSS vulnerability in docs/assets/theme.js
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
# TODO for Scheduler Update Agent
|
||||
|
||||
- [x] Scan prompt directories
|
||||
- [x] Update roster.json
|
||||
- [x] Update scheduler markdown tables
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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
|
||||
@@ -1 +0,0 @@
|
||||
- [x] Extract lock-ops constants to src/constants.mjs
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,311 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
# CI Health Agent Check Failed
|
||||
|
||||
The repository checks failed during the CI run.
|
||||
|
||||
## Check Failures
|
||||
|
||||
1. **flake8**: Found numerous style violations (E501 line too long, unused imports, etc.).
|
||||
2. **pytest**: Failed to start due to configuration error (`unrecognized arguments: -n`).
|
||||
|
||||
This indicates the codebase is currently unhealthy or the environment is misconfigured.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"nostrLock": {
|
||||
"namespace": "torch-c00fb269",
|
||||
"relays": [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net"
|
||||
],
|
||||
"ttlSeconds": 7200,
|
||||
"queryTimeoutMs": 15000,
|
||||
"publishTimeoutMs": 8000,
|
||||
"minSuccessfulRelayPublishes": 2,
|
||||
"minActiveRelayPool": 2,
|
||||
"relayFallbacks": [
|
||||
"wss://relay.snort.social",
|
||||
"wss://relay.nostr.band"
|
||||
]
|
||||
},
|
||||
"dashboard": {
|
||||
"defaultCadenceView": "daily",
|
||||
"defaultStatusView": "active"
|
||||
},
|
||||
"scheduler": {
|
||||
"firstPromptByCadence": {
|
||||
"daily": "scheduler-update-agent",
|
||||
"weekly": "bug-reproducer-agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user