svamp-cli 0.2.98 → 0.2.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +7 -5
  2. package/bin/skills/loop/IMPLEMENTATION_PROGRESS.md +49 -0
  3. package/bin/skills/loop/SKILL.md +99 -0
  4. package/bin/skills/loop/bin/channel-core.mjs +161 -0
  5. package/bin/skills/loop/bin/channel-server.mjs +151 -0
  6. package/bin/skills/loop/bin/inject-loop.mjs +41 -0
  7. package/bin/skills/loop/bin/loop-init.mjs +128 -0
  8. package/bin/skills/loop/bin/loop-status.mjs +38 -0
  9. package/bin/skills/loop/bin/precompact.mjs +27 -0
  10. package/bin/skills/loop/bin/routine-cli.mjs +121 -0
  11. package/bin/skills/loop/bin/routine-core.mjs +126 -0
  12. package/bin/skills/loop/bin/routine-runner.mjs +125 -0
  13. package/bin/skills/loop/bin/routine-store.mjs +49 -0
  14. package/bin/skills/loop/bin/state-fp.mjs +113 -0
  15. package/bin/skills/loop/bin/stop-gate.mjs +170 -0
  16. package/bin/skills/loop/routines.process.yaml +20 -0
  17. package/bin/skills/loop/test/test-channel-core.mjs +86 -0
  18. package/bin/skills/loop/test/test-loop-gate.mjs +246 -0
  19. package/bin/skills/loop/test/test-routine-core.mjs +54 -0
  20. package/bin/skills/loop/test/test-routine-engine.mjs +122 -0
  21. package/dist/{agentCommands-BULNvfKa.mjs → agentCommands-muy26BZI.mjs} +2 -2
  22. package/dist/{auth-BfDOBBPy.mjs → auth-RVq9wRhV.mjs} +1 -1
  23. package/dist/{caddy-BMbX-mFX.mjs → caddy-CuTbE3NY.mjs} +1 -14
  24. package/dist/cli.mjs +76 -77
  25. package/dist/{commands-h2Dzb5m1.mjs → commands-ChzeHFd3.mjs} +1 -1
  26. package/dist/{commands-C9DbNFz1.mjs → commands-Cu96nDGv.mjs} +2 -2
  27. package/dist/{commands-FhGCsATM.mjs → commands-EwE87XNi.mjs} +1 -1
  28. package/dist/{commands-qE4ZGLzB.mjs → commands-lSqc48Ib.mjs} +6 -6
  29. package/dist/{commands-DIhhodx8.mjs → commands-rSREfaQg.mjs} +34 -42
  30. package/dist/{fleet-Cmma7Iu-.mjs → fleet-qN96q6Qb.mjs} +1 -1
  31. package/dist/{frpc-BZ4l4-os.mjs → frpc-CIkmTNdJ.mjs} +2 -15
  32. package/dist/{headlessCli-xRpI9fdk.mjs → headlessCli-BVcAcLr1.mjs} +2 -2
  33. package/dist/index.mjs +1 -1
  34. package/dist/package-B7S5w1VE.mjs +63 -0
  35. package/dist/{run-DxzG-3JD.mjs → run-CdtYIBbd.mjs} +158 -709
  36. package/dist/{run-DTIEcH-W.mjs → run-zXRdkYtk.mjs} +1 -1
  37. package/dist/{serveCommands-CzllIFB_.mjs → serveCommands-BZd0reEj.mjs} +5 -5
  38. package/dist/{serveManager-C6_Vloil.mjs → serveManager-lmPtmRnR.mjs} +3 -3
  39. package/dist/{sideband-wPe3a3m1.mjs → sideband-JeID_jF-.mjs} +1 -1
  40. package/package.json +3 -3
  41. package/dist/package-DD227VZO.mjs +0 -63
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // loop-init.mjs — project the loop config into Claude Code's native files.
3
+ // Generates, in a target project dir:
4
+ // LOOP.md (if absent), .claude/loop/{loop.config.json,loop-state.json,bin/*},
5
+ // .claude/settings.json hooks (Stop gate + LOOP.md injection),
6
+ // and (optional) .claude/agents/loop-evaluator.md.
7
+ // Usage:
8
+ // node loop-init.mjs <dir> --task "..." [--criteria "..."] [--oracle "cmd"]
9
+ // [--max N] [--evaluator on|off] [--model NAME] [--loop-file LOOP.md]
10
+ import { mkdirSync, writeFileSync, copyFileSync, readFileSync, existsSync, chmodSync } from 'node:fs';
11
+ import { dirname, join, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+
16
+ function parseArgs(argv) {
17
+ const a = { _: [] };
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const t = argv[i];
20
+ if (t.startsWith('--')) { a[t.slice(2)] = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true; }
21
+ else a._.push(t);
22
+ }
23
+ return a;
24
+ }
25
+ const args = parseArgs(process.argv.slice(2));
26
+ const dir = resolve(args._[0] || process.cwd());
27
+ const loopFile = args['loop-file'] || 'LOOP.md';
28
+ const oracle = typeof args.oracle === 'string' ? args.oracle : null;
29
+ const max = args.max ? Number(args.max) : 20;
30
+ const evaluatorOn = args.evaluator !== 'off';
31
+ const model = typeof args.model === 'string' ? args.model : null;
32
+ const task = typeof args.task === 'string' ? args.task : '(describe the task here)';
33
+ const criteria = typeof args.criteria === 'string' ? args.criteria : null;
34
+
35
+ const loopDir = join(dir, '.claude', 'loop');
36
+ const binDir = join(loopDir, 'bin');
37
+ mkdirSync(binDir, { recursive: true });
38
+ mkdirSync(join(dir, '.claude', 'agents'), { recursive: true });
39
+
40
+ // 1. Copy hook scripts so the project is self-contained.
41
+ for (const f of ['state-fp.mjs', 'stop-gate.mjs', 'inject-loop.mjs', 'loop-status.mjs', 'precompact.mjs']) {
42
+ const dest = join(binDir, f);
43
+ copyFileSync(join(HERE, f), dest);
44
+ try { chmodSync(dest, 0o755); } catch {}
45
+ }
46
+
47
+ // 2. loop.config.json
48
+ const config = {
49
+ loop_file: loopFile,
50
+ oracle: oracle ? { command: oracle, timeout_sec: 600 } : null,
51
+ evaluator: { enabled: evaluatorOn, model },
52
+ max_iterations: max,
53
+ budget: (args['max-runtime'] || args['max-tokens']) ? {
54
+ ...(args['max-runtime'] ? { max_runtime_sec: Number(args['max-runtime']) } : {}),
55
+ ...(args['max-tokens'] ? { max_tokens: Number(args['max-tokens']) } : {}),
56
+ } : undefined,
57
+ };
58
+ writeFileSync(join(loopDir, 'loop.config.json'), JSON.stringify(config, null, 2));
59
+
60
+ // 3. loop-state.json (machine state, daemon/gate-owned)
61
+ writeFileSync(join(loopDir, 'loop-state.json'), JSON.stringify({
62
+ active: true, iteration: 0, phase: 'running', started_at: new Date().toISOString(),
63
+ }, null, 2));
64
+
65
+ // 4. LOOP.md (agent + human editable) — only if not present
66
+ const loopPath = join(dir, loopFile);
67
+ if (!existsSync(loopPath)) {
68
+ writeFileSync(loopPath, `# Loop Task
69
+
70
+ ## Task
71
+ ${task}
72
+
73
+ ## Success criteria
74
+ ${criteria
75
+ ? criteria.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => (l.startsWith('-') ? l : `- ${l}`)).join('\n')
76
+ : '- (define objective success criteria)'}
77
+ ${oracle ? `- The oracle passes: \`${oracle}\`` : ''}
78
+ - An independent evaluator confirms the work is genuinely complete.
79
+
80
+ ## Plan
81
+ - (the agent fills this in)
82
+
83
+ ## Progress
84
+ - (the agent appends iteration notes here; this is durable memory)
85
+ `);
86
+ }
87
+
88
+ // 5. .claude/settings.json hooks (merge if present)
89
+ const settingsPath = join(dir, '.claude', 'settings.json');
90
+ let settings = {};
91
+ if (existsSync(settingsPath)) { try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch {} }
92
+ const node = process.execPath;
93
+ const cmd = (script) => `"${node}" "${join(binDir, script)}"`;
94
+ settings.hooks = settings.hooks || {};
95
+ settings.hooks.SessionStart = [{ hooks: [{ type: 'command', command: cmd('inject-loop.mjs') }] }];
96
+ settings.hooks.UserPromptSubmit = [{ hooks: [{ type: 'command', command: cmd('inject-loop.mjs') }] }];
97
+ settings.hooks.Stop = [{ hooks: [{ type: 'command', command: cmd('stop-gate.mjs') }] }];
98
+ settings.hooks.PreCompact = [{ hooks: [{ type: 'command', command: cmd('precompact.mjs') }] }];
99
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
100
+
101
+ // 6. Evaluator agent (materialized) — optional
102
+ if (evaluatorOn) {
103
+ const fm = ['---', 'name: loop-evaluator',
104
+ 'description: Skeptical independent reviewer that decides if a loop task is genuinely complete.',
105
+ 'tools: Read, Bash, Grep, Glob', ...(model ? [`model: ${model}`] : []), '---', ''].join('\n');
106
+ writeFileSync(join(dir, '.claude', 'agents', 'loop-evaluator.md'), fm +
107
+ `You are an INDEPENDENT, skeptical reviewer. You did NOT write the code under review.
108
+
109
+ You are given: the task/goal (from LOOP.md), the current diff, and the oracle output.
110
+ Decide whether the work is GENUINELY and COMPLETELY done — default to "continue" on any doubt.
111
+
112
+ Check:
113
+ - Does it actually satisfy every success criterion in LOOP.md (not just look plausible)?
114
+ - Does the oracle genuinely pass, and does it actually cover the requirement?
115
+ - Edge cases, stubbed/faked functionality, regressions.
116
+
117
+ Return ONLY a JSON object:
118
+ {"verdict":"done"|"continue","reason":"<concise>","guidance":"<what to fix if continue>"}
119
+ Be strict. A false "done" is far worse than one more iteration.
120
+ `);
121
+ }
122
+
123
+ console.log(`✅ loop initialised in ${dir}
124
+ task file : ${loopFile}
125
+ oracle : ${oracle || '(none)'}
126
+ evaluator : ${evaluatorOn ? 'on' + (model ? ` (${model})` : '') : 'off'}
127
+ max iters : ${max}
128
+ hooks : SessionStart, UserPromptSubmit, Stop (.claude/settings.json)`);
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // loop-status.mjs — show a loop's current state + per-iteration history timeline.
3
+ // Usage: node loop-status.mjs [project-dir] [--json] [-n <count>]
4
+ import { readFileSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+
7
+ const args = process.argv.slice(2);
8
+ const json = args.includes('--json');
9
+ const nIdx = args.indexOf('-n');
10
+ const limit = nIdx !== -1 ? Number(args[nIdx + 1]) : 20;
11
+ const dir = resolve(args.find((a) => !a.startsWith('-') && a !== String(limit)) || process.cwd());
12
+ const LOOP_DIR = join(dir, '.claude', 'loop');
13
+
14
+ const readJSON = (p, f) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return f; } };
15
+ const state = readJSON(join(LOOP_DIR, 'loop.config.json'), null)
16
+ ? readJSON(join(LOOP_DIR, 'loop-state.json'), {})
17
+ : null;
18
+
19
+ let history = [];
20
+ try {
21
+ history = readFileSync(join(LOOP_DIR, 'history.jsonl'), 'utf8')
22
+ .split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
23
+ } catch {}
24
+
25
+ if (json) {
26
+ console.log(JSON.stringify({ state, history: history.slice(-limit) }, null, 2));
27
+ } else if (!state) {
28
+ console.log('No loop configured in', dir);
29
+ } else {
30
+ const icon = { done: '✅', gave_up: '🛑', continue: '🔄', running: '🔄' };
31
+ console.log(`Loop in ${dir}`);
32
+ console.log(` phase: ${state.phase || '?'} | iteration: ${state.iteration ?? 0}${state.gave_up_reason ? ` | gave up: ${state.gave_up_reason}` : ''}`);
33
+ if (state.last_oracle) console.log(` oracle: ${String(state.last_oracle).split('\n')[0]}`);
34
+ console.log(` history (last ${Math.min(limit, history.length)} of ${history.length}):`);
35
+ for (const h of history.slice(-limit)) {
36
+ console.log(` ${icon[h.decision] || '·'} iter ${h.iteration} [${h.decision}] ${h.ts}${h.reason ? ' — ' + h.reason : ''}`);
37
+ }
38
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // precompact.mjs — Claude Code `PreCompact` hook. Records a compaction event in
3
+ // the loop's history timeline (so the monitor shows context resets) and reminds
4
+ // the agent that LOOP.md is the durable handoff across compaction.
5
+ import { readFileSync, appendFileSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ const PROJECT = resolve(HERE, '..', '..', '..');
11
+ const LOOP_DIR = join(PROJECT, '.claude', 'loop');
12
+
13
+ let active = false;
14
+ try { active = JSON.parse(readFileSync(join(LOOP_DIR, 'loop-state.json'), 'utf8')).active !== false; } catch {}
15
+ if (!active) process.exit(0);
16
+
17
+ let iteration = 0;
18
+ try { iteration = JSON.parse(readFileSync(join(LOOP_DIR, 'loop-state.json'), 'utf8')).iteration || 0; } catch {}
19
+ try {
20
+ appendFileSync(join(LOOP_DIR, 'history.jsonl'),
21
+ JSON.stringify({ ts: new Date().toISOString(), iteration, decision: 'compaction' }) + '\n');
22
+ } catch {}
23
+
24
+ // Context is about to be compacted — LOOP.md (auto-injected on the next turn) is
25
+ // the durable handoff. Nudge the agent to ensure its progress is captured there.
26
+ process.stdout.write('Context is being compacted. LOOP.md is your durable memory across this reset — ensure your latest progress, plan, and remaining work are written there before continuing.');
27
+ process.exit(0);
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // routine-cli.mjs — manage + run routines.
3
+ // add/list/remove/enable/disable/run-now — CRUD + manual fire
4
+ // serve [--port N] — scheduler tick + webhook HTTP server
5
+ //
6
+ // Delivery uses the svamp CLI: a fired routine becomes a message (or loop
7
+ // kickoff) into the bound session via `svamp session send`.
8
+ import { execFileSync } from 'node:child_process';
9
+ import { createServer } from 'node:http';
10
+ import { dirname, join, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { RoutineStore } from './routine-store.mjs';
13
+ import { RoutineRunner } from './routine-runner.mjs';
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ const LOOP_INIT = join(HERE, 'loop-init.mjs');
17
+
18
+ function parseArgs(argv) {
19
+ const a = { _: [] };
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const t = argv[i];
22
+ if (t.startsWith('--')) a[t.slice(2)] = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true;
23
+ else a._.push(t);
24
+ }
25
+ return a;
26
+ }
27
+
28
+ // ---- real delivery via svamp CLI ---------------------------------------
29
+ async function cliDeliver({ routine, resolved }) {
30
+ const sid = routine.session_id;
31
+ if (resolved.kind === 'message') {
32
+ execFileSync('svamp', ['session', 'send', sid, resolved.text], { stdio: 'pipe' });
33
+ return;
34
+ }
35
+ // loop action: set up the loop in the project dir, then kick the session.
36
+ const dir = routine.action.loop?.dir || routine.dir;
37
+ if (dir) {
38
+ const initArgs = [LOOP_INIT, resolve(dir), '--task', resolved.task || routine.action.loop?.task || '(task)'];
39
+ if (routine.action.loop?.oracle) initArgs.push('--oracle', routine.action.loop.oracle);
40
+ initArgs.push('--evaluator', routine.action.loop?.evaluator || 'off');
41
+ execFileSync(process.execPath, initArgs, { stdio: 'pipe' });
42
+ }
43
+ execFileSync('svamp', ['session', 'send', sid,
44
+ `Begin the loop (LOOP MODE). Task: ${resolved.task}. Iterate until the gate lets you stop.`], { stdio: 'pipe' });
45
+ }
46
+
47
+ function buildRoutineFromArgs(a) {
48
+ const r = { session_id: a.session, name: a.name || 'routine', overlap: a.overlap || 'queue' };
49
+ if (a.schedule) r.trigger = { type: 'schedule', cron: a.schedule, missed: a.missed || 'skip', tz: a.tz };
50
+ else if (a.webhook || a.api) r.trigger = { type: a.api ? 'api' : 'webhook', methods: a.get ? ['GET'] : ['GET', 'POST'], public: !!a.public };
51
+ else r.trigger = { type: 'manual' };
52
+ if (a.loop) r.action = { kind: 'loop', task_template: a.task || '', loop: { dir: a.dir, oracle: a.oracle, evaluator: a.evaluator || 'off' } };
53
+ else r.action = { kind: 'message', template: a.message || a.task || 'run' };
54
+ return r;
55
+ }
56
+
57
+ async function main() {
58
+ const a = parseArgs(process.argv.slice(2));
59
+ const cmd = a._[0];
60
+ const store = new RoutineStore();
61
+ const runner = new RoutineRunner({ store, deliver: cliDeliver, log: console.error });
62
+
63
+ switch (cmd) {
64
+ case 'add': {
65
+ if (!a.session) { console.error('--session <id> required'); process.exit(1); }
66
+ const r = store.save(buildRoutineFromArgs(a));
67
+ console.log(`✅ added routine ${r.id} (${r.name}) trigger=${r.trigger.type} action=${r.action.kind}`);
68
+ if (r.trigger.key) console.log(` webhook key: ${r.trigger.key}`);
69
+ break;
70
+ }
71
+ case 'list': {
72
+ const rs = store.list();
73
+ if (a.json) { console.log(JSON.stringify(rs, null, 2)); break; }
74
+ if (!rs.length) { console.log('(no routines)'); break; }
75
+ for (const r of rs) console.log(`${r.enabled ? '●' : '○'} ${r.id} ${r.name} [${r.trigger.type}${r.trigger.cron ? ' ' + r.trigger.cron : ''}] -> ${r.action.kind} session=${r.session_id}`);
76
+ break;
77
+ }
78
+ case 'remove': ok(store.remove(a._[1]), `removed ${a._[1]}`); break;
79
+ case 'enable': store.setEnabled(a._[1], true); console.log(`enabled ${a._[1]}`); break;
80
+ case 'disable': store.setEnabled(a._[1], false); console.log(`disabled ${a._[1]}`); break;
81
+ case 'run-now': console.log(JSON.stringify(await runner.runNow(a._[1], {}), null, 2)); break;
82
+ case 'serve': await serve(runner, store, Number(a.port) || 8722); break;
83
+ default:
84
+ console.log(`usage: routine-cli <add|list|remove|enable|disable|run-now|serve>
85
+ add --session <id> --name <n> [--schedule "*/5 * * * *" [--missed catchup]] [--webhook|--api [--get] [--public]] (--message "..." | --loop --dir <path> --task "..." [--oracle "cmd"])
86
+ serve [--port 8722]`);
87
+ }
88
+ function ok(c, m) { console.log(c ? '✅ ' + m : '✗ ' + m); }
89
+ }
90
+
91
+ async function serve(runner, store, port) {
92
+ // catch up missed schedules, then tick every 20s (per-minute dedupe inside).
93
+ await runner.catchUp(new Date(Date.now() - 24 * 3600 * 1000), new Date());
94
+ const timer = setInterval(() => { runner.tick(new Date()).catch((e) => console.error('tick error', e)); }, 20000);
95
+
96
+ const server = createServer((req, res) => {
97
+ const u = new URL(req.url, `http://localhost:${port}`);
98
+ if (u.pathname === '/' || u.pathname === '/health') { res.writeHead(200).end('routines ok'); return; }
99
+ const m = u.pathname.match(/^\/routine\/([\w.-]+)$/);
100
+ if (!m) { res.writeHead(404).end('not found'); return; }
101
+ const id = m[1];
102
+ const key = u.searchParams.get('key');
103
+ const query = Object.fromEntries(u.searchParams.entries());
104
+ let chunks = '';
105
+ req.on('data', (c) => { chunks += c; if (chunks.length > 1e6) req.destroy(); });
106
+ req.on('end', async () => {
107
+ let body = {}; try { body = chunks ? JSON.parse(chunks) : {}; } catch {}
108
+ const result = await runner.webhook(id, { key, method: req.method, body, query });
109
+ res.writeHead(result.status || 200, { 'content-type': 'application/json' }).end(JSON.stringify(result));
110
+ });
111
+ });
112
+ server.listen(port, () => {
113
+ console.log(`🪝 routine server on http://localhost:${port}`);
114
+ console.log(` webhook URL pattern: http://localhost:${port}/routine/<id>?key=<key>`);
115
+ console.log(` expose publicly: svamp service expose routines --port ${port}`);
116
+ console.log(` scheduler: ticking every 20s`);
117
+ });
118
+ process.on('SIGINT', () => { clearInterval(timer); server.close(); process.exit(0); });
119
+ }
120
+
121
+ main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // routine-core.mjs — dependency-free primitives for the routine layer:
3
+ // * cron matching + next-fire (standard 5-field cron)
4
+ // * payload templating (${body.x}/${query.x}/${now}) for trigger -> action
5
+ // * routine spec validation
6
+ // No external deps (mirrors the loop gate). Pure + unit-testable.
7
+
8
+ // ---- cron ---------------------------------------------------------------
9
+ const FIELD_RANGES = [
10
+ [0, 59], // minute
11
+ [0, 23], // hour
12
+ [1, 31], // day of month
13
+ [1, 12], // month
14
+ [0, 6], // day of week (0=Sun)
15
+ ];
16
+
17
+ function parseField(token, [min, max]) {
18
+ const set = new Set();
19
+ for (const part of token.split(',')) {
20
+ let m;
21
+ if (part === '*') { for (let i = min; i <= max; i++) set.add(i); }
22
+ else if ((m = part.match(/^\*\/(\d+)$/))) { const s = +m[1]; for (let i = min; i <= max; i += s) set.add(i); }
23
+ else if ((m = part.match(/^(\d+)-(\d+)\/(\d+)$/))) { const [, a, b, s] = m; for (let i = +a; i <= +b; i += +s) set.add(i); }
24
+ else if ((m = part.match(/^(\d+)-(\d+)$/))) { const [, a, b] = m; for (let i = +a; i <= +b; i++) set.add(i); }
25
+ else if ((m = part.match(/^(\d+)$/))) { set.add(+m[1]); }
26
+ else throw new Error(`invalid cron field: "${token}"`);
27
+ }
28
+ for (const v of set) if (v < min || v > max) throw new Error(`cron value ${v} out of range [${min},${max}]`);
29
+ return set;
30
+ }
31
+
32
+ export function parseCron(expr) {
33
+ const fields = String(expr).trim().split(/\s+/);
34
+ if (fields.length !== 5) throw new Error(`cron must have 5 fields, got ${fields.length}: "${expr}"`);
35
+ const [minute, hour, dom, month, dow] = fields.map((f, i) => parseField(f, FIELD_RANGES[i]));
36
+ const domRestricted = fields[2] !== '*';
37
+ const dowRestricted = fields[4] !== '*';
38
+ return { minute, hour, dom, month, dow, domRestricted, dowRestricted };
39
+ }
40
+
41
+ /** Does `date` (minute resolution) match the cron expr? */
42
+ export function cronMatches(expr, date) {
43
+ const c = typeof expr === 'string' ? parseCron(expr) : expr;
44
+ if (!c.minute.has(date.getMinutes())) return false;
45
+ if (!c.hour.has(date.getHours())) return false;
46
+ if (!c.month.has(date.getMonth() + 1)) return false;
47
+ const domOk = c.dom.has(date.getDate());
48
+ const dowOk = c.dow.has(date.getDay());
49
+ // Standard cron: when BOTH dom and dow are restricted, match is OR; else AND.
50
+ if (c.domRestricted && c.dowRestricted) return domOk || dowOk;
51
+ return domOk && dowOk;
52
+ }
53
+
54
+ /** First firing strictly after `from` (defaults to now-ish; caller passes Date). */
55
+ // Return a Date whose LOCAL fields equal the wall-clock time in `tz`, so
56
+ // cronMatches (which reads local getHours/getMinutes/...) effectively matches
57
+ // against that timezone. Falls back to the input on any error.
58
+ export function inZone(date, tz) {
59
+ if (!tz) return date;
60
+ try {
61
+ const p = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false,
62
+ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
63
+ .formatToParts(date).reduce((o, x) => { o[x.type] = x.value; return o; }, {});
64
+ return new Date(+p.year, +p.month - 1, +p.day, +(p.hour === '24' ? 0 : p.hour), +p.minute);
65
+ } catch { return date; }
66
+ }
67
+
68
+ export function nextFire(expr, from, tz) {
69
+ const c = parseCron(expr);
70
+ const d = new Date(from.getTime());
71
+ d.setSeconds(0, 0);
72
+ d.setMinutes(d.getMinutes() + 1); // strictly after
73
+ const limit = 366 * 24 * 60; // scan up to ~1 year of minutes
74
+ for (let i = 0; i < limit; i++) {
75
+ if (cronMatches(c, tz ? inZone(d, tz) : d)) return new Date(d.getTime());
76
+ d.setMinutes(d.getMinutes() + 1);
77
+ }
78
+ return null;
79
+ }
80
+
81
+ // ---- templating ---------------------------------------------------------
82
+ function resolvePath(ctx, path) {
83
+ return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), ctx);
84
+ }
85
+ /** Replace ${a.b.c} from ctx; missing -> empty string. No code eval. */
86
+ export function renderTemplate(template, ctx) {
87
+ return String(template).replace(/\$\{([\w.$]+)\}/g, (_, p) => {
88
+ const v = resolvePath(ctx, p);
89
+ return v == null ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
90
+ });
91
+ }
92
+
93
+ // ---- spec validation ----------------------------------------------------
94
+ const TRIGGER_TYPES = ['manual', 'schedule', 'webhook', 'api'];
95
+ const ACTION_KINDS = ['message', 'loop'];
96
+ const OVERLAP = ['queue', 'skip', 'replace'];
97
+
98
+ export function validateRoutine(r) {
99
+ const errs = [];
100
+ if (!r || typeof r !== 'object') return ['routine must be an object'];
101
+ if (!r.session_id) errs.push('session_id required');
102
+ if (!r.name) errs.push('name required');
103
+ const t = r.trigger;
104
+ if (!t || !TRIGGER_TYPES.includes(t.type)) errs.push(`trigger.type must be one of ${TRIGGER_TYPES.join('|')}`);
105
+ if (t?.type === 'schedule') {
106
+ try { parseCron(t.cron); } catch (e) { errs.push(`trigger.cron: ${e.message}`); }
107
+ if (t.missed && !['catchup', 'skip'].includes(t.missed)) errs.push('trigger.missed must be catchup|skip');
108
+ }
109
+ const a = r.action;
110
+ if (!a || !ACTION_KINDS.includes(a.kind)) errs.push(`action.kind must be one of ${ACTION_KINDS.join('|')}`);
111
+ if (a?.kind === 'message' && !a.template) errs.push('action.template required for message action');
112
+ if (a?.kind === 'loop' && !a.loop && !a.task_template) errs.push('action.loop or action.task_template required for loop action');
113
+ if (r.overlap && !OVERLAP.includes(r.overlap)) errs.push(`overlap must be one of ${OVERLAP.join('|')}`);
114
+ // Security: a public (keyless) webhook + loop action = unauthenticated task
115
+ // injection into the session. Require a key for loop actions.
116
+ if ((t?.type === 'webhook' || t?.type === 'api') && t.public && a?.kind === 'loop')
117
+ errs.push('a public webhook/api may not use a loop action (unauthenticated task injection) — use a message action or require a key');
118
+ return errs;
119
+ }
120
+
121
+ if (import.meta.url === `file://${process.argv[1]}`) {
122
+ const [cmd, ...rest] = process.argv.slice(2);
123
+ if (cmd === 'next') console.log(nextFire(rest.join(' '), new Date())?.toISOString() || 'no fire');
124
+ else if (cmd === 'matches') console.log(cronMatches(rest.slice(0, 5).join(' '), new Date()));
125
+ else console.log('usage: routine-core.mjs next "<cron>" | matches "<cron>"');
126
+ }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // routine-runner.mjs — fires routines (schedule tick / webhook / manual),
3
+ // applies overlap + missed-run policies, and delivers the resolved action
4
+ // (message | loop) via an injectable `deliver` fn. Pure logic; the actual
5
+ // CLI delivery + timer + HTTP server live in routine-cli.mjs.
6
+ import { cronMatches, renderTemplate, nextFire, inZone } from './routine-core.mjs';
7
+
8
+ const minuteKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${d.getHours()}-${d.getMinutes()}`;
9
+
10
+ export class RoutineRunner {
11
+ /** @param {{store, deliver:(ctx)=>Promise<any>, now?:()=>Date, onReplace?:(id)=>void, log?:Function}} o */
12
+ constructor({ store, deliver, now = () => new Date(), onReplace, log = () => {} }) {
13
+ this.store = store; this.deliver = deliver; this.now = now; this.onReplace = onReplace; this.log = log;
14
+ this.active = new Set(); // routine ids with an in-flight loop run
15
+ this._firedMinute = new Map(); // id -> minuteKey (in-memory dedupe within a process)
16
+ }
17
+
18
+ resolveAction(routine, payload = {}) {
19
+ const ctx = { ...payload, now: this.now().toISOString(), routine: { id: routine.id, name: routine.name } };
20
+ const a = routine.action;
21
+ if (a.kind === 'message') return { kind: 'message', text: renderTemplate(a.template, ctx) };
22
+ const task = renderTemplate(a.task_template || a.loop?.task || '', ctx);
23
+ return { kind: 'loop', task, loop: a.loop };
24
+ }
25
+
26
+ markDone(id) { this.active.delete(id); }
27
+
28
+ /** Deliveries counted today via a dedicated counter (NOT derived from the
29
+ * capped last_runs audit, which would undercount past 20 runs/day). */
30
+ _deliveredToday(routine) {
31
+ const today = this.now().toDateString();
32
+ return routine.daily && routine.daily.date === today ? routine.daily.n : 0;
33
+ }
34
+
35
+ /** Core fire path with overlap + daily-cap policy. via = 'schedule'|'webhook'|'api'|'manual'.
36
+ * NOTE: `active` guards genuinely CONCURRENT in-flight deliveries only — the runner
37
+ * cannot observe a spawned loop's full duration (delivery is fire-and-forget), so it
38
+ * is cleared once delivery returns. True loop-duration overlap needs a loop-state
39
+ * signal (future). `replace` calls onReplace (best-effort) then proceeds. */
40
+ async fire(routine, payload = {}, via = 'manual') {
41
+ if (!routine.enabled) return { skipped: 'disabled' };
42
+ // Defense-in-depth (also enforced at validate time): never run a loop action
43
+ // from a public/keyless webhook (unauthenticated task injection).
44
+ if (routine.action?.kind === 'loop' && (routine.trigger?.type === 'webhook' || routine.trigger?.type === 'api') && routine.trigger?.public)
45
+ return { skipped: 'forbidden (public webhook + loop)' };
46
+ const today = this.now().toDateString();
47
+ // Reserve a daily slot SYNCHRONOUSLY (before any await) so concurrent fires
48
+ // serialize on the JS event loop and cannot all read n=0 and over-deliver.
49
+ const r0 = this.store.get(routine.id) || routine;
50
+ const usedToday = (r0.daily && r0.daily.date === today) ? r0.daily.n : 0;
51
+ if (routine.daily_cap && usedToday >= routine.daily_cap) {
52
+ this.store.recordRun(routine.id, { via, delivered: routine.action.kind, outcome: 'skipped (daily cap)' });
53
+ return { skipped: 'daily_cap' };
54
+ }
55
+ if (this.active.has(routine.id)) {
56
+ if (routine.overlap === 'skip') { this.store.recordRun(routine.id, { via, delivered: routine.action.kind, outcome: 'skipped (busy)' }); return { skipped: 'busy' }; }
57
+ if (routine.overlap === 'replace') { try { this.onReplace?.(routine.id); } catch {} }
58
+ // 'queue'/'replace' -> proceed
59
+ }
60
+ this.active.add(routine.id);
61
+ if (routine.daily_cap) { r0.daily = { date: today, n: usedToday + 1 }; this.store.save(r0); } // reserve
62
+ const resolved = this.resolveAction(routine, payload);
63
+ let outcome = 'delivered';
64
+ try {
65
+ await this.deliver({ routine, action: routine.action, resolved, payload, via });
66
+ } catch (e) {
67
+ outcome = 'error: ' + (e?.message || e);
68
+ if (routine.daily_cap) { const rb = this.store.get(routine.id); if (rb?.daily?.date === today && rb.daily.n > 0) { rb.daily.n -= 1; this.store.save(rb); } } // roll back reservation
69
+ } finally { this.active.delete(routine.id); }
70
+ const r = this.store.get(routine.id) || routine;
71
+ r.last_fired_at = this.now().toISOString();
72
+ r.last_runs = [{ firedAt: r.last_fired_at, via, delivered: resolved.kind, outcome }, ...(r.last_runs || [])].slice(0, 20);
73
+ this.store.save(r);
74
+ return { fired: true, via, resolved, outcome };
75
+ }
76
+
77
+ /** Schedule tick — fire any matching schedule routine once per minute. */
78
+ async tick(date = this.now()) {
79
+ const results = [];
80
+ for (const r of this.store.list()) {
81
+ if (!r.enabled || r.trigger?.type !== 'schedule') continue;
82
+ if (!cronMatches(r.trigger.cron, inZone(date, r.trigger.tz))) continue;
83
+ const mk = minuteKey(date);
84
+ if (this._firedMinute.get(r.id) === mk) continue; // already fired this minute
85
+ this._firedMinute.set(r.id, mk);
86
+ results.push({ id: r.id, ...(await this.fire(r, { tick: date.toISOString() }, 'schedule')) });
87
+ }
88
+ return results;
89
+ }
90
+
91
+ /** Webhook/api fire: validate key + method, then fire with payload. */
92
+ async webhook(id, { key, method = 'POST', body = {}, query = {} } = {}) {
93
+ const r = this.store.get(id);
94
+ if (!r) return { status: 404, error: 'routine not found' };
95
+ if (r.trigger?.type !== 'webhook' && r.trigger?.type !== 'api') return { status: 400, error: 'not a webhook routine' };
96
+ if (!r.enabled) return { status: 409, error: 'routine disabled' };
97
+ if (!r.trigger.public && r.trigger.key && key !== r.trigger.key) return { status: 401, error: 'bad key' };
98
+ const methods = r.trigger.methods || ['GET', 'POST'];
99
+ if (!methods.includes(method)) return { status: 405, error: 'method not allowed' };
100
+ const res = await this.fire(r, { body, query }, r.trigger.type);
101
+ return { status: 200, ...res };
102
+ }
103
+
104
+ async runNow(id, payload = {}) {
105
+ const r = this.store.get(id);
106
+ if (!r) return { error: 'not found' };
107
+ return this.fire(r, payload, 'manual');
108
+ }
109
+
110
+ /** On startup, fire schedule routines whose time was missed while down (catchup). */
111
+ async catchUp(sinceDate, nowDate = this.now()) {
112
+ const results = [];
113
+ for (const r of this.store.list()) {
114
+ if (!r.enabled || r.trigger?.type !== 'schedule' || r.trigger.missed !== 'catchup') continue;
115
+ const deadlineMs = (r.trigger.deadline_sec || 3600) * 1000;
116
+ const since = new Date(Math.max(sinceDate.getTime(), nowDate.getTime() - deadlineMs));
117
+ const due = nextFire(r.trigger.cron, since, r.trigger.tz); // first scheduled time after `since` (in the routine's tz)
118
+ if (due && due <= nowDate) {
119
+ const last = r.last_fired_at ? new Date(r.last_fired_at) : new Date(0);
120
+ if (last < due) results.push({ id: r.id, ...(await this.fire(r, { catchup: due.toISOString() }, 'schedule')) });
121
+ }
122
+ }
123
+ return results;
124
+ }
125
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // routine-store.mjs — persistence + CRUD for routine specs.
3
+ // Specs live as JSON files under a routines dir (default ~/.svamp/routines/),
4
+ // one file per routine, atomic writes, restored on restart.
5
+ import { mkdirSync, writeFileSync, renameSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { validateRoutine } from './routine-core.mjs';
10
+
11
+ export function defaultRoutinesDir() {
12
+ return process.env.SVAMP_ROUTINES_DIR || join(homedir(), '.svamp', 'routines');
13
+ }
14
+ const genId = () => 'rt_' + randomBytes(5).toString('hex');
15
+ const genKey = () => randomBytes(18).toString('base64url');
16
+
17
+ export class RoutineStore {
18
+ constructor(dir = defaultRoutinesDir()) { this.dir = dir; mkdirSync(dir, { recursive: true }); }
19
+ _path(id) { return join(this.dir, `${id}.json`); }
20
+
21
+ list() {
22
+ return readdirSync(this.dir).filter((f) => f.endsWith('.json')).map((f) => {
23
+ try { return JSON.parse(readFileSync(join(this.dir, f), 'utf8')); } catch { return null; }
24
+ }).filter(Boolean);
25
+ }
26
+ get(id) { try { return JSON.parse(readFileSync(this._path(id), 'utf8')); } catch { return null; } }
27
+
28
+ /** Create/update. Assigns id, key (for webhook/api), defaults; validates. */
29
+ save(routine) {
30
+ const r = { overlap: 'queue', enabled: true, last_runs: [], ...routine };
31
+ if (!r.id) r.id = genId();
32
+ if ((r.trigger?.type === 'webhook' || r.trigger?.type === 'api') && !r.trigger.key) r.trigger.key = genKey();
33
+ const errs = validateRoutine(r);
34
+ if (errs.length) throw new Error('invalid routine: ' + errs.join('; '));
35
+ const tmp = this._path(r.id) + '.tmp';
36
+ writeFileSync(tmp, JSON.stringify(r, null, 2));
37
+ renameSync(tmp, this._path(r.id));
38
+ return r;
39
+ }
40
+ remove(id) { const p = this._path(id); if (existsSync(p)) { rmSync(p); return true; } return false; }
41
+ setEnabled(id, enabled) { const r = this.get(id); if (!r) return null; r.enabled = enabled; return this.save(r); }
42
+
43
+ /** Append a capped run-history entry. */
44
+ recordRun(id, entry) {
45
+ const r = this.get(id); if (!r) return null;
46
+ r.last_runs = [{ firedAt: new Date().toISOString(), ...entry }, ...(r.last_runs || [])].slice(0, 20);
47
+ return this.save(r);
48
+ }
49
+ }