@danieltmn/tamnora 0.13.1 → 0.15.0

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 (32) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/harness/packages/core/src/config/index.js +2 -0
  3. package/harness/packages/core/src/hooks/index.js +42 -0
  4. package/harness/packages/core/src/index.js +3 -0
  5. package/harness/packages/core/src/plugins/index.js +36 -0
  6. package/harness/packages/core/src/rules/index.js +9 -63
  7. package/harness/packages/core/src/schedule/cron.js +124 -0
  8. package/harness/packages/core/src/storage/sqlite.js +96 -1
  9. package/harness/packages/core/src/tools/glob.js +64 -2
  10. package/harness/packages/core/test/cron.test.js +46 -0
  11. package/harness/packages/core/test/hooks.test.js +29 -0
  12. package/harness/packages/core/test/plugins.test.js +25 -0
  13. package/harness/packages/server/public/app.js +193 -3
  14. package/harness/packages/server/public/index.html +76 -2
  15. package/harness/packages/server/public/landing/index.html +5 -3
  16. package/harness/packages/server/public/styles.css +17 -10
  17. package/harness/packages/server/src/lib/advisor.js +4 -0
  18. package/harness/packages/server/src/lib/agentRun.js +24 -1
  19. package/harness/packages/server/src/lib/channels/telegram.js +7 -1
  20. package/harness/packages/server/src/lib/checkpoints.js +106 -0
  21. package/harness/packages/server/src/lib/compact.js +4 -0
  22. package/harness/packages/server/src/lib/hooks.js +45 -0
  23. package/harness/packages/server/src/lib/plugins.js +76 -0
  24. package/harness/packages/server/src/lib/scheduler.js +190 -0
  25. package/harness/packages/server/src/routes/advisor.js +4 -2
  26. package/harness/packages/server/src/routes/chat.js +32 -7
  27. package/harness/packages/server/src/routes/schedules.js +124 -0
  28. package/harness/packages/server/src/routes/sessions.js +18 -2
  29. package/harness/packages/server/src/server.js +24 -0
  30. package/harness/packages/server/test/server.test.js +201 -0
  31. package/harness/tamnora.example.json +2 -0
  32. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.15.0] - 2026-10-10
9
+
10
+ ### Agregado
11
+
12
+ - **Fase 4 del roadmap del server (plataforma)**:
13
+ - **Hooks locales**: módulos `.mjs` en `.tamnora/hooks/*.mjs` (o `hooks[]` en
14
+ `tamnora.json`) que observan el ciclo de vida (`run.start/end`,
15
+ `tool.before/after`, `session.compacting/compacted`, `advice`). Aislados de
16
+ errores, con datos redactados, opt-in.
17
+ - **Plugins**: módulos `.mjs` en `.tamnora/plugins/*.mjs` (o `plugins[]`) que
18
+ agregan **tools propias** al agente (`plugin__<plugin>__<tool>`), sujetas a
19
+ permisos (default `ask`).
20
+ - **Asesor con proveedor/modelo propios**: `app.json -> advisor`
21
+ `provider`/`model`; si están vacíos usa los activos.
22
+
23
+ ## [0.14.0] - 2026-10-10
24
+
25
+ ### Agregado
26
+
27
+ - **Fase 3 del roadmap del server (automatización y seguridad)**:
28
+ - **Tareas programadas**: `every N m|h`, `hourly`, `daily HH:MM` y un subconjunto
29
+ de cron. El scheduler corre un tick cada 30 s y dispara tareas `agent` (turno
30
+ headless que reusa el historial y avisa por los canales) o `shell` (con la
31
+ allowlist de procesos). CRUD en `/api/schedules` y pestaña **Tareas**.
32
+ - **Deshacer (checkpoints/rewind)**: se guarda la versión previa de los archivos
33
+ que `write`/`edit` tocan y `POST /api/sessions/:id/rewind` restaura los
34
+ archivos y borra el turno del historial. Acción **deshacer cambios** en el menú
35
+ de cada mensaje del asistente.
36
+
37
+ ### Cambios
38
+
39
+ - Consola: el botón de tema muestra el icono del tema al que se va a cambiar
40
+ (en claro, luna; en oscuro, sol). Igual en la landing.
41
+ - Consola: el fondo del chat pasa a un color sólido; la imagen queda solo en la
42
+ landing.
43
+ - Consola: los botones de los paneles (p. ej. cerrar/refrescar) ya no se recortan
44
+ al angostar: las pestañas se comprimen y hacen scroll.
45
+
8
46
  ## [0.13.1] - 2026-10-10
