@danieltmn/tamnora 0.14.0 → 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.
- package/CHANGELOG.md +15 -0
- package/harness/packages/core/src/config/index.js +2 -0
- package/harness/packages/core/src/hooks/index.js +42 -0
- package/harness/packages/core/src/index.js +2 -0
- package/harness/packages/core/src/plugins/index.js +36 -0
- package/harness/packages/core/src/rules/index.js +9 -63
- package/harness/packages/core/src/tools/glob.js +64 -2
- package/harness/packages/core/test/hooks.test.js +29 -0
- package/harness/packages/core/test/plugins.test.js +25 -0
- package/harness/packages/server/public/app.js +6 -0
- package/harness/packages/server/public/index.html +10 -2
- package/harness/packages/server/src/lib/advisor.js +4 -0
- package/harness/packages/server/src/lib/agentRun.js +3 -1
- package/harness/packages/server/src/lib/channels/telegram.js +7 -1
- package/harness/packages/server/src/lib/compact.js +4 -0
- package/harness/packages/server/src/lib/hooks.js +45 -0
- package/harness/packages/server/src/lib/plugins.js +76 -0
- package/harness/packages/server/src/lib/scheduler.js +8 -1
- package/harness/packages/server/src/routes/advisor.js +4 -2
- package/harness/packages/server/src/routes/chat.js +24 -5
- package/harness/packages/server/src/server.js +16 -0
- package/harness/packages/server/test/server.test.js +66 -0
- package/harness/tamnora.example.json +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ 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
|
+
|
|
8
23
|
## [0.14.0] - 2026-10-10
|
|
9
24
|
|
|
10
25
|
### Agregado
|
|
@@ -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
|
+
}
|
|
@@ -124,6 +124,8 @@ export {
|
|
|
124
124
|
buildPathRulesBlock,
|
|
125
125
|
} from './rules/index.js';
|
|
126
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';
|
|
127
129
|
export {
|
|
128
130
|
searchMcpRegistry,
|
|
129
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
/* ignore unreadable rule */
|
|
84
55
|
}
|
|
85
56
|
}
|
|
86
|
-
|
|
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). */
|
|
@@ -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,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
|
+
});
|
|
@@ -3754,6 +3754,8 @@ const asesorExplainEl = $('#asesor-explain');
|
|
|
3754
3754
|
const asesorAdviceEl = $('#asesor-advice');
|
|
3755
3755
|
const asesorWordsEl = $('#asesor-words');
|
|
3756
3756
|
const asesorNotesEl = $('#asesor-notes');
|
|
3757
|
+
const asesorProviderEl = $('#asesor-provider');
|
|
3758
|
+
const asesorModelEl = $('#asesor-model');
|
|
3757
3759
|
const trProviderEl = $('#tr-provider');
|
|
3758
3760
|
const trModelEl = $('#tr-model');
|
|
3759
3761
|
|
|
@@ -4734,6 +4736,8 @@ function aplicarAsesor(data) {
|
|
|
4734
4736
|
if (asesorAdviceEl) asesorAdviceEl.checked = data.advice !== false;
|
|
4735
4737
|
if (asesorWordsEl) asesorWordsEl.value = data.maxWords ?? 40;
|
|
4736
4738
|
if (asesorNotesEl) asesorNotesEl.value = data.notes ?? '';
|
|
4739
|
+
if (asesorProviderEl) asesorProviderEl.value = data.provider ?? '';
|
|
4740
|
+
if (asesorModelEl) asesorModelEl.value = data.model ?? '';
|
|
4737
4741
|
const estado = $('#asesor-status');
|
|
4738
4742
|
if (estado) {
|
|
4739
4743
|
estado.className = `summary${data.enabled ? ' ok' : ''}`;
|
|
@@ -4755,6 +4759,8 @@ function asesorBody() {
|
|
|
4755
4759
|
advice: asesorAdviceEl?.checked !== false,
|
|
4756
4760
|
maxWords: Number(asesorWordsEl?.value) || 40,
|
|
4757
4761
|
notes: asesorNotesEl?.value ?? '',
|
|
4762
|
+
provider: asesorProviderEl?.value ?? '',
|
|
4763
|
+
model: asesorModelEl?.value ?? '',
|
|
4758
4764
|
};
|
|
4759
4765
|
}
|
|
4760
4766
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
</head>
|
|
38
38
|
<body>
|
|
39
39
|
<header class="topbar">
|
|
40
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.
|
|
40
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.15.0</span></div>
|
|
41
41
|
<nav class="tabs">
|
|
42
42
|
<button class="tab active" data-tab="chat">chat</button>
|
|
43
43
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -737,8 +737,16 @@
|
|
|
737
737
|
<p class="muted verify-line">
|
|
738
738
|
Un asesor que observa lo que el agente hace, lo interpreta y solo habla cuando tiene
|
|
739
739
|
algo que aportar: te alerta de un problema en el código, te explica un proceso o te da
|
|
740
|
-
un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos.
|
|
740
|
+
un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos, o unos propios.
|
|
741
741
|
</p>
|
|
742
|
+
<div class="row">
|
|
743
|
+
<label class="field"><span>proveedor (opcional)</span>
|
|
744
|
+
<input id="asesor-provider" spellcheck="false" placeholder="por defecto: el activo" />
|
|
745
|
+
</label>
|
|
746
|
+
<label class="field grow"><span>modelo (opcional)</span>
|
|
747
|
+
<input id="asesor-model" spellcheck="false" placeholder="por defecto: el activo" />
|
|
748
|
+
</label>
|
|
749
|
+
</div>
|
|
742
750
|
<label class="check" title="tener un asesor que interprete y comente el turno">
|
|
743
751
|
<input id="asesor-enabled" type="checkbox" /> Habilitar el asesor
|
|
744
752
|
</label>
|
|
@@ -15,6 +15,8 @@ export const DEFAULT_ADVISOR = {
|
|
|
15
15
|
advice: true,
|
|
16
16
|
notes: '',
|
|
17
17
|
maxWords: 40,
|
|
18
|
+
provider: '',
|
|
19
|
+
model: '',
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
const KINDS = new Set(['code', 'explain', 'advice']);
|
|
@@ -34,6 +36,8 @@ export function resolveAdvisor(app) {
|
|
|
34
36
|
maxWords: Number.isFinite(maxWords)
|
|
35
37
|
? Math.min(Math.max(Math.round(maxWords), 10), 120)
|
|
36
38
|
: DEFAULT_ADVISOR.maxWords,
|
|
39
|
+
provider: typeof saved.provider === 'string' ? saved.provider.trim().slice(0, 60) : '',
|
|
40
|
+
model: typeof saved.model === 'string' ? saved.model.trim().slice(0, 120) : '',
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -20,16 +20,18 @@ export function toolCallPayload(event) {
|
|
|
20
20
|
* Combina tools locales + admin + subagentes en un toolset unificado.
|
|
21
21
|
* `approve` es el callback de aprobacion de las tools de administraación.
|
|
22
22
|
*/
|
|
23
|
-
export function combineToolset({ local, admin = null, tasks = null, exposeAdmin = false, approve = null }) {
|
|
23
|
+
export function combineToolset({ local, admin = null, tasks = null, plugins = null, exposeAdmin = false, approve = null }) {
|
|
24
24
|
const adminAllowed = (name) => Boolean(admin?.handles(name)) && (exposeAdmin || admin.readOnly?.has(name));
|
|
25
25
|
const definitions = [
|
|
26
26
|
...local.definitions,
|
|
27
27
|
...(admin ? admin.definitions.filter((d) => adminAllowed(d.function.name)) : []),
|
|
28
28
|
...(tasks ? tasks.definitions : []),
|
|
29
|
+
...(plugins ? plugins.definitions : []),
|
|
29
30
|
];
|
|
30
31
|
const execute = (name, args, context = {}) => {
|
|
31
32
|
if (tasks?.handles(name)) return tasks.execute(name, args);
|
|
32
33
|
if (adminAllowed(name)) return admin.execute(name, args, { approve, ...context });
|
|
34
|
+
if (plugins?.handles(name)) return plugins.execute(name, args, { approve, ...context });
|
|
33
35
|
return local.execute(name, args, context);
|
|
34
36
|
};
|
|
35
37
|
return { definitions, execute };
|
|
@@ -477,7 +477,13 @@ export function startTelegramChannel(
|
|
|
477
477
|
});
|
|
478
478
|
const exposeAdmin = !mode.tools;
|
|
479
479
|
const admin = ctx.agentTools;
|
|
480
|
-
const { definitions, execute } = combineToolset({
|
|
480
|
+
const { definitions, execute } = combineToolset({
|
|
481
|
+
local,
|
|
482
|
+
admin,
|
|
483
|
+
plugins: exposeAdmin ? ctx.plugins : null,
|
|
484
|
+
exposeAdmin,
|
|
485
|
+
approve: onAsk,
|
|
486
|
+
});
|
|
481
487
|
const { provider, config } = resolveProviderFor(ctx, providerId);
|
|
482
488
|
const userMessage = buildUserMessage(input, images);
|
|
483
489
|
const messages = await composeAgentMessages({ ctx, cwd, mode, history, userMessage });
|
|
@@ -52,8 +52,10 @@ export async function summarizeSession(ctx, session, instruction) {
|
|
|
52
52
|
if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
|
|
53
53
|
const resolved = providerFor(ctx, session);
|
|
54
54
|
if (resolved.error) return resolved;
|
|
55
|
+
ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, messages: messages.length });
|
|
55
56
|
const summary = await summarize(ctx, session, messages, instruction);
|
|
56
57
|
if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
|
|
58
|
+
ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, summary });
|
|
57
59
|
return { summary };
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -70,8 +72,10 @@ export async function maybeAutoCompact(ctx, session, history, { contextTokens }
|
|
|
70
72
|
const live = liveHistory(history, session.meta?.summaryCursor ?? null);
|
|
71
73
|
const plan = planCompaction(live, { contextTokens, threshold, keepLast });
|
|
72
74
|
if (!plan) return null;
|
|
75
|
+
ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, auto: true, messages: plan.toSummarize.length });
|
|
73
76
|
const summary = await summarize(ctx, session, plan.toSummarize, COMPACT_INSTRUCTION, session.meta?.summary);
|
|
74
77
|
if (!summary) return null;
|
|
75
78
|
ctx.store.touchSession(session.id, { meta: { summary, summaryCursor: plan.cursor } });
|
|
79
|
+
ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, auto: true, summary });
|
|
76
80
|
return { summary, cursor: plan.cursor, summarized: plan.toSummarize.length };
|
|
77
81
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Runner de hooks: carga los modulos (`core/src/hooks`) y despacha eventos a
|
|
2
|
+
// sus handlers de forma aislada (un hook que tira no rompe el run) y con los
|
|
3
|
+
// datos redactados. Es opt-in: sin archivos de hooks, no se ejecuta codigo.
|
|
4
|
+
|
|
5
|
+
import { loadHooks, redactDeep } from './core.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Crea el runner. `dispatch(event)` llama a los handlers suscritos a
|
|
9
|
+
* `event.type` (y a los wildcard `*`); no devuelve valor (observadores).
|
|
10
|
+
*/
|
|
11
|
+
export async function createHookRunner(ctx, patterns = []) {
|
|
12
|
+
let hooks = [];
|
|
13
|
+
try {
|
|
14
|
+
hooks = await loadHooks(ctx.cwd, patterns);
|
|
15
|
+
} catch {
|
|
16
|
+
hooks = [];
|
|
17
|
+
}
|
|
18
|
+
const handlers = new Map();
|
|
19
|
+
for (const hook of hooks) {
|
|
20
|
+
for (const [event, fn] of Object.entries(hook.events)) {
|
|
21
|
+
if (typeof fn !== 'function') continue;
|
|
22
|
+
const list = handlers.get(event) ?? [];
|
|
23
|
+
list.push(fn);
|
|
24
|
+
handlers.set(event, list);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function dispatch(event) {
|
|
29
|
+
const list = [...(handlers.get(event.type) ?? []), ...(handlers.get('*') ?? [])];
|
|
30
|
+
if (!list.length) return;
|
|
31
|
+
const safe = redactDeep(event);
|
|
32
|
+
for (const fn of list) {
|
|
33
|
+
Promise.resolve()
|
|
34
|
+
.then(() => fn(safe, ctx))
|
|
35
|
+
.catch(() => {
|
|
36
|
+
// un hook caido no debe frenar el run
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const view = () =>
|
|
42
|
+
hooks.map((hook) => ({ id: hook.id, path: hook.path, events: Object.keys(hook.events).filter((e) => e !== '*') }));
|
|
43
|
+
|
|
44
|
+
return { dispatch, count: hooks.length, list: view };
|
|
45
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Plugins: agregan herramientas propias al agente (`.tamnora/plugins/*.mjs` o
|
|
2
|
+
// las rutas de `config.plugins`). Corren codigo del usuario, asi que pasan por
|
|
3
|
+
// permisos (default `ask`).
|
|
4
|
+
|
|
5
|
+
import { loadPlugins } from './core.js';
|
|
6
|
+
|
|
7
|
+
function normalizeName(value) {
|
|
8
|
+
return String(value ?? '')
|
|
9
|
+
.replace(/[^a-z0-9_-]+/gi, '_')
|
|
10
|
+
.replace(/^_+|_+$/g, '')
|
|
11
|
+
.slice(0, 64);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Crea el toolset de plugins. `execute(name, args, { approve })`. */
|
|
15
|
+
export async function createPlugins(ctx, patterns = []) {
|
|
16
|
+
let plugins = [];
|
|
17
|
+
try {
|
|
18
|
+
plugins = await loadPlugins(ctx.cwd, patterns);
|
|
19
|
+
} catch {
|
|
20
|
+
plugins = [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tools = [];
|
|
24
|
+
const used = new Set();
|
|
25
|
+
for (const plugin of plugins) {
|
|
26
|
+
for (const tool of plugin.tools) {
|
|
27
|
+
const name = `plugin__${normalizeName(plugin.id)}__${normalizeName(tool.name)}`;
|
|
28
|
+
if (!name || used.has(name)) continue;
|
|
29
|
+
used.add(name);
|
|
30
|
+
tools.push({
|
|
31
|
+
name,
|
|
32
|
+
source: tool.name,
|
|
33
|
+
plugin: plugin.id,
|
|
34
|
+
description: String(tool.description || tool.name),
|
|
35
|
+
parameters: tool.parameters && typeof tool.parameters === 'object' ? tool.parameters : {},
|
|
36
|
+
required: Array.isArray(tool.required) ? tool.required : [],
|
|
37
|
+
handler: tool.handler,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
43
|
+
const definitions = tools.map((tool) => ({
|
|
44
|
+
type: 'function',
|
|
45
|
+
function: {
|
|
46
|
+
name: tool.name,
|
|
47
|
+
description: tool.description,
|
|
48
|
+
parameters: { type: 'object', properties: tool.parameters, required: tool.required },
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
async function execute(name, args = {}, { approve } = {}) {
|
|
53
|
+
const tool = byName.get(name);
|
|
54
|
+
if (!tool) return `Herramienta desconocida: ${name}`;
|
|
55
|
+
const decision = (ctx.config.permissions ?? {})[tool.name] ?? 'ask';
|
|
56
|
+
if (decision === 'deny') return `Permiso denegado para "${name}".`;
|
|
57
|
+
if (decision === 'ask') {
|
|
58
|
+
const ok = approve ? await approve(name, args) : false;
|
|
59
|
+
if (!ok) return `Permiso denegado para "${name}".`;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const out = await tool.handler(args, ctx);
|
|
63
|
+
return typeof out === 'string' ? out : JSON.stringify(out ?? null, null, 2);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return `Error en ${name}: ${error?.message ?? error}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
definitions,
|
|
71
|
+
handles: (name) => byName.has(name),
|
|
72
|
+
execute,
|
|
73
|
+
count: tools.length,
|
|
74
|
+
list: () => tools.map(({ name, source, plugin, description }) => ({ name, source, plugin, description })),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -68,7 +68,14 @@ async function runAgentSchedule(ctx, schedule) {
|
|
|
68
68
|
exclude: admin.definitions.map((d) => d.function.name),
|
|
69
69
|
emit,
|
|
70
70
|
});
|
|
71
|
-
const { definitions, execute } = combineToolset({
|
|
71
|
+
const { definitions, execute } = combineToolset({
|
|
72
|
+
local,
|
|
73
|
+
admin,
|
|
74
|
+
tasks,
|
|
75
|
+
plugins: exposeAdmin ? ctx.plugins : null,
|
|
76
|
+
exposeAdmin,
|
|
77
|
+
approve: async () => true,
|
|
78
|
+
});
|
|
72
79
|
const { provider, config } = resolveProviderFor(ctx, providerId);
|
|
73
80
|
|
|
74
81
|
try {
|
|
@@ -20,6 +20,8 @@ export function registerAdvisor(route, ctx) {
|
|
|
20
20
|
if ('explain' in body) next.explain = body.explain !== false;
|
|
21
21
|
if ('advice' in body) next.advice = body.advice !== false;
|
|
22
22
|
if ('notes' in body) next.notes = String(body.notes ?? '').slice(0, 2000);
|
|
23
|
+
if ('provider' in body) next.provider = String(body.provider ?? '').trim().slice(0, 60);
|
|
24
|
+
if ('model' in body) next.model = String(body.model ?? '').trim().slice(0, 120);
|
|
23
25
|
if ('maxWords' in body) {
|
|
24
26
|
const value = Number(body.maxWords);
|
|
25
27
|
next.maxWords = Number.isFinite(value)
|
|
@@ -34,8 +36,8 @@ export function registerAdvisor(route, ctx) {
|
|
|
34
36
|
|
|
35
37
|
route('POST', '/api/advisor/test', async ({ res }) => {
|
|
36
38
|
const options = resolveAdvisor(ctx.app);
|
|
37
|
-
const providerId = ctx.effectiveProviderId();
|
|
38
|
-
const model = ctx.effectiveModel();
|
|
39
|
+
const providerId = options.provider || ctx.effectiveProviderId();
|
|
40
|
+
const model = options.model || ctx.effectiveModel();
|
|
39
41
|
if (!model) return sendError(res, 400, 'Elegí un proveedor y un modelo primero.');
|
|
40
42
|
const { provider, config } = ctx.resolveProvider({
|
|
41
43
|
...ctx.providerSettings(providerId),
|
|
@@ -221,11 +221,18 @@ export function registerChat(route, ctx) {
|
|
|
221
221
|
permissions: ctx.config.permissions,
|
|
222
222
|
mcp,
|
|
223
223
|
signal: ctx.runs.signal(sessionId),
|
|
224
|
-
exclude: admin.definitions.map((d) => d.function.name),
|
|
224
|
+
exclude: [...admin.definitions.map((d) => d.function.name), ...(ctx.plugins?.list?.().map((t) => t.name) ?? [])],
|
|
225
225
|
emit: emitAgent,
|
|
226
226
|
});
|
|
227
227
|
const approve = autoApprove ? async () => true : (name, args) => onAsk(name, args);
|
|
228
|
-
const { definitions, execute } = combineToolset({
|
|
228
|
+
const { definitions, execute } = combineToolset({
|
|
229
|
+
local,
|
|
230
|
+
admin,
|
|
231
|
+
tasks,
|
|
232
|
+
plugins: exposeAdmin ? ctx.plugins : null,
|
|
233
|
+
exposeAdmin,
|
|
234
|
+
approve,
|
|
235
|
+
});
|
|
229
236
|
|
|
230
237
|
const { provider, config } = resolveProviderFor(ctx, providerId, {
|
|
231
238
|
baseUrl: body.baseUrl || ctx.providerSettings(providerId).baseUrl,
|
|
@@ -261,6 +268,7 @@ export function registerChat(route, ctx) {
|
|
|
261
268
|
const onLifecycle = combineLifecycle([
|
|
262
269
|
createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }),
|
|
263
270
|
createCheckpointer(ctx, { sessionId, cwd: sessionCwd, turnId: userMessageId }),
|
|
271
|
+
ctx.hooks ? (event) => ctx.hooks.dispatch(event) : null,
|
|
264
272
|
]);
|
|
265
273
|
|
|
266
274
|
(async () => {
|
|
@@ -356,10 +364,20 @@ export function registerChat(route, ctx) {
|
|
|
356
364
|
advisorOptions.enabled &&
|
|
357
365
|
advisorShouldRun({ toolCalls, answer: result.content })
|
|
358
366
|
) {
|
|
367
|
+
// El asesor puede tener proveedor/modelo propios; si no, usa los activos.
|
|
368
|
+
let advisorProvider = provider;
|
|
369
|
+
let advisorConfig = config;
|
|
370
|
+
let advisorModel = model;
|
|
371
|
+
if (advisorOptions.provider || advisorOptions.model) {
|
|
372
|
+
const resolved = resolveProviderFor(ctx, advisorOptions.provider || providerId);
|
|
373
|
+
advisorProvider = resolved.provider;
|
|
374
|
+
advisorConfig = resolved.config;
|
|
375
|
+
advisorModel = advisorOptions.model || model;
|
|
376
|
+
}
|
|
359
377
|
const advice = await consultAdvisor({
|
|
360
|
-
provider,
|
|
361
|
-
config,
|
|
362
|
-
model,
|
|
378
|
+
provider: advisorProvider,
|
|
379
|
+
config: advisorConfig,
|
|
380
|
+
model: advisorModel,
|
|
363
381
|
signal: ctx.runs.signal(sessionId),
|
|
364
382
|
userInput: input,
|
|
365
383
|
answer: result.content,
|
|
@@ -374,6 +392,7 @@ export function registerChat(route, ctx) {
|
|
|
374
392
|
mode: advice.kind,
|
|
375
393
|
});
|
|
376
394
|
ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
|
|
395
|
+
ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.text, kind: advice.kind });
|
|
377
396
|
}
|
|
378
397
|
}
|
|
379
398
|
} catch (error) {
|
|
@@ -53,6 +53,8 @@ import { registerPresence } from './routes/presence.js';
|
|
|
53
53
|
import { registerEval } from './routes/eval.js';
|
|
54
54
|
import { registerSchedules } from './routes/schedules.js';
|
|
55
55
|
import { createScheduler } from './lib/scheduler.js';
|
|
56
|
+
import { createHookRunner } from './lib/hooks.js';
|
|
57
|
+
import { createPlugins } from './lib/plugins.js';
|
|
56
58
|
|
|
57
59
|
const VERSION = readPackageVersion();
|
|
58
60
|
|
|
@@ -217,6 +219,12 @@ export async function createTamnoraServer({
|
|
|
217
219
|
ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
|
|
218
220
|
ctx.agentTools = createAgentTools(ctx);
|
|
219
221
|
|
|
222
|
+
// Hooks locales (opt-in: solo si hay archivos). Observan el ciclo de vida.
|
|
223
|
+
ctx.hooks = await createHookRunner(ctx, config.hooks ?? []);
|
|
224
|
+
|
|
225
|
+
// Plugins locales: tools propias del usuario (`.tamnora/plugins/*.mjs`).
|
|
226
|
+
ctx.plugins = await createPlugins(ctx, config.plugins ?? []);
|
|
227
|
+
|
|
220
228
|
// Canales de mensajeria (Telegram via long polling) si hay token.
|
|
221
229
|
const channels = createChannelsManager(ctx, {
|
|
222
230
|
enabled: telegram,
|
|
@@ -270,6 +278,14 @@ export async function createTamnoraServer({
|
|
|
270
278
|
registerEval(route, ctx);
|
|
271
279
|
registerSchedules(route, ctx);
|
|
272
280
|
|
|
281
|
+
route('GET', '/api/hooks', ({ res }) => {
|
|
282
|
+
sendJson(res, 200, { hooks: ctx.hooks?.list?.() ?? [] });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
route('GET', '/api/plugins', ({ res }) => {
|
|
286
|
+
sendJson(res, 200, { plugins: ctx.plugins?.list?.() ?? [] });
|
|
287
|
+
});
|
|
288
|
+
|
|
273
289
|
function matchRoute(method, pathname) {
|
|
274
290
|
const parts = pathname.split('/').filter(Boolean);
|
|
275
291
|
for (const r of routes) {
|
|
@@ -1356,6 +1356,72 @@ test('rewind restores files and truncates the turn', async () => {
|
|
|
1356
1356
|
}
|
|
1357
1357
|
});
|
|
1358
1358
|
|
|
1359
|
+
test('hooks observe tool events and plugins add custom tools', async () => {
|
|
1360
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-plugins-'));
|
|
1361
|
+
await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
|
|
1362
|
+
await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
|
|
1363
|
+
await writeFile(
|
|
1364
|
+
path.join(cwd, '.tamnora', 'hooks', 'audit.mjs'),
|
|
1365
|
+
"import { appendFileSync } from 'node:fs';\nimport path from 'node:path';\n" +
|
|
1366
|
+
"export default { name: 'audit', events: { 'tool.after': (event, ctx) => { appendFileSync(path.join(ctx.cwd, 'hook.log'), `${event.type}:${event.name}\\n`); } } };\n"
|
|
1367
|
+
);
|
|
1368
|
+
await writeFile(
|
|
1369
|
+
path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
|
|
1370
|
+
"export default { name: 'demo', tools: [{ name: 'saludar', description: 'saluda', parameters: { quien: { type: 'string' } }, handler: (args) => `Hola ${args.quien}` }] };\n"
|
|
1371
|
+
);
|
|
1372
|
+
await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ permissions: { plugin__demo__saludar: 'allow' } }));
|
|
1373
|
+
|
|
1374
|
+
let captured = null;
|
|
1375
|
+
const provider = {
|
|
1376
|
+
id: 'hp',
|
|
1377
|
+
async streamChat(_config, { messages }) {
|
|
1378
|
+
const toolMsg = messages.find((m) => m.role === 'tool' && String(m.name).startsWith('plugin__'));
|
|
1379
|
+
if (toolMsg) {
|
|
1380
|
+
captured = toolMsg.content;
|
|
1381
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
1382
|
+
}
|
|
1383
|
+
return {
|
|
1384
|
+
content: '',
|
|
1385
|
+
reasoning: '',
|
|
1386
|
+
toolCalls: [
|
|
1387
|
+
{
|
|
1388
|
+
id: 't1',
|
|
1389
|
+
type: 'function',
|
|
1390
|
+
function: { name: 'plugin__demo__saludar', arguments: JSON.stringify({ quien: 'mundo' }) },
|
|
1391
|
+
},
|
|
1392
|
+
],
|
|
1393
|
+
};
|
|
1394
|
+
},
|
|
1395
|
+
};
|
|
1396
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
1397
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1398
|
+
try {
|
|
1399
|
+
const hooks = await (await fetch(`${base}/api/hooks`)).json();
|
|
1400
|
+
assert.ok(hooks.hooks.some((h) => h.id === 'audit'));
|
|
1401
|
+
|
|
1402
|
+
const plugins = await (await fetch(`${base}/api/plugins`)).json();
|
|
1403
|
+
assert.ok(plugins.plugins.some((p) => p.name === 'plugin__demo__saludar'));
|
|
1404
|
+
|
|
1405
|
+
const created = await (
|
|
1406
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
|
|
1407
|
+
).json();
|
|
1408
|
+
await (
|
|
1409
|
+
await fetch(`${base}/api/chat`, {
|
|
1410
|
+
method: 'POST',
|
|
1411
|
+
headers: json,
|
|
1412
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'saludá' }),
|
|
1413
|
+
})
|
|
1414
|
+
).text();
|
|
1415
|
+
assert.equal(captured, 'Hola mundo');
|
|
1416
|
+
|
|
1417
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1418
|
+
const log = await readFile(path.join(cwd, 'hook.log'), 'utf8').catch(() => '');
|
|
1419
|
+
assert.match(log, /tool\.after:plugin__demo__saludar/);
|
|
1420
|
+
} finally {
|
|
1421
|
+
await instance.close();
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1359
1425
|
test('skills: modo eager inyecta el body en el system prompt', async () => {
|
|
1360
1426
|
let captured = '';
|
|
1361
1427
|
const captureProvider = {
|
package/package.json
CHANGED