@saulwade/swl-ses 1.3.7 → 1.4.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.
- package/CLAUDE.md +12 -4
- package/README.md +1 -1
- package/bin/swl-mcp-server.js +187 -187
- package/bin/swl-webhook-server.js +198 -0
- package/comandos/swl/.evolved.json +22 -22
- package/comandos/swl/adoptar-proyecto.md +21 -1
- package/comandos/swl/claudemd.md +14 -1
- package/comandos/swl/contribuir.md +233 -233
- package/comandos/swl/exportar-vault.md +207 -7
- package/comandos/swl/nuevo-proyecto.md +24 -2
- package/gateway/adapters/base.js +109 -0
- package/gateway/adapters/discord.js +167 -0
- package/gateway/adapters/email.js +221 -0
- package/gateway/adapters/slack.js +192 -0
- package/gateway/adapters/telegram.js +183 -0
- package/gateway/adapters/webhook.js +113 -0
- package/gateway/adapters/whatsapp.js +214 -0
- package/gateway/agent-executor.js +322 -0
- package/gateway/command-relay.js +271 -0
- package/gateway/cron/jobs.js +263 -0
- package/gateway/cron/scheduler.js +322 -0
- package/gateway/cron/store.js +335 -0
- package/gateway/index.js +320 -0
- package/gateway/lib/event-channel.js +191 -0
- package/gateway/session.js +131 -0
- package/gateway/webhook-server.js +324 -0
- package/habilidades/backend-production-resilience/SKILL.md +288 -288
- package/habilidades/benchmark-memoria/SKILL.md +186 -186
- package/habilidades/build-errors-nextjs/SKILL.md +55 -1
- package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
- package/habilidades/doubt-driven-review/SKILL.md +171 -171
- package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
- package/habilidades/eval-framework/SKILL.md +212 -212
- package/habilidades/extractor-de-aprendizajes/SKILL.md +24 -10
- package/habilidades/harness-claude-code/SKILL.md +299 -299
- package/habilidades/infra-github-actions/SKILL.md +166 -166
- package/habilidades/legacy-code-rescue/SKILL.md +267 -267
- package/habilidades/manejo-errores/.evolved.json +8 -8
- package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
- package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
- package/habilidades/nextjs-testing/SKILL.md +89 -5
- package/habilidades/node-experto/SKILL.md +37 -1
- package/habilidades/patrones-python/SKILL.md +229 -229
- package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
- package/habilidades/planear-fase/SKILL.md +319 -319
- package/habilidades/react-experto/SKILL.md +45 -4
- package/habilidades/release-semver/.evolved.json +8 -8
- package/habilidades/swl-claudemd/SKILL.md +15 -1
- package/habilidades/tdd-workflow/SKILL.md +36 -4
- package/habilidades/testing-python/SKILL.md +340 -340
- package/hooks/claudemd-bloat-detector.js +161 -161
- package/hooks/inyeccion-contexto.js +8 -3
- package/hooks/lib/agent-routing.js +107 -107
- package/hooks/lib/auto-consolidator.js +335 -335
- package/hooks/lib/error-classifier.js +308 -308
- package/hooks/lib/merkle-audit.js +96 -96
- package/hooks/lib/provenance-tracker.js +191 -191
- package/hooks/lib/rate-limit-ip.js +177 -0
- package/hooks/lib/rate-limit-tracker.js +253 -253
- package/hooks/lib/resource-quota.js +122 -122
- package/hooks/lib/retry-jitter.js +165 -165
- package/hooks/lib/skill-auditor.js +588 -588
- package/hooks/lib/sync-status.js +228 -228
- package/hooks/lib/taint-tracker.js +107 -107
- package/hooks/lib/text-similarity.js +241 -241
- package/hooks/lib/toon-compressor.js +245 -245
- package/hooks/lib/webhook-dedup.js +184 -0
- package/hooks/lib/webhook-verify.js +123 -0
- package/hooks/proteccion-rutas.js +120 -15
- package/hooks/registro-turnos.js +209 -209
- package/hooks/sugerir-regenerar-inventario.js +170 -170
- package/hooks/validar-formato-post-subagente.js +140 -140
- package/hooks/validar-memoria-hook.js +218 -218
- package/instintos/prompt-appendices.yaml +57 -57
- package/manifiestos/agent-output-schemas.json +57 -57
- package/manifiestos/modulos.json +1 -0
- package/manifiestos/skills-lock.json +37 -37
- package/package.json +5 -3
- package/plantillas/auditor-veto-template.md +105 -105
- package/plantillas/github-workflows/README.md +47 -47
- package/plantillas/github-workflows/release-please.yml +44 -44
- package/plantillas/github-workflows/swl-ci.yml +107 -107
- package/plantillas/github-workflows/swl-security.yml +51 -51
- package/plugin.json +1 -1
- package/reglas/analisis-previo-tareas-grandes.md +172 -172
- package/reglas/arreglar-al-detectar.md +147 -147
- package/reglas/fragmentos-compartidos.md +152 -152
- package/reglas/harness-claude-code.md +213 -213
- package/reglas/usar-context7.md +226 -226
- package/reglas/usar-sistema-swl.md +251 -0
- package/schemas/diary-entry.schema.json +80 -80
- package/scripts/benchmark-memoria.js +167 -167
- package/scripts/comandos/skills.js +251 -2
- package/scripts/configurar-branch-protection.js +418 -418
- package/scripts/detectar-aprendizajes-duplicados.js +151 -151
- package/scripts/field-report.js +199 -199
- package/scripts/generar-checklists-consolidados.js +273 -273
- package/scripts/generar-inventario.js +420 -420
- package/scripts/generar-matriz-lenguajes.js +271 -271
- package/scripts/lib/artefactos-python.js +43 -43
- package/scripts/lib/benchmark-metrics.js +160 -160
- package/scripts/lib/budget-enforcer.js +252 -252
- package/scripts/lib/configurar-ci.js +380 -380
- package/scripts/lib/contadores-inventario.js +217 -217
- package/scripts/lib/detectar-stack-detallado.js +307 -307
- package/scripts/lib/diary-entry.js +234 -234
- package/scripts/lib/eval-metrics-store.js +218 -218
- package/scripts/lib/eval-quality.js +171 -171
- package/scripts/lib/eval-schemas.js +144 -144
- package/scripts/lib/eval-self-correct.js +106 -106
- package/scripts/lib/eval-validator.js +185 -185
- package/scripts/lib/jaccard-similarity.js +98 -98
- package/scripts/lib/longmemeval-runner.js +125 -125
- package/scripts/lib/npm-version.js +261 -261
- package/scripts/lib/paquetes-conocidos.js +50 -50
- package/scripts/lib/prompt-builder.js +264 -264
- package/scripts/lib/rrf-fusion.js +175 -175
- package/scripts/lib/scoring-instintos.js +277 -277
- package/scripts/lib/semantic-search.js +252 -252
- package/scripts/limpiar-artefactos-python.js +131 -131
- package/scripts/mcp-server/README.md +128 -128
- package/scripts/mcp-server/handlers.js +206 -206
- package/scripts/migrar-csv-a-array.js +168 -168
- package/scripts/migrar-fase-dominio.js +201 -201
- package/scripts/publicar.js +511 -511
- package/scripts/run-eval.js +141 -141
- package/scripts/validar-manifest.js +195 -195
- package/scripts/validar-userland-vacio.js +110 -110
- package/scripts/verificar-release.js +110 -0
package/gateway/index.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Gateway Runner — Orquestador multi-plataforma para SWL-SES.
|
|
6
|
+
*
|
|
7
|
+
* Conecta el sistema SWL con plataformas de mensajería externas
|
|
8
|
+
* (Telegram, Discord, Webhook) para notificaciones bidireccionales.
|
|
9
|
+
*
|
|
10
|
+
* Arquitectura:
|
|
11
|
+
* Claude Code (SWL) ←→ .planning/comms/ ←→ Gateway ←→ Plataformas
|
|
12
|
+
*
|
|
13
|
+
* Flujos:
|
|
14
|
+
* SWL → Plataforma: Polling de .planning/comms/ cada N segundos
|
|
15
|
+
* Plataforma → SWL: Escribir comandos en .planning/comms/
|
|
16
|
+
*
|
|
17
|
+
* Inspirado en Hermes Agent (gateway/run.py).
|
|
18
|
+
*
|
|
19
|
+
* Uso:
|
|
20
|
+
* node gateway/index.js [baseDir]
|
|
21
|
+
* node gateway/index.js --config manifiestos/gateway-config.json
|
|
22
|
+
*
|
|
23
|
+
* @module gateway/index
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Constantes
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const DEFAULT_POLL_INTERVAL = 2000; // 2 segundos
|
|
34
|
+
const COMMS_DIR = '.planning/comms';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Carga de configuración
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Carga la configuración del gateway desde archivo o defaults.
|
|
42
|
+
* @param {string} baseDir
|
|
43
|
+
* @returns {object}
|
|
44
|
+
*/
|
|
45
|
+
function loadConfig(baseDir) {
|
|
46
|
+
const configPath = path.join(baseDir, 'manifiestos', 'gateway-config.json');
|
|
47
|
+
const defaults = {
|
|
48
|
+
enabled: false,
|
|
49
|
+
pollIntervalMs: DEFAULT_POLL_INTERVAL,
|
|
50
|
+
adapters: {
|
|
51
|
+
telegram: { enabled: false },
|
|
52
|
+
discord: { enabled: false },
|
|
53
|
+
webhook: { enabled: false },
|
|
54
|
+
},
|
|
55
|
+
notifications: {
|
|
56
|
+
onSessionComplete: true,
|
|
57
|
+
onCheckpoint: true,
|
|
58
|
+
onError: true,
|
|
59
|
+
onRelease: true,
|
|
60
|
+
onBuildFail: true,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (fs.existsSync(configPath)) {
|
|
66
|
+
const loaded = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
67
|
+
return { ...defaults, ...loaded, adapters: { ...defaults.adapters, ...loaded.adapters } };
|
|
68
|
+
}
|
|
69
|
+
} catch (_) {}
|
|
70
|
+
|
|
71
|
+
return defaults;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Gateway Runner
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
class GatewayRunner {
|
|
79
|
+
constructor(baseDir) {
|
|
80
|
+
this.baseDir = baseDir;
|
|
81
|
+
this.config = loadConfig(baseDir);
|
|
82
|
+
this.adapters = [];
|
|
83
|
+
this._pollInterval = null;
|
|
84
|
+
this._running = false;
|
|
85
|
+
|
|
86
|
+
// CommandRelay opt-in — activo si config.relay.enabled === true.
|
|
87
|
+
// Permite recibir comandos bidireccionales desde canales externos
|
|
88
|
+
// (ej. Telegram) hacia Claude Code con validaciones de seguridad.
|
|
89
|
+
try {
|
|
90
|
+
const CommandRelay = require('./command-relay');
|
|
91
|
+
this.relay = new CommandRelay(baseDir, this.config);
|
|
92
|
+
} catch (_) {
|
|
93
|
+
this.relay = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Inicializa y arranca todos los adaptadores habilitados.
|
|
99
|
+
*/
|
|
100
|
+
async start() {
|
|
101
|
+
if (!this.config.enabled) {
|
|
102
|
+
console.log('[gateway] Gateway deshabilitado en configuración. Activar con enabled: true.');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('[gateway] Iniciando gateway...');
|
|
107
|
+
|
|
108
|
+
// Cargar adaptadores habilitados
|
|
109
|
+
const adapterConfigs = this.config.adapters;
|
|
110
|
+
|
|
111
|
+
if (adapterConfigs.telegram?.enabled) {
|
|
112
|
+
const TelegramAdapter = require('./adapters/telegram');
|
|
113
|
+
const adapter = new TelegramAdapter(adapterConfigs.telegram);
|
|
114
|
+
adapter.onMessage((msg) => this._handleIncoming(msg));
|
|
115
|
+
await adapter.start();
|
|
116
|
+
if (adapter.running) this.adapters.push(adapter);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (adapterConfigs.discord?.enabled) {
|
|
120
|
+
const DiscordAdapter = require('./adapters/discord');
|
|
121
|
+
const adapter = new DiscordAdapter(adapterConfigs.discord);
|
|
122
|
+
adapter.onMessage((msg) => this._handleIncoming(msg));
|
|
123
|
+
await adapter.start();
|
|
124
|
+
if (adapter.running) this.adapters.push(adapter);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (adapterConfigs.webhook?.enabled) {
|
|
128
|
+
const WebhookAdapter = require('./adapters/webhook');
|
|
129
|
+
const adapter = new WebhookAdapter(adapterConfigs.webhook);
|
|
130
|
+
await adapter.start();
|
|
131
|
+
if (adapter.running) this.adapters.push(adapter);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (adapterConfigs.whatsapp?.enabled) {
|
|
135
|
+
const WhatsAppAdapter = require('./adapters/whatsapp');
|
|
136
|
+
const adapter = new WhatsAppAdapter(adapterConfigs.whatsapp);
|
|
137
|
+
adapter.onMessage((msg) => this._handleIncoming(msg));
|
|
138
|
+
await adapter.start();
|
|
139
|
+
if (adapter.running) this.adapters.push(adapter);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (adapterConfigs.slack?.enabled) {
|
|
143
|
+
const SlackAdapter = require('./adapters/slack');
|
|
144
|
+
const adapter = new SlackAdapter(adapterConfigs.slack);
|
|
145
|
+
adapter.onMessage((msg) => this._handleIncoming(msg));
|
|
146
|
+
await adapter.start();
|
|
147
|
+
if (adapter.running) this.adapters.push(adapter);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (adapterConfigs.email?.enabled) {
|
|
151
|
+
const EmailAdapter = require('./adapters/email');
|
|
152
|
+
const adapter = new EmailAdapter(adapterConfigs.email);
|
|
153
|
+
await adapter.start();
|
|
154
|
+
if (adapter.running) this.adapters.push(adapter);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (this.adapters.length === 0) {
|
|
158
|
+
console.log('[gateway] Ningún adaptador habilitado o disponible.');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(`[gateway] ${this.adapters.length} adaptador(es) activo(s): ${this.adapters.map(a => a.name).join(', ')}`);
|
|
163
|
+
|
|
164
|
+
// Iniciar polling de agent-comms
|
|
165
|
+
this._running = true;
|
|
166
|
+
this._pollInterval = setInterval(
|
|
167
|
+
() => this._pollComms(),
|
|
168
|
+
this.config.pollIntervalMs || DEFAULT_POLL_INTERVAL,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
console.log(`[gateway] Polling activo (cada ${this.config.pollIntervalMs || DEFAULT_POLL_INTERVAL}ms).`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Detiene todos los adaptadores y el polling.
|
|
176
|
+
*/
|
|
177
|
+
async stop() {
|
|
178
|
+
this._running = false;
|
|
179
|
+
if (this._pollInterval) {
|
|
180
|
+
clearInterval(this._pollInterval);
|
|
181
|
+
this._pollInterval = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const adapter of this.adapters) {
|
|
185
|
+
await adapter.stop();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.adapters = [];
|
|
189
|
+
console.log('[gateway] Detenido.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Poll de .planning/comms/ buscando mensajes tipo gateway_notification.
|
|
194
|
+
* @private
|
|
195
|
+
*/
|
|
196
|
+
_pollComms() {
|
|
197
|
+
const commsDir = path.join(this.baseDir, COMMS_DIR);
|
|
198
|
+
if (!fs.existsSync(commsDir)) return;
|
|
199
|
+
|
|
200
|
+
let files;
|
|
201
|
+
try {
|
|
202
|
+
files = fs.readdirSync(commsDir)
|
|
203
|
+
.filter(f => f.startsWith('msg-') && f.endsWith('.json'));
|
|
204
|
+
} catch (_) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const file of files) {
|
|
209
|
+
const filePath = path.join(commsDir, file);
|
|
210
|
+
let msg;
|
|
211
|
+
try {
|
|
212
|
+
msg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
213
|
+
} catch (_) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Solo procesar mensajes de gateway pendientes
|
|
218
|
+
if (msg.status !== 'pending') continue;
|
|
219
|
+
if (msg.type !== 'gateway_notification' && msg.type !== 'gateway_command_response') continue;
|
|
220
|
+
|
|
221
|
+
// Despachar a adaptador(es) correspondientes
|
|
222
|
+
const target = msg.to || 'all';
|
|
223
|
+
this._dispatch(target, msg);
|
|
224
|
+
|
|
225
|
+
// Marcar como procesado
|
|
226
|
+
try {
|
|
227
|
+
msg.status = 'processed';
|
|
228
|
+
msg.processedAt = new Date().toISOString();
|
|
229
|
+
fs.writeFileSync(filePath, JSON.stringify(msg, null, 2), 'utf8');
|
|
230
|
+
} catch (_) {}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Despacha un mensaje a el/los adaptador(es) objetivo.
|
|
236
|
+
* @private
|
|
237
|
+
*/
|
|
238
|
+
_dispatch(target, message) {
|
|
239
|
+
for (const adapter of this.adapters) {
|
|
240
|
+
if (target === 'all' || target === adapter.name || target === 'broadcast') {
|
|
241
|
+
adapter.send(message).catch(err => {
|
|
242
|
+
console.error(`[gateway] Error despachando a ${adapter.name}: ${err.message}`);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Maneja un mensaje entrante de una plataforma.
|
|
250
|
+
* Escribe en .planning/comms/ para que Claude Code lo procese.
|
|
251
|
+
* Adicionalmente, si el CommandRelay está habilitado para la plataforma
|
|
252
|
+
* y el usuario, encola en .planning/inbox/ para consumo vía /swl:inbox.
|
|
253
|
+
* @private
|
|
254
|
+
*/
|
|
255
|
+
_handleIncoming(message) {
|
|
256
|
+
const commsDir = path.join(this.baseDir, COMMS_DIR);
|
|
257
|
+
if (!fs.existsSync(commsDir)) {
|
|
258
|
+
fs.mkdirSync(commsDir, { recursive: true });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const id = `msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
262
|
+
const msg = {
|
|
263
|
+
id,
|
|
264
|
+
type: 'gateway_command',
|
|
265
|
+
from: `${message.platform}:${message.userId || 'unknown'}`,
|
|
266
|
+
to: 'swl-system',
|
|
267
|
+
payload: {
|
|
268
|
+
platform: message.platform,
|
|
269
|
+
chatId: message.chatId,
|
|
270
|
+
userId: message.userId,
|
|
271
|
+
userName: message.userName,
|
|
272
|
+
text: message.text,
|
|
273
|
+
command: message.command,
|
|
274
|
+
args: message.args,
|
|
275
|
+
},
|
|
276
|
+
timestamp: new Date().toISOString(),
|
|
277
|
+
status: 'pending',
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const filePath = path.join(commsDir, `${id}.json`);
|
|
281
|
+
try {
|
|
282
|
+
fs.writeFileSync(filePath, JSON.stringify(msg, null, 2), 'utf8');
|
|
283
|
+
} catch (_) {}
|
|
284
|
+
|
|
285
|
+
// Relay bidireccional: encolar también en .planning/inbox/ si aplica.
|
|
286
|
+
if (this.relay && this.relay.habilitado()) {
|
|
287
|
+
try {
|
|
288
|
+
this.relay.recibirComando(message);
|
|
289
|
+
} catch (_) { /* silencioso */ }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Entrypoint CLI
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
if (require.main === module) {
|
|
299
|
+
const baseDir = process.argv[2] || process.cwd();
|
|
300
|
+
|
|
301
|
+
const gateway = new GatewayRunner(baseDir);
|
|
302
|
+
|
|
303
|
+
const cleanup = async () => {
|
|
304
|
+
await gateway.stop();
|
|
305
|
+
process.exit(0);
|
|
306
|
+
};
|
|
307
|
+
process.on('SIGTERM', cleanup);
|
|
308
|
+
process.on('SIGINT', cleanup);
|
|
309
|
+
|
|
310
|
+
gateway.start().catch(err => {
|
|
311
|
+
console.error(`[gateway] Error fatal: ${err.message}`);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Exports
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
module.exports = { GatewayRunner, loadConfig };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* event-channel.js
|
|
5
|
+
*
|
|
6
|
+
* Pub/sub event channel para CommandRelay y otros componentes del gateway.
|
|
7
|
+
*
|
|
8
|
+
* Patrón adaptado de `temp/obsidian-agent-client-master/src/acp/acp-handler.ts`
|
|
9
|
+
* (Set<listeners> con callback unsubscribe). Diferencias:
|
|
10
|
+
* - Zero-deps Node.js (sin RxJS, sin EventEmitter de node — Set nativo).
|
|
11
|
+
* - Backward compat: si no hay listeners, comportamiento idéntico al actual.
|
|
12
|
+
* - Tipos de evento explícitos para evitar typos.
|
|
13
|
+
*
|
|
14
|
+
* Casos de uso en SWL:
|
|
15
|
+
* - CommandRelay emite eventos al recibir/aceptar/rechazar/procesar comandos.
|
|
16
|
+
* - Múltiples adaptadores (Telegram, Discord) pueden suscribirse simultáneamente.
|
|
17
|
+
* - El consumidor /swl:inbox puede mostrar progreso sin polling.
|
|
18
|
+
*
|
|
19
|
+
* Uso:
|
|
20
|
+
* const channel = new EventChannel();
|
|
21
|
+
* const off = channel.on('cmd:queued', (event) => console.log(event));
|
|
22
|
+
* channel.emit({ type: 'cmd:queued', commandId: 'cmd-abc', userId: '123' });
|
|
23
|
+
* off(); // unsubscribe
|
|
24
|
+
*
|
|
25
|
+
* @module gateway/lib/event-channel
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// ── tipos de evento ──────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Tipos válidos de evento. Se usan strings para serializar fácilmente
|
|
32
|
+
* a JSONL si se requiere persistencia (no obligatorio).
|
|
33
|
+
*/
|
|
34
|
+
const EVENTS = Object.freeze({
|
|
35
|
+
// Lifecycle de comandos en CommandRelay
|
|
36
|
+
CMD_RECEIVED: 'cmd:received', // mensaje llegó al relay
|
|
37
|
+
CMD_REJECTED: 'cmd:rejected', // rechazado por validación (auth/rate/dedup)
|
|
38
|
+
CMD_QUEUED: 'cmd:queued', // encolado en .planning/inbox/
|
|
39
|
+
CMD_PROCESSED: 'cmd:processed', // marcado como procesado por consumidor
|
|
40
|
+
|
|
41
|
+
// Lifecycle de notificaciones
|
|
42
|
+
NOTIFICATION_SENT: 'notification:sent',
|
|
43
|
+
NOTIFICATION_FAILED: 'notification:failed',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── implementación ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Canal de eventos pub/sub con escucha por tipo o wildcard.
|
|
50
|
+
*
|
|
51
|
+
* Mantiene dos estructuras:
|
|
52
|
+
* - listenersByType: Map<eventType, Set<callback>>
|
|
53
|
+
* - wildcardListeners: Set<callback> (escuchan TODOS los eventos)
|
|
54
|
+
*
|
|
55
|
+
* Una excepción en un listener NO interrumpe la propagación a los demás —
|
|
56
|
+
* patrón de aislamiento del mismo origen (acp-handler.ts:47-50).
|
|
57
|
+
*/
|
|
58
|
+
class EventChannel {
|
|
59
|
+
constructor() {
|
|
60
|
+
this.listenersByType = new Map();
|
|
61
|
+
this.wildcardListeners = new Set();
|
|
62
|
+
this.errorHandler = null; // opcional: callback para errores en listeners
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Suscribe un listener a un tipo de evento (o '*' para todos).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} eventType - tipo de evento o '*'
|
|
69
|
+
* @param {function} callback - recibe el objeto event
|
|
70
|
+
* @returns {function} función de unsubscribe (idempotente)
|
|
71
|
+
*/
|
|
72
|
+
on(eventType, callback) {
|
|
73
|
+
if (typeof callback !== 'function') {
|
|
74
|
+
throw new TypeError('callback debe ser función');
|
|
75
|
+
}
|
|
76
|
+
if (typeof eventType !== 'string' || !eventType) {
|
|
77
|
+
throw new TypeError('eventType debe ser string no vacío');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (eventType === '*') {
|
|
81
|
+
this.wildcardListeners.add(callback);
|
|
82
|
+
return () => this.wildcardListeners.delete(callback);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!this.listenersByType.has(eventType)) {
|
|
86
|
+
this.listenersByType.set(eventType, new Set());
|
|
87
|
+
}
|
|
88
|
+
const set = this.listenersByType.get(eventType);
|
|
89
|
+
set.add(callback);
|
|
90
|
+
return () => set.delete(callback);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Suscribe un listener que se invoca UNA sola vez y luego se desuscribe.
|
|
95
|
+
*/
|
|
96
|
+
once(eventType, callback) {
|
|
97
|
+
const off = this.on(eventType, (event) => {
|
|
98
|
+
off();
|
|
99
|
+
callback(event);
|
|
100
|
+
});
|
|
101
|
+
return off;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Emite un evento a todos los listeners suscriptos al tipo + wildcards.
|
|
106
|
+
* El evento debe tener al menos `type`. Se enriquece con `ts` (ISO).
|
|
107
|
+
*
|
|
108
|
+
* @param {object} event - objeto con `type` y datos arbitrarios
|
|
109
|
+
* @returns {number} cantidad de listeners notificados
|
|
110
|
+
*/
|
|
111
|
+
emit(event) {
|
|
112
|
+
if (!event || typeof event !== 'object') {
|
|
113
|
+
throw new TypeError('event debe ser objeto');
|
|
114
|
+
}
|
|
115
|
+
if (typeof event.type !== 'string' || !event.type) {
|
|
116
|
+
throw new TypeError('event.type es obligatorio');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const enriched = { ts: new Date().toISOString(), ...event };
|
|
120
|
+
let notified = 0;
|
|
121
|
+
|
|
122
|
+
// Listeners específicos del tipo
|
|
123
|
+
const typedSet = this.listenersByType.get(enriched.type);
|
|
124
|
+
if (typedSet) {
|
|
125
|
+
for (const cb of typedSet) {
|
|
126
|
+
notified++;
|
|
127
|
+
this._safeInvoke(cb, enriched);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Listeners wildcard
|
|
132
|
+
for (const cb of this.wildcardListeners) {
|
|
133
|
+
notified++;
|
|
134
|
+
this._safeInvoke(cb, enriched);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return notified;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Cuenta de listeners para un tipo (o total si no se pasa tipo).
|
|
142
|
+
*/
|
|
143
|
+
listenerCount(eventType) {
|
|
144
|
+
if (eventType == null) {
|
|
145
|
+
let total = this.wildcardListeners.size;
|
|
146
|
+
for (const set of this.listenersByType.values()) total += set.size;
|
|
147
|
+
return total;
|
|
148
|
+
}
|
|
149
|
+
if (eventType === '*') return this.wildcardListeners.size;
|
|
150
|
+
const set = this.listenersByType.get(eventType);
|
|
151
|
+
return (set ? set.size : 0) + this.wildcardListeners.size;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Limpia todos los listeners. Útil para tests.
|
|
156
|
+
*/
|
|
157
|
+
clear() {
|
|
158
|
+
this.listenersByType.clear();
|
|
159
|
+
this.wildcardListeners.clear();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Asigna un handler para errores en listeners.
|
|
164
|
+
* Si no se asigna, los errores se silencian (no rompen la propagación).
|
|
165
|
+
*/
|
|
166
|
+
onError(handler) {
|
|
167
|
+
if (typeof handler !== 'function') {
|
|
168
|
+
throw new TypeError('handler debe ser función');
|
|
169
|
+
}
|
|
170
|
+
this.errorHandler = handler;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── internos ────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
_safeInvoke(cb, event) {
|
|
176
|
+
try {
|
|
177
|
+
cb(event);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (this.errorHandler) {
|
|
180
|
+
try { this.errorHandler(err, event); } catch (_) { /* silencioso */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── exports ───────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
EventChannel,
|
|
190
|
+
EVENTS,
|
|
191
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gateway Session — Gestión de sesiones por plataforma.
|
|
5
|
+
*
|
|
6
|
+
* Mantiene un mapeo entre usuarios de plataformas externas y sesiones SWL.
|
|
7
|
+
* Permite que un usuario de Telegram tenga una "sesión" persistente con el gateway.
|
|
8
|
+
*
|
|
9
|
+
* Persistencia en .planning/gateway-sessions.json.
|
|
10
|
+
*
|
|
11
|
+
* @module gateway/session
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const { atomicWriteJSON } = require('../hooks/lib/atomic-write');
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Constantes
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const SESSIONS_FILE = '.planning/gateway-sessions.json';
|
|
24
|
+
const INACTIVITY_MS = 30 * 60 * 1000; // 30 minutos
|
|
25
|
+
const MAX_SESSIONS = 100;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// API
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Obtiene o crea una sesión para un usuario de plataforma.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} baseDir
|
|
35
|
+
* @param {string} platformUserId - Identificador único: "telegram:12345"
|
|
36
|
+
* @returns {object} Sesión con lastActivity actualizada.
|
|
37
|
+
*/
|
|
38
|
+
function getOrCreateSession(baseDir, platformUserId) {
|
|
39
|
+
const sessions = _loadSessions(baseDir);
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
|
|
42
|
+
let session = sessions[platformUserId];
|
|
43
|
+
|
|
44
|
+
if (session) {
|
|
45
|
+
// Verificar timeout de inactividad
|
|
46
|
+
if (now - session.lastActivity > INACTIVITY_MS) {
|
|
47
|
+
// Sesión expirada — crear nueva
|
|
48
|
+
session = _newSession(platformUserId);
|
|
49
|
+
} else {
|
|
50
|
+
session.lastActivity = now;
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
session = _newSession(platformUserId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
sessions[platformUserId] = session;
|
|
57
|
+
|
|
58
|
+
// Limitar número total de sesiones (FIFO)
|
|
59
|
+
const keys = Object.keys(sessions);
|
|
60
|
+
if (keys.length > MAX_SESSIONS) {
|
|
61
|
+
// Eliminar las más antiguas
|
|
62
|
+
const sorted = keys.sort((a, b) => sessions[a].lastActivity - sessions[b].lastActivity);
|
|
63
|
+
for (let i = 0; i < keys.length - MAX_SESSIONS; i++) {
|
|
64
|
+
delete sessions[sorted[i]];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_saveSessions(baseDir, sessions);
|
|
69
|
+
return session;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lista todas las sesiones activas (no expiradas).
|
|
74
|
+
*
|
|
75
|
+
* @param {string} baseDir
|
|
76
|
+
* @returns {object[]}
|
|
77
|
+
*/
|
|
78
|
+
function listActiveSessions(baseDir) {
|
|
79
|
+
const sessions = _loadSessions(baseDir);
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
|
|
82
|
+
return Object.entries(sessions)
|
|
83
|
+
.filter(([, s]) => now - s.lastActivity <= INACTIVITY_MS)
|
|
84
|
+
.map(([userId, s]) => ({ userId, ...s }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Invalida una sesión específica.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} baseDir
|
|
91
|
+
* @param {string} platformUserId
|
|
92
|
+
*/
|
|
93
|
+
function invalidateSession(baseDir, platformUserId) {
|
|
94
|
+
const sessions = _loadSessions(baseDir);
|
|
95
|
+
delete sessions[platformUserId];
|
|
96
|
+
_saveSessions(baseDir, sessions);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Helpers internos
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function _newSession(platformUserId) {
|
|
104
|
+
const [platform, userId] = platformUserId.split(':');
|
|
105
|
+
return {
|
|
106
|
+
id: `gw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
107
|
+
platform,
|
|
108
|
+
userId,
|
|
109
|
+
createdAt: Date.now(),
|
|
110
|
+
lastActivity: Date.now(),
|
|
111
|
+
context: {},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _loadSessions(baseDir) {
|
|
116
|
+
const p = path.join(baseDir, SESSIONS_FILE);
|
|
117
|
+
try {
|
|
118
|
+
if (!fs.existsSync(p)) return {};
|
|
119
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
120
|
+
} catch (_) {
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _saveSessions(baseDir, sessions) {
|
|
126
|
+
const dir = path.dirname(path.join(baseDir, SESSIONS_FILE));
|
|
127
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
128
|
+
atomicWriteJSON(path.join(baseDir, SESSIONS_FILE), sessions);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { getOrCreateSession, listActiveSessions, invalidateSession };
|