9
47
 
10
48
  ### Arreglado
@@ -16,6 +16,8 @@ export const DEFAULT_CONFIG = {
16
16
  compact: { enabled: true, threshold: 0.75, keepLast: 8 },
17
17
  rules: ['.tamnora/rules/*.md'],
18
18
  memory: { enabled: true },
19
+ hooks: [],
20
+ plugins: [],
19
21
  tools: [],
20
22
  permissions: {},
21
23
  mcp: [],
@@ -0,0 +1,42 @@
1
+ // Hooks locales: modulos `.mjs` que observan el ciclo de vida del agente
2
+ // (cargados con `import()` dinamico, opt-in por presencia y aislados de errores).
3
+ // Un hook exporta por default un objeto `{ name?, events: { '<tipo>': fn } }` o
4
+ // una funcion (se llama para todos los eventos).
5
+
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { globFiles } from '../tools/glob.js';
9
+
10
+ export const DEFAULT_HOOK_PATTERNS = ['.tamnora/hooks/*.mjs'];
11
+
12
+ /** Normaliza el modulo de un hook a `{ id, events }` (o null si no es valido). */
13
+ export function normalizeHookModule(mod, id = '') {
14
+ const value = mod?.default ?? mod;
15
+ if (typeof value === 'function') return { id, events: { '*': value } };
16
+ if (value && typeof value === 'object' && value.events && typeof value.events === 'object') {
17
+ return { id: value.name || id, events: value.events };
18
+ }
19
+ if (value && typeof value === 'object' && typeof value.onEvent === 'function') {
20
+ return { id: value.name || id, events: { '*': value.onEvent } };
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * Carga los hooks de `patterns` + `.tamnora/hooks/*.mjs` (relativos a `cwd`).
27
+ * Un modulo roto se ignora.
28
+ */
29
+ export async function loadHooks(cwd, patterns = []) {
30
+ const list = [...new Set([...(patterns ?? []), ...DEFAULT_HOOK_PATTERNS])];
31
+ const hooks = [];
32
+ for (const rel of await globFiles(cwd, list)) {
33
+ try {
34
+ const mod = await import(pathToFileURL(path.join(cwd, rel)).href);
35
+ const hook = normalizeHookModule(mod, rel);
36
+ if (hook) hooks.push({ ...hook, path: rel });
37
+ } catch {
38
+ /* modulo roto: se ignora */
39
+ }
40
+ }
41
+ return hooks;
42
+ }
@@ -123,6 +123,9 @@ export {
123
123
  buildStaticRulesBlock,
124
124
  buildPathRulesBlock,
125
125
  } from './rules/index.js';
126
+ export { parseScheduleSpec, parseCronFields, nextRun, nextRunFor } from './schedule/cron.js';
127
+ export { loadHooks, normalizeHookModule, DEFAULT_HOOK_PATTERNS } from './hooks/index.js';
128
+ export { loadPlugins, normalizePlugin, DEFAULT_PLUGIN_PATTERNS } from './plugins/index.js';
126
129
  export {
127
130
  searchMcpRegistry,
128
131
  getMcpRegistryServer,
@@ -0,0 +1,36 @@
1
+ // Plugins locales: modulos `.mjs` que exportan `{ name?, tools: [...] }` para
2
+ // agregar herramientas propias al agente. Se cargan con `import()` dinamico,
3
+ // opt-in por presencia. Cada tool: `{ name, description, parameters?, required?,
4
+ // handler(args, ctx) }`.
5
+
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { globFiles } from '../tools/glob.js';
9
+
10
+ export const DEFAULT_PLUGIN_PATTERNS = ['.tamnora/plugins/*.mjs'];
11
+
12
+ /** Normaliza el modulo de un plugin a `{ id, tools }` (o null). */
13
+ export function normalizePlugin(mod, id = '') {
14
+ const value = mod?.default ?? mod;
15
+ if (!value || typeof value !== 'object') return null;
16
+ const tools = Array.isArray(value.tools) ? value.tools : [];
17
+ const valid = tools.filter((tool) => tool && typeof tool.name === 'string' && typeof tool.handler === 'function');
18
+ if (!valid.length) return null;
19
+ return { id: value.name || id, tools: valid };
20
+ }
21
+
22
+ /** Carga los plugins de `patterns` + `.tamnora/plugins/*.mjs`. */
23
+ export async function loadPlugins(cwd, patterns = []) {
24
+ const list = [...new Set([...(patterns ?? []), ...DEFAULT_PLUGIN_PATTERNS])];
25
+ const plugins = [];
26
+ for (const rel of await globFiles(cwd, list)) {
27
+ try {
28
+ const mod = await import(pathToFileURL(path.join(cwd, rel)).href);
29
+ const plugin = normalizePlugin(mod, rel);
30
+ if (plugin) plugins.push({ ...plugin, path: rel });
31
+ } catch {
32
+ /* plugin roto: se ignora */
33
+ }
34
+ }
35
+ return plugins;
36
+ }
@@ -2,9 +2,9 @@
2
2
  // Las que no tienen `paths` son estáticas (van siempre al system prompt); las
3
3
  // que sí, se inyectan en runtime cuando una tool toca un archivo que matchea.
4
4
 
5
- import { readFile, readdir, stat } from 'node:fs/promises';
5
+ import { readFile } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
- import { globToRegExp } from '../tools/glob.js';
7
+ import { globToRegExp, globFiles } from '../tools/glob.js';
8
8
 
9
9
  /** Parsea un archivo de reglas: frontmatter (name, paths/globs) + body. */
10
10
  export function parseRuleMarkdown(text, fallbackId = '') {
@@ -41,74 +41,20 @@ function normalizePaths(paths) {
41
41
  return [...new Set((paths ?? []).map((p) => String(p).replace(/\\/g, '/').replace(/^\.?\//, '').trim()).filter(Boolean))];
42
42
  }
43
43
 
44
- function hasGlob(pattern) {
45
- return /[*?]/.test(pattern);
46
- }
47
-
48
- function fixedPrefix(pattern) {
49
- if (!hasGlob(pattern)) return pattern;
50
- const at = pattern.search(/[*?]/);
51
- const slash = pattern.slice(0, at).lastIndexOf('/');
52
- return slash >= 0 ? pattern.slice(0, slash) : '';
53
- }
54
-
55
44
  /** Carga y parsea los archivos de reglas que matchean `patterns` (rel a `root`). */
56
45
  export async function loadRules(root, patterns = []) {
57
- const seen = new Set();
58
46
  const rules = [];
59
- for (const pattern of patterns) {
60
- const normalized = String(pattern).replace(/\\/g, '/');
61
- const candidates = await matchFiles(root, normalized);
62
- for (const rel of candidates) {
63
- if (seen.has(rel)) continue;
64
- seen.add(rel);
65
- try {
66
- const text = await readFile(path.join(root, rel), 'utf8');
67
- const parsed = parseRuleMarkdown(text, rel);
68
- if (!parsed.body) continue;
69
- rules.push({ id: rel, name: parsed.name, paths: parsed.paths, body: parsed.body });
70
- } catch {
71
- /* ignore unreadable rule */
72
- }
73
- }
74
- }
75
- return rules.sort((a, b) => a.id.localeCompare(b.id, 'es'));
76
- }
77
-
78
- async function matchFiles(root, pattern) {
79
- if (!hasGlob(pattern)) {
47
+ for (const rel of await globFiles(root, patterns)) {
80
48
  try {
81
- return (await stat(path.join(root, pattern))).isFile() ? [pattern] : [];
49
+ const text = await readFile(path.join(root, rel), 'utf8');
50
+ const parsed = parseRuleMarkdown(text, rel);
51
+ if (!parsed.body) continue;
52
+ rules.push({ id: rel, name: parsed.name, paths: parsed.paths, body: parsed.body });
82
53
  } catch {
83
- return [];
54
+ /* ignore unreadable rule */
84
55
  }
85
56
  }
86
- const prefix = fixedPrefix(pattern);
87
- const base = prefix ? path.join(root, prefix) : root;
88
- const files = [];
89
- const walk = async (dir, depth) => {
90
- let entries;
91
- try {
92
- entries = await readdir(dir, { withFileTypes: true });
93
- } catch {
94
- return;
95
- }
96
- for (const entry of entries) {
97
- if (entry.name === '.git' || entry.name === 'node_modules') continue;
98
- const full = path.join(dir, entry.name);
99
- if (entry.isDirectory()) {
100
- if (depth < 6 && entry.name !== '.git' && entry.name !== 'node_modules') {
101
- await walk(full, depth + 1);
102
- }
103
- } else if (entry.isFile()) {
104
- const rel = path.relative(root, full).replace(/\\/g, '/');
105
- files.push(rel);
106
- }
107
- }
108
- };
109
- await walk(base, 0);
110
- const re = globToRegExp(pattern);
111
- return files.filter((rel) => re.test(rel));
57
+ return rules.sort((a, b) => a.id.localeCompare(b.id, 'es'));
112
58
  }
113
59
 
114
60
  /** Reglas sin `paths` (aplican siempre). */
@@ -0,0 +1,124 @@
1
+ // Especificaciones de programación (puro, sin dependencias): `every N m|h`,
2
+ // `hourly`, `daily HH:MM`, `HH:MM` y un subconjunto de cron (5 campos:
3
+ // minuto hora dia-mes mes dia-semana) con `*`, listas, rangos y pasos.
4
+
5
+ const MINUTE = 60_000;
6
+
7
+ function parseField(text, min, max) {
8
+ const out = new Set();
9
+ for (const part of String(text).split(',')) {
10
+ const token = part.trim();
11
+ if (!token) return null;
12
+ const [rangePart, stepPart] = token.split('/');
13
+ const step = stepPart === undefined ? 1 : Number(stepPart);
14
+ if (!Number.isInteger(step) || step <= 0) return null;
15
+ let start;
16
+ let end;
17
+ if (rangePart === '*') {
18
+ start = min;
19
+ end = max;
20
+ } else {
21
+ const range = rangePart.split('-');
22
+ start = Number(range[0]);
23
+ end = range.length > 1 ? Number(range[1]) : start;
24
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
25
+ }
26
+ if (start < min || end > max || start > end) return null;
27
+ for (let v = start; v <= end; v += step) out.add(v);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** Parsea los 5 campos de cron. Devuelve null si es inválido. */
33
+ export function parseCronFields(parts) {
34
+ const [minute, hour, dom, month, dow] = parts;
35
+ const fields = {
36
+ minute: parseField(minute, 0, 59),
37
+ hour: parseField(hour, 0, 23),
38
+ dom: parseField(dom, 1, 31),
39
+ month: parseField(month, 1, 12),
40
+ dow: parseField(dow, 0, 6),
41
+ };
42
+ if (Object.values(fields).some((set) => !set)) return null;
43
+ fields.domStar = dom.trim() === '*';
44
+ fields.dowStar = dow.trim() === '*';
45
+ return fields;
46
+ }
47
+
48
+ /**
49
+ * Normaliza una especificación a `{ type, ... }` o null:
50
+ * - `{ type: 'every', minutes }`
51
+ * - `{ type: 'daily', hour, minute }`
52
+ * - `{ type: 'cron', fields }`
53
+ */
54
+ export function parseScheduleSpec(spec) {
55
+ const text = String(spec ?? '').trim().toLowerCase();
56
+ if (!text) return null;
57
+
58
+ const every = text.match(/^every\s+(\d+)\s*(m|min|mins|minute|minutes|h|hr|hour|hours)$/);
59
+ if (every) {
60
+ const n = Number(every[1]);
61
+ if (n <= 0) return null;
62
+ return { type: 'every', minutes: every[2].startsWith('h') ? n * 60 : n, spec: text };
63
+ }
64
+ if (/^(every\s+hour|hourly)$/.test(text)) return { type: 'every', minutes: 60, spec: text };
65
+
66
+ const time = text.match(/^(?:daily\s+)?(\d{1,2}):(\d{2})$/);
67
+ if (time) {
68
+ const hour = Number(time[1]);
69
+ const minute = Number(time[2]);
70
+ if (hour > 23 || minute > 59) return null;
71
+ return { type: 'daily', hour, minute, spec: text };
72
+ }
73
+
74
+ const parts = text.split(/\s+/);
75
+ if (parts.length === 5) {
76
+ const fields = parseCronFields(parts);
77
+ if (!fields) return null;
78
+ return { type: 'cron', fields, spec: text };
79
+ }
80
+ return null;
81
+ }
82
+
83
+ function cronMatches(fields, date) {
84
+ if (!fields.minute.has(date.getMinutes())) return false;
85
+ if (!fields.hour.has(date.getHours())) return false;
86
+ if (!fields.month.has(date.getMonth() + 1)) return false;
87
+ const domMatch = fields.dom.has(date.getDate());
88
+ const dowMatch = fields.dow.has(date.getDay());
89
+ if (fields.domStar && fields.dowStar) return true;
90
+ if (fields.domStar) return dowMatch;
91
+ if (fields.dowStar) return domMatch;
92
+ return domMatch || dowMatch;
93
+ }
94
+
95
+ /** Próximo instante (ms) en que corre, estrictamente después de `from`. */
96
+ export function nextRun(parsed, from = Date.now()) {
97
+ if (!parsed) return null;
98
+ if (parsed.type === 'every') {
99
+ const ms = parsed.minutes * MINUTE;
100
+ return Math.floor(from / ms) * ms + ms;
101
+ }
102
+ if (parsed.type === 'daily') {
103
+ const next = new Date(from);
104
+ next.setSeconds(0, 0);
105
+ next.setHours(parsed.hour, parsed.minute, 0, 0);
106
+ if (next.getTime() <= from) next.setDate(next.getDate() + 1);
107
+ return next.getTime();
108
+ }
109
+ const limit = from + 366 * 24 * 60 * MINUTE;
110
+ const cursor = new Date(from);
111
+ cursor.setSeconds(0, 0);
112
+ cursor.setMinutes(cursor.getMinutes() + 1);
113
+ while (cursor.getTime() <= limit) {
114
+ if (cronMatches(parsed.fields, cursor)) return cursor.getTime();
115
+ cursor.setMinutes(cursor.getMinutes() + 1);
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** `parse + nextRun` en un paso. */
121
+ export function nextRunFor(spec, from = Date.now()) {
122
+ const parsed = parseScheduleSpec(spec);
123
+ return parsed ? nextRun(parsed, from) : null;
124
+ }
@@ -115,6 +115,21 @@ CREATE TABLE IF NOT EXISTS settings (
115
115
  key TEXT PRIMARY KEY,
116
116
  value TEXT
117
117
  );
118
+
119
+ CREATE TABLE IF NOT EXISTS schedules (
120
+ id TEXT PRIMARY KEY,
121
+ name TEXT,
122
+ type TEXT,
123
+ spec TEXT,
124
+ payload TEXT,
125
+ cwd TEXT,
126
+ enabled INTEGER,
127
+ last_run INTEGER,
128
+ last_status TEXT,
129
+ next_run INTEGER,
130
+ created_at INTEGER,
131
+ updated_at INTEGER
132
+ );
118
133
  `;
119
134
 
120
135
  /** Open (creating if needed) a SQLite database with the harness schema. */
@@ -533,8 +548,71 @@ export class Store {
533
548
  this.db.prepare(`DELETE FROM providers WHERE id = ?`).run(id);
534
549
  }
535
550
 
536
- // --- Settings (key/value) ----------------------------------------------
551
+ // --- Schedules (tareas programadas) ------------------------------------
552
+
553
+ listSchedules() {
554
+ return this.db
555
+ .prepare(`SELECT * FROM schedules ORDER BY created_at ASC`)
556
+ .all()
557
+ .map(toSchedule);
558
+ }
559
+
560
+ getSchedule(id) {
561
+ const row = this.db.prepare(`SELECT * FROM schedules WHERE id = ?`).get(id);
562
+ return row ? toSchedule(row) : null;
563
+ }
537
564
 
565
+ saveSchedule(schedule) {
566
+ const ts = now();
567
+ const existing = this.getSchedule(schedule.id);
568
+ this.db
569
+ .prepare(
570
+ `INSERT INTO schedules (id, name, type, spec, payload, cwd, enabled, last_run, last_status, next_run, created_at, updated_at)
571
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
572
+ ON CONFLICT(id) DO UPDATE SET
573
+ name = excluded.name, type = excluded.type, spec = excluded.spec, payload = excluded.payload,
574
+ cwd = excluded.cwd, enabled = excluded.enabled, next_run = excluded.next_run, updated_at = excluded.updated_at`
575
+ )
576
+ .run(
577
+ schedule.id,
578
+ schedule.name ?? schedule.id,
579
+ schedule.type ?? 'agent',
580
+ schedule.spec ?? '',
581
+ json(schedule.payload ?? {}),
582
+ schedule.cwd ?? null,
583
+ schedule.enabled === false ? 0 : 1,
584
+ schedule.lastRun ?? existing?.lastRun ?? null,
585
+ schedule.lastStatus ?? existing?.lastStatus ?? null,
586
+ schedule.nextRun ?? null,
587
+ existing?.createdAt ?? ts,
588
+ ts
589
+ );
590
+ return this.getSchedule(schedule.id);
591
+ }
592
+
593
+ deleteSchedule(id) {
594
+ this.db.prepare(`DELETE FROM schedules WHERE id = ?`).run(id);
595
+ }
596
+
597
+ setScheduleEnabled(id, enabled) {
598
+ this.db.prepare(`UPDATE schedules SET enabled = ?, updated_at = ? WHERE id = ?`).run(enabled ? 1 : 0, now(), id);
599
+ }
600
+
601
+ markScheduleRun(id, { status, nextRun } = {}) {
602
+ this.db
603
+ .prepare(`UPDATE schedules SET last_run = ?, last_status = ?, next_run = ?, updated_at = ? WHERE id = ?`)
604
+ .run(now(), status ?? null, nextRun ?? null, now(), id);
605
+ }
606
+
607
+ /** Tareas habilitadas cuyo `next_run` ya venció. */
608
+ dueSchedules(at = Date.now()) {
609
+ return this.db
610
+ .prepare(`SELECT * FROM schedules WHERE enabled = 1 AND next_run IS NOT NULL AND next_run <= ? ORDER BY next_run ASC`)
611
+ .all(at)
612
+ .map(toSchedule);
613
+ }
614
+
615
+ // --- Settings (key/value) ----------------------------------------------
538
616
  getSetting(key, fallback = null) {
539
617
  const row = this.db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key);
540
618
  return row ? parseJson(row.value) : fallback;
@@ -574,6 +652,23 @@ function toAgent(row) {
574
652
  };
575
653
  }
576
654
 
655
+ function toSchedule(row) {
656
+ return {
657
+ id: row.id,
658
+ name: row.name ?? row.id,
659
+ type: row.type ?? 'agent',
660
+ spec: row.spec ?? '',
661
+ payload: parseJson(row.payload) ?? {},
662
+ cwd: row.cwd ?? null,
663
+ enabled: row.enabled === 0 ? false : true,
664
+ lastRun: row.last_run ?? null,
665
+ lastStatus: row.last_status ?? null,
666
+ nextRun: row.next_run ?? null,
667
+ createdAt: row.created_at,
668
+ updatedAt: row.updated_at,
669
+ };
670
+ }
671
+
577
672
  function toProvider(row) {
578
673
  return {
579
674
  id: row.id,
@@ -1,6 +1,5 @@
1
- import { readdir } from 'node:fs/promises';
1
+ import { readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
-
4
3
  export const DEFAULT_IGNORES = new Set([
5
4
  'node_modules',
6
5
  '.git',
@@ -65,3 +64,66 @@ export async function* walkFiles(root, { ignores = DEFAULT_IGNORES } = {}) {
65
64
  }
66
65
  }
67
66
  }
67
+
68
+ function hasGlob(pattern) {
69
+ return /[*?]/.test(pattern);
70
+ }
71
+
72
+ function fixedPrefix(pattern) {
73
+ if (!hasGlob(pattern)) return pattern;
74
+ const at = pattern.search(/[*?]/);
75
+ const slash = pattern.slice(0, at).lastIndexOf('/');
76
+ return slash >= 0 ? pattern.slice(0, slash) : '';
77
+ }
78
+
79
+ async function matchPattern(root, pattern) {
80
+ if (!hasGlob(pattern)) {
81
+ try {
82
+ return (await stat(path.join(root, pattern))).isFile() ? [pattern] : [];
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+ const prefix = fixedPrefix(pattern);
88
+ const base = prefix ? path.join(root, prefix) : root;
89
+ const files = [];
90
+ const walk = async (dir, depth) => {
91
+ let entries;
92
+ try {
93
+ entries = await readdir(dir, { withFileTypes: true });
94
+ } catch {
95
+ return;
96
+ }
97
+ for (const entry of entries) {
98
+ if (entry.name === '.git' || entry.name === 'node_modules') continue;
99
+ const full = path.join(dir, entry.name);
100
+ if (entry.isDirectory()) {
101
+ if (depth < 6) await walk(full, depth + 1);
102
+ } else if (entry.isFile()) {
103
+ files.push(path.relative(root, full).replace(/\\/g, '/'));
104
+ }
105
+ }
106
+ };
107
+ await walk(base, 0);
108
+ const re = globToRegExp(pattern);
109
+ return files.filter((rel) => re.test(rel));
110
+ }
111
+
112
+ /**
113
+ * Resuelve una lista de patrones glob a archivos (relativos a `root`). A
114
+ * diferencia de `walkFiles`, entra en `.tamnora` (los patrones suelen apuntar
115
+ * ahi): solo saltea `.git` y `node_modules`.
116
+ */
117
+ export async function globFiles(root, patterns = []) {
118
+ const seen = new Set();
119
+ const out = [];
120
+ for (const pattern of patterns) {
121
+ const rel = String(pattern).replace(/\\/g, '/');
122
+ for (const file of await matchPattern(root, rel)) {
123
+ if (seen.has(file)) continue;
124
+ seen.add(file);
125
+ out.push(file);
126
+ }
127
+ }
128
+ return out;
129
+ }
@@ -0,0 +1,46 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseScheduleSpec, nextRun, nextRunFor } from '../src/schedule/cron.js';
4
+
5
+ test('parseScheduleSpec accepts every / hourly / daily / time', () => {
6
+ assert.deepEqual(parseScheduleSpec('every 15 m'), { type: 'every', minutes: 15, spec: 'every 15 m' });
7
+ assert.equal(parseScheduleSpec('every 2 h').minutes, 120);
8
+ assert.equal(parseScheduleSpec('hourly').minutes, 60);
9
+ assert.deepEqual(parseScheduleSpec('daily 09:30'), { type: 'daily', hour: 9, minute: 30, spec: 'daily 09:30' });
10
+ assert.equal(parseScheduleSpec('08:05').type, 'daily');
11
+ assert.equal(parseScheduleSpec('bogus'), null);
12
+ assert.equal(parseScheduleSpec('every 0 m'), null);
13
+ });
14
+
15
+ test('nextRun for every aligns to the interval', () => {
16
+ const from = new Date('2026-01-01T10:07:00').getTime();
17
+ const next = new Date(nextRun(parseScheduleSpec('every 15 m'), from));
18
+ assert.equal(next.getHours(), 10);
19
+ assert.equal(next.getMinutes(), 15);
20
+ });
21
+
22
+ test('nextRun for daily picks today or tomorrow', () => {
23
+ const spec = parseScheduleSpec('daily 09:00');
24
+ const before = new Date('2026-01-01T08:00:00').getTime();
25
+ assert.equal(new Date(nextRun(spec, before)).getDate(), 1);
26
+ const after = new Date('2026-01-01T10:00:00').getTime();
27
+ assert.equal(new Date(nextRun(spec, after)).getDate(), 2);
28
+ });
29
+
30
+ test('nextRun for a cron subset', () => {
31
+ const from = new Date('2026-01-01T10:00:00').getTime();
32
+ const next = new Date(nextRun(parseScheduleSpec('30 14 * * *'), from));
33
+ assert.equal(next.getHours(), 14);
34
+ assert.equal(next.getMinutes(), 30);
35
+ });
36
+
37
+ test('nextRun for weekday cron skips to the right day', () => {
38
+ const from = new Date('2026-01-01T00:00:00').getTime(); // Thursday
39
+ const next = new Date(nextRun(parseScheduleSpec('0 6 * * 1'), from)); // Monday 06:00
40
+ assert.equal(next.getDay(), 1);
41
+ assert.equal(next.getHours(), 6);
42
+ });
43
+
44
+ test('nextRunFor returns null for invalid spec', () => {
45
+ assert.equal(nextRunFor('nope'), null);
46
+ });
@@ -0,0 +1,29 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { normalizeHookModule, loadHooks } from '../src/hooks/index.js';
7
+
8
+ test('normalizeHookModule acepta objeto events, funcion y onEvent', () => {
9
+ const byEvents = normalizeHookModule({ default: { name: 'a', events: { 'run.end': () => {} } } }, 'x');
10
+ assert.equal(byEvents.id, 'a');
11
+ assert.deepEqual(Object.keys(byEvents.events), ['run.end']);
12
+ assert.ok(normalizeHookModule({ default: () => {} }, 'f').events['*']);
13
+ assert.ok(normalizeHookModule({ default: { onEvent: () => {} } }, 'o').events['*']);
14
+ assert.equal(normalizeHookModule({ default: {} }, 'n'), null);
15
+ });
16
+
17
+ test('loadHooks carga .tamnora/hooks/*.mjs e ignora lo roto', async () => {
18
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-'));
19
+ await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
20
+ await writeFile(
21
+ path.join(cwd, '.tamnora', 'hooks', 'demo.mjs'),
22
+ "export default { name: 'demo', events: { 'run.end': () => {} } };\n"
23
+ );
24
+ await writeFile(path.join(cwd, '.tamnora', 'hooks', 'roto.mjs'), 'this is not valid javascript {{{\n');
25
+ const hooks = await loadHooks(cwd, []);
26
+ assert.equal(hooks.length, 1);
27
+ assert.equal(hooks[0].id, 'demo');
28
+ assert.deepEqual(Object.keys(hooks[0].events), ['run.end']);
29
+ });
@@ -0,0 +1,25 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { normalizePlugin, loadPlugins } from '../src/plugins/index.js';
7
+
8
+ test('normalizePlugin filtra tools invalidas', () => {
9
+ const plugin = normalizePlugin({ default: { name: 'demo', tools: [{ name: 'x', handler: () => {} }, { name: 'bad' }] } }, 'z');
10
+ assert.equal(plugin.id, 'demo');
11
+ assert.equal(plugin.tools.length, 1);
12
+ assert.equal(normalizePlugin({ default: {} }, 'n'), null);
13
+ });
14
+
15
+ test('loadPlugins carga .tamnora/plugins/*.mjs', async () => {
16
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-plugins-'));
17
+ await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
18
+ await writeFile(
19
+ path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
20
+ "export default { name: 'demo', tools: [{ name: 'saludar', handler: () => 'hi' }] };\n"
21
+ );
22
+ const plugins = await loadPlugins(cwd, []);
23
+ assert.equal(plugins.length, 1);
24
+ assert.equal(plugins[0].tools[0].name, 'saludar');
25
+ });