@danieltmn/tamnora 0.25.0 → 0.26.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 (110) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +13 -25
  3. package/SECURITY.md +3 -4
  4. package/harness/packages/cli/src/cli.js +0 -3
  5. package/harness/packages/cli/src/commands/bridge.js +0 -1
  6. package/harness/packages/cli/src/commands/repl.js +20 -4
  7. package/harness/packages/cli/src/commands/run.js +6 -2
  8. package/harness/packages/cli/src/commands/server.js +1 -1
  9. package/harness/packages/cli/src/commands/tui.js +6 -2
  10. package/harness/packages/cli/src/lib/runtime.js +2 -0
  11. package/harness/packages/cli/src/lib/util.js +2 -2
  12. package/harness/packages/cli/src/tui/app.js +24 -4
  13. package/harness/packages/cli/src/tui/layout.js +1 -1
  14. package/harness/packages/cli/test/tunnel.test.js +5 -32
  15. package/harness/packages/core/src/agent/runAgent.js +5 -2
  16. package/harness/packages/core/src/agents/index.js +113 -0
  17. package/harness/packages/core/src/config/app.js +0 -4
  18. package/harness/packages/core/src/config/home.js +1 -20
  19. package/harness/packages/core/src/config/index.js +6 -8
  20. package/harness/packages/core/src/extensions/load.js +22 -0
  21. package/harness/packages/core/src/frontmatter.js +32 -0
  22. package/harness/packages/core/src/hooks/index.js +2 -16
  23. package/harness/packages/core/src/index.js +12 -14
  24. package/harness/packages/core/src/plugins/index.js +2 -16
  25. package/harness/packages/core/src/procs/project.js +68 -0
  26. package/harness/packages/core/src/procs/static.js +17 -0
  27. package/harness/packages/core/src/providers/index.js +3 -1
  28. package/harness/packages/core/src/providers/ollama.js +4 -1
  29. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  30. package/harness/packages/core/src/providers/openrouter.js +5 -1
  31. package/harness/packages/core/src/providers/presets.js +4 -3
  32. package/harness/packages/core/src/providers/reasoning.js +58 -0
  33. package/harness/packages/core/src/rules/index.js +8 -27
  34. package/harness/packages/core/src/skills/index.js +8 -19
  35. package/harness/packages/core/src/storage/sqlite.js +595 -690
  36. package/harness/packages/core/src/tunnel.js +9 -147
  37. package/harness/packages/core/test/agents.test.js +111 -0
  38. package/harness/packages/core/test/home.test.js +2 -35
  39. package/harness/packages/core/test/procs.test.js +3 -64
  40. package/harness/packages/core/test/reasoning.test.js +43 -0
  41. package/harness/packages/core/test/storage.test.js +1 -12
  42. package/harness/packages/server/public/app.js +39 -19
  43. package/harness/packages/server/public/appearance.js +2 -56
  44. package/harness/packages/server/public/boot.js +2 -2
  45. package/harness/packages/server/public/cards.js +2 -21
  46. package/harness/packages/server/public/chat.js +1 -16
  47. package/harness/packages/server/public/chatFormat.js +0 -11
  48. package/harness/packages/server/public/chrome.js +95 -5
  49. package/harness/packages/server/public/commands.js +1 -2
  50. package/harness/packages/server/public/core.js +1 -1
  51. package/harness/packages/server/public/explorer.js +7 -10
  52. package/harness/packages/server/public/icons (1).zip +0 -0
  53. package/harness/packages/server/public/index.html +1228 -1237
  54. package/harness/packages/server/public/landing/boot.js +2 -3
  55. package/harness/packages/server/public/landing/index.html +3 -367
  56. package/harness/packages/server/public/sessions.js +1 -0
  57. package/harness/packages/server/public/settings.js +1255 -1155
  58. package/harness/packages/server/public/settingsNav.js +1 -1
  59. package/harness/packages/server/public/styles.css +2341 -2867
  60. package/harness/packages/server/public/system.js +356 -495
  61. package/harness/packages/server/public/voice.js +6 -22
  62. package/harness/packages/server/src/auth.js +4 -15
  63. package/harness/packages/server/src/cli.js +1 -1
  64. package/harness/packages/server/src/lib/agents.js +60 -0
  65. package/harness/packages/server/src/lib/appearance.js +3 -22
  66. package/harness/packages/server/src/lib/channels/telegram/commands.js +1 -1
  67. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  68. package/harness/packages/server/src/lib/definitions.js +40 -0
  69. package/harness/packages/server/src/lib/prompt.js +11 -3
  70. package/harness/packages/server/src/lib/scheduler.js +4 -0
  71. package/harness/packages/server/src/lib/sessionView.js +1 -0
  72. package/harness/packages/server/src/lib/skills.js +27 -21
  73. package/harness/packages/server/src/lib/tasks.js +71 -23
  74. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  75. package/harness/packages/server/src/lib/workspace.js +9 -11
  76. package/harness/packages/server/src/routes/agents.js +88 -0
  77. package/harness/packages/server/src/routes/chat.js +9 -44
  78. package/harness/packages/server/src/routes/preview.js +5 -22
  79. package/harness/packages/server/src/routes/procs.js +2 -4
  80. package/harness/packages/server/src/routes/providers.js +7 -0
  81. package/harness/packages/server/src/routes/sessions.js +3 -1
  82. package/harness/packages/server/src/routes/tts.js +1 -1
  83. package/harness/packages/server/src/server.js +8 -5
  84. package/harness/packages/server/test/agents.test.js +194 -0
  85. package/harness/packages/server/test/appearance-route.test.js +3 -3
  86. package/harness/packages/server/test/appearance.test.js +12 -38
  87. package/harness/packages/server/test/chatFormat.test.js +0 -8
  88. package/harness/packages/server/test/consoleTabs.test.js +2 -2
  89. package/harness/packages/server/test/ops.test.js +8 -56
  90. package/harness/packages/server/test/preview.test.js +2 -42
  91. package/harness/packages/server/test/security.test.js +194 -370
  92. package/harness/packages/server/test/settingsNav.test.js +2 -2
  93. package/harness/packages/server/test/tts.test.js +4 -4
  94. package/harness/tamnora.example.json +2 -1
  95. package/package.json +2 -2
  96. package/harness/packages/cli/src/commands/eval.js +0 -186
  97. package/harness/packages/core/src/eval/dataset.js +0 -47
  98. package/harness/packages/core/src/eval/judge.js +0 -39
  99. package/harness/packages/core/src/eval/report.js +0 -48
  100. package/harness/packages/core/src/eval/runTask.js +0 -62
  101. package/harness/packages/core/src/eval/runner.js +0 -153
  102. package/harness/packages/core/src/eval/scorers.js +0 -217
  103. package/harness/packages/core/src/eval/task.js +0 -59
  104. package/harness/packages/core/src/procs/detect.js +0 -149
  105. package/harness/packages/core/test/eval.test.js +0 -144
  106. package/harness/packages/server/src/lib/advisor.js +0 -198
  107. package/harness/packages/server/src/routes/advisor.js +0 -79
  108. package/harness/packages/server/src/routes/eval.js +0 -100
  109. package/harness/packages/server/test/advisor.test.js +0 -249
  110. package/harness/scripts/example-dataset.jsonl +0 -4
@@ -0,0 +1,113 @@
1
+ // Subagentes: definiciones en markdown con frontmatter (`<casa>/agents/<id>/AGENT.md`
2
+ // o `<cwd>/.tamnora/agents/<id>.md`). El agente principal los ve como un índice
3
+ // en el system prompt y delega en ellos con `task({ agent })`.
4
+
5
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { mkdir, writeFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { parseFrontmatter, parseListValue } from '../frontmatter.js';
9
+
10
+ export const AGENT_FILE = 'AGENT.md';
11
+
12
+ /** Parse an AGENT.md: frontmatter `--- key: value ---` plus the body prompt. */
13
+ export function parseAgentMarkdown(text, fallbackId = '') {
14
+ const { fields, body } = parseFrontmatter(text);
15
+ return {
16
+ id: fallbackId,
17
+ name: fields.name || fallbackId,
18
+ description: fields.description || '',
19
+ model: fields.model || '',
20
+ provider: fields.provider || '',
21
+ mode: fields.mode || '',
22
+ effort: fields.effort || fields.reasoning || fields.reasoning_effort || '',
23
+ maxRounds: Number(fields.maxrounds || fields.max_rounds) || 0,
24
+ tools: fields.tools === undefined ? null : parseListValue(fields.tools),
25
+ skills: fields.skills === undefined ? null : parseListValue(fields.skills),
26
+ body,
27
+ };
28
+ }
29
+
30
+ /** List agents under a root (missing root -> []). Directory form wins. */
31
+ export function loadAgents(root) {
32
+ const agents = [];
33
+ let entries = [];
34
+ try {
35
+ entries = readdirSync(root, { withFileTypes: true });
36
+ } catch {
37
+ return agents;
38
+ }
39
+ for (const entry of entries) {
40
+ const full = path.join(root, entry.name);
41
+ const file = entry.isDirectory() ? path.join(full, AGENT_FILE) : entry.name.toLowerCase().endsWith('.md') ? full : null;
42
+ if (!file) continue;
43
+ try {
44
+ if (!statSync(file).isFile()) continue;
45
+ const id = entry.isDirectory() ? entry.name : entry.name.replace(/\.md$/i, '');
46
+ const parsed = parseAgentMarkdown(readFileSync(file, 'utf8'), id);
47
+ agents.push({ ...parsed, id, path: file });
48
+ } catch {
49
+ // ignore unreadable agents
50
+ }
51
+ }
52
+ return agents.sort((a, b) => a.id.localeCompare(b.id, 'es'));
53
+ }
54
+
55
+ /** Merge several roots; later roots win on id collisions (workspace over casa). */
56
+ export function mergeAgents(rootLists = []) {
57
+ const byId = new Map();
58
+ for (const list of rootLists) for (const agent of list) byId.set(agent.id, agent);
59
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id, 'es'));
60
+ }
61
+
62
+ /**
63
+ * Índice compacto para el system prompt: qué subagentes hay y cómo delegar.
64
+ * Sin agentes devuelve '' (no ensucia el contexto).
65
+ */
66
+ export function buildAgentsIndex(agents = []) {
67
+ if (!agents.length) return '';
68
+ const lines = [
69
+ 'Tenés subagentes disponibles. Para delegarles una subtarea, usá `task({ agent: "<id>", prompt: "..." })` con una instrucción autocontenida:',
70
+ ];
71
+ for (const agent of agents) {
72
+ const title = agent.name || agent.id;
73
+ const desc = String(agent.description || '').replace(/\s+/g, ' ').trim();
74
+ lines.push(`- ${agent.id}: ${title}${desc ? ` — ${desc}` : ''}`);
75
+ }
76
+ return lines.join('\n');
77
+ }
78
+
79
+ /** Ficha del subagente para su propio system prompt. */
80
+ export function buildSubagentPrompt(agent, { basePrompt = '' } = {}) {
81
+ const parts = [];
82
+ if (basePrompt) parts.push(basePrompt);
83
+ parts.push(
84
+ [
85
+ `Sos el subagente "${agent.name || agent.id}" de Tamnora.`,
86
+ agent.description ? `Rol: ${agent.description}` : '',
87
+ 'Ejecutá de forma autónoma la tarea encomendada con las herramientas disponibles y devolvé un resumen conciso y concreto de lo que encontraste o hiciste (archivos, hallazgos, decisiones). No pidas confirmaciones: si algo no es posible, explicalo en el resumen.',
88
+ agent.body ? `\nInstrucciones del subagente:\n${agent.body}` : '',
89
+ ]
90
+ .filter(Boolean)
91
+ .join('\n')
92
+ );
93
+ return parts.join('\n\n---\n\n');
94
+ }
95
+
96
+ /** Escribe el AGENT.md de un subagente (con guarda de traversal en el id). */
97
+ export async function writeAgentFile(dir, agent) {
98
+ const id = String(agent.id ?? '').trim();
99
+ if (!id || !/^[a-z0-9][a-z0-9_-]{0,39}$/i.test(id)) throw new Error('Id de subagente inválido.');
100
+ const lines = ['---', `name: ${agent.name || id}`];
101
+ if (agent.description) lines.push(`description: ${agent.description}`);
102
+ if (agent.model) lines.push(`model: ${agent.model}`);
103
+ if (agent.provider) lines.push(`provider: ${agent.provider}`);
104
+ if (agent.mode) lines.push(`mode: ${agent.mode}`);
105
+ if (agent.effort) lines.push(`effort: ${agent.effort}`);
106
+ if (agent.maxRounds > 0) lines.push(`maxRounds: ${Math.round(agent.maxRounds)}`);
107
+ if (Array.isArray(agent.tools)) lines.push(`tools: ${agent.tools.join(', ')}`);
108
+ if (Array.isArray(agent.skills)) lines.push(`skills: ${agent.skills.join(', ')}`);
109
+ lines.push('---', '', String(agent.body ?? '').trim(), '');
110
+ await mkdir(dir, { recursive: true });
111
+ await writeFile(path.join(dir, AGENT_FILE), lines.join('\n'), 'utf8');
112
+ return path.join(dir, AGENT_FILE);
113
+ }
@@ -29,10 +29,6 @@ export function readApp() {
29
29
  }
30
30
  const app = { ...DEFAULT_APP, ...(data && typeof data === 'object' ? data : {}) };
31
31
  app.tunnel = { ...DEFAULT_APP.tunnel, ...(app.tunnel || {}) };
32
- // `tunnelmole` fue el default viejo y `relay` fue un proveedor retirado: lo
33
- // persistido en instalaciones previas se migra a tailscale.
34
- const legacy = ['tunnelmole', 'tmole', 'relay'];
35
- if (legacy.includes(app.tunnel.provider)) app.tunnel.provider = 'tailscale';
36
32
  return app;
37
33
  }
38
34
 
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
@@ -82,22 +82,3 @@ export function ensureHome() {
82
82
  export function homeExists() {
83
83
  return existsSync(appConfigPath());
84
84
  }
85
-
86
- /**
87
- * Migra una base local legacy (`<cwd>/.tamnora/tamnora.db`) a la casa global,
88
- * una sola vez. Devuelve la ruta del archivo global.
89
- */
90
- export function migrateLegacyDb(cwd = process.cwd()) {
91
- const target = databaseFile();
92
- if (existsSync(target)) return target;
93
- const legacy = path.join(cwd, DIR_NAME, 'tamnora.db');
94
- if (existsSync(legacy)) {
95
- try {
96
- ensureHome();
97
- copyFileSync(legacy, target);
98
- } catch {
99
- /* si falla, se arranca con una base nueva */
100
- }
101
- }
102
- return target;
103
- }
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { migrateLegacyDb } from './home.js';
4
+ import { databaseFile } from './home.js';
5
5
 
6
6
  export const DEFAULT_CONFIG = {
7
7
  provider: 'openrouter',
@@ -12,6 +12,7 @@ export const DEFAULT_CONFIG = {
12
12
  maxDurationMs: 0,
13
13
  maxOutputTokens: 0,
14
14
  promptCaching: false,
15
+ effort: '',
15
16
  skills: { mode: 'lazy' },
16
17
  compact: { enabled: true, threshold: 0.75, keepLast: 8 },
17
18
  rules: ['.tamnora/rules/*.md'],
@@ -25,21 +26,18 @@ export const DEFAULT_CONFIG = {
25
26
  mcpPinned: [],
26
27
  server: { host: '127.0.0.1', port: 4517 },
27
28
  processes: { enabled: true, allow: [] },
28
- preview: { provider: 'tailscale', tailscalePort: 8443, fallback: true },
29
+ preview: { provider: 'tailscale', tailscalePort: 8443 },
29
30
  telegram: { token: '', allowedChatIds: [] },
30
31
  agents: { maxRounds: 6 },
31
32
  };
32
33
 
33
- /** Directory (relative to the project) where harness state lived historically. */
34
- export const STATE_DIR = '.tamnora';
35
-
36
34
  /**
37
35
  * Absolute path to the SQLite database. Es **global** (casa de Tamnora) para
38
36
  * que proveedores y claves no se dupliquen entre proyectos. `TAMNORA_DB` lo
39
- * sobrescribe; si no existe la base global se migra la legacy del `cwd`.
37
+ * sobrescribe.
40
38
  */
41
- export function databasePath(cwd = process.cwd()) {
42
- return process.env.TAMNORA_DB || migrateLegacyDb(cwd);
39
+ export function databasePath() {
40
+ return process.env.TAMNORA_DB || databaseFile();
43
41
  }
44
42
 
45
43
  /**
@@ -0,0 +1,22 @@
1
+ // Carga de modulos de extension locales (hooks/plugins): solo los patrones
2
+ // explicitos, deduplicados; un modulo roto se ignora.
3
+
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { globFiles } from '../tools/glob.js';
7
+
8
+ export async function loadExtensionModules(cwd, patterns, normalize) {
9
+ const list = [...new Set(patterns ?? [])];
10
+ if (!list.length) return [];
11
+ const items = [];
12
+ for (const rel of await globFiles(cwd, list)) {
13
+ try {
14
+ const mod = await import(pathToFileURL(path.join(cwd, rel)).href);
15
+ const item = normalize(mod, rel);
16
+ if (item) items.push({ ...item, path: rel });
17
+ } catch {
18
+ /* modulo roto: se ignora */
19
+ }
20
+ }
21
+ return items;
22
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Frontmatter simple compartido por skills, subagentes y reglas: un bloque
3
+ * `--- clave: valor ---` (una clave por linea) y el resto del documento.
4
+ */
5
+
6
+ const FRONTMATTER = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/;
7
+
8
+ export function parseFrontmatter(text) {
9
+ const source = String(text ?? '');
10
+ const fields = {};
11
+ let body = source.trim();
12
+ const match = source.match(FRONTMATTER);
13
+ if (!match) return { fields, body };
14
+ body = match[2].trim();
15
+ for (const line of match[1].split(/\r?\n/)) {
16
+ const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
17
+ if (!kv) continue;
18
+ fields[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, '');
19
+ }
20
+ return { fields, body };
21
+ }
22
+
23
+ /** Lista por comas: `tools: read, grep` o `paths: [a, b]`. */
24
+ export function parseListValue(value) {
25
+ const raw = String(value ?? '').trim();
26
+ if (!raw) return [];
27
+ const inner = raw.startsWith('[') && raw.endsWith(']') ? raw.slice(1, -1) : raw;
28
+ return inner
29
+ .split(',')
30
+ .map((item) => item.trim().replace(/^["']|["']$/g, ''))
31
+ .filter(Boolean);
32
+ }
@@ -5,9 +5,7 @@
5
5
  // `{ name?, events: { '<tipo>': fn } }` o una funcion (se llama para todos los
6
6
  // eventos).
7
7
 
8
- import path from 'node:path';
9
- import { pathToFileURL } from 'node:url';
10
- import { globFiles } from '../tools/glob.js';
8
+ import { loadExtensionModules } from '../extensions/load.js';
11
9
 
12
10
  export const DEFAULT_HOOK_PATTERNS = ['.tamnora/hooks/*.mjs'];
13
11
 
@@ -30,17 +28,5 @@ export function normalizeHookModule(mod, id = '') {
30
28
  * Un modulo roto se ignora.
31
29
  */
32
30
  export async function loadHooks(cwd, patterns = []) {
33
- const list = [...new Set(patterns ?? [])];
34
- if (!list.length) return [];
35
- const hooks = [];
36
- for (const rel of await globFiles(cwd, list)) {
37
- try {
38
- const mod = await import(pathToFileURL(path.join(cwd, rel)).href);
39
- const hook = normalizeHookModule(mod, rel);
40
- if (hook) hooks.push({ ...hook, path: rel });
41
- } catch {
42
- /* modulo roto: se ignora */
43
- }
44
- }
45
- return hooks;
31
+ return loadExtensionModules(cwd, patterns, normalizeHookModule);
46
32
  }
@@ -32,6 +32,7 @@ export {
32
32
  testProviderConnection,
33
33
  streamChat,
34
34
  } from './providers/index.js';
35
+ export { EFFORT_LEVELS, EFFORT_LABELS, normalizeEffort, applyReasoningEffort } from './providers/reasoning.js';
35
36
  export {
36
37
  connectMcpServer,
37
38
  connectMcpHttpServer,
@@ -49,7 +50,6 @@ export {
49
50
  resolveModel,
50
51
  resolveProvider,
51
52
  databasePath,
52
- STATE_DIR,
53
53
  DEFAULT_CONFIG,
54
54
  } from './config/index.js';
55
55
  export {
@@ -68,7 +68,6 @@ export {
68
68
  bridgeStatePath,
69
69
  ensureHome,
70
70
  homeExists,
71
- migrateLegacyDb,
72
71
  } from './config/home.js';
73
72
  export { DEFAULT_APP, readApp, writeApp, ensureApp, randomToken } from './config/app.js';
74
73
  export { exportConfig, importConfig, CONFIG_KIND, CONFIG_VERSION, MASKED } from './config/portable.js';
@@ -93,13 +92,6 @@ export {
93
92
  planMcpTools,
94
93
  } from './tools/index.js';
95
94
  export { Store, openDatabase } from './storage/sqlite.js';
96
- export { runTask } from './eval/runTask.js';
97
- export { loadDataset } from './eval/dataset.js';
98
- export { runDataset, extractToolCalls } from './eval/runner.js';
99
- export { summarize } from './eval/report.js';
100
- export { runScorers, validateSchema, extractJson, SCORERS } from './eval/scorers.js';
101
- export { createJudge } from './eval/judge.js';
102
- export { createTaskWorkspace, normalizeExpect } from './eval/task.js';
103
95
  export {
104
96
  parseSkillMarkdown,
105
97
  loadSkills,
@@ -108,6 +100,15 @@ export {
108
100
  buildSkillsPrompt,
109
101
  buildSkillsIndex,
110
102
  } from './skills/index.js';
103
+ export {
104
+ AGENT_FILE,
105
+ parseAgentMarkdown,
106
+ loadAgents,
107
+ mergeAgents,
108
+ buildAgentsIndex,
109
+ buildSubagentPrompt,
110
+ writeAgentFile,
111
+ } from './agents/index.js';
111
112
  export { parseSkillRef, fetchSkillFromGithub, searchSkillsGithub } from './skills/remote.js';
112
113
  export { SKILLS_CATALOG, skillCatalogEntry } from './skills/catalog.js';
113
114
  export {
@@ -148,17 +149,13 @@ export {
148
149
  } from './mcp/registry.js';
149
150
  export { MCP_CATALOG, mcpCatalogEntry } from './mcp/catalog.js';
150
151
  export {
151
- parseTunnelmoleUrls,
152
152
  parseTailscaleStatus,
153
153
  probeTailscale,
154
154
  stopTailscaleServe,
155
155
  startTailscaleServe,
156
156
  startTailscalePreview,
157
- startTunnelmole,
158
157
  startTunnel,
159
158
  tailscalePreviewUrl,
160
- tunnelState,
161
- tunnelActive,
162
159
  } from './tunnel.js';
163
160
  export {
164
161
  TRUSTED_CLIENT_TOKEN,
@@ -174,7 +171,8 @@ export {
174
171
  export { reconocerGoogle, wavToPcm } from './stt/google.js';
175
172
  export { pidAlive, killPidTree } from './pid.js';
176
173
  export { openBrowser, openerFor } from './open.js';
177
- export { detectProcs, loadProjectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip, PROCS_FILE } from './procs/detect.js';
174
+ export { loadProjectProcs, DEFAULT_PROCESS_ALLOW, PROCS_FILE } from './procs/project.js';
175
+ export { staticServerChip } from './procs/static.js';
178
176
  export { killPort, portInfo, pidsOnPort } from './procs/kill.js';
179
177
  export {
180
178
  ROOT_PACKAGE_NAME,
@@ -4,9 +4,7 @@
4
4
  // presencia de archivos en el cwd no ejecuta nada. Cada tool:
5
5
  // `{ name, description, parameters?, required?, handler(args, ctx) }`.
6
6
 
7
- import path from 'node:path';
8
- import { pathToFileURL } from 'node:url';
9
- import { globFiles } from '../tools/glob.js';
7
+ import { loadExtensionModules } from '../extensions/load.js';
10
8
 
11
9
  export const DEFAULT_PLUGIN_PATTERNS = ['.tamnora/plugins/*.mjs'];
12
10
 
@@ -22,17 +20,5 @@ export function normalizePlugin(mod, id = '') {
22
20
 
23
21
  /** Carga los plugins de los `patterns` explicitos (relativos a `cwd`). */
24
22
  export async function loadPlugins(cwd, patterns = []) {
25
- const list = [...new Set(patterns ?? [])];
26
- if (!list.length) return [];
27
- const plugins = [];
28
- for (const rel of await globFiles(cwd, list)) {
29
- try {
30
- const mod = await import(pathToFileURL(path.join(cwd, rel)).href);
31
- const plugin = normalizePlugin(mod, rel);
32
- if (plugin) plugins.push({ ...plugin, path: rel });
33
- } catch {
34
- /* plugin roto: se ignora */
35
- }
36
- }
37
- return plugins;
23
+ return loadExtensionModules(cwd, patterns, normalizePlugin);
38
24
  }
@@ -0,0 +1,68 @@
1
+ // Procesos de desarrollo declarados por el proyecto en `.tamnora/procs.json`.
2
+ // No se autodetecta nada: el usuario declara como arrancar cada servidor.
3
+
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ export const DEFAULT_PROCESS_ALLOW = [
8
+ 'npm',
9
+ 'node',
10
+ 'npx',
11
+ 'pnpm',
12
+ 'yarn',
13
+ 'bun',
14
+ 'bunx',
15
+ 'php',
16
+ 'python',
17
+ 'python3',
18
+ 'composer',
19
+ ];
20
+
21
+ /** Archivo por proyecto (relativo a su raiz) que define sus procesos de dev. */
22
+ export const PROCS_FILE = '.tamnora/procs.json';
23
+
24
+ /** True si `target` es `root` o vive dentro de el (sin salirse por `..`). */
25
+ function isInside(root, target) {
26
+ const rel = path.relative(root, target);
27
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
28
+ }
29
+
30
+ /**
31
+ * Procesos definidos a mano por el proyecto en `<root>/.tamnora/procs.json`.
32
+ * Formato: `{ "processes": [{ label?, dir?, command, args?, port? }] }` o un array
33
+ * directo. `dir` es relativo a la raiz y debe quedar dentro de ella. Cada chip
34
+ * sigue pasando por la whitelist (`processes.allow`) al arrancar.
35
+ */
36
+ export function loadProjectProcs(root) {
37
+ const file = path.join(root, PROCS_FILE);
38
+ if (!existsSync(file)) return [];
39
+ const raw = readJson(file);
40
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.processes) ? raw.processes : [];
41
+ const chips = [];
42
+ for (const entry of list) {
43
+ if (!entry || typeof entry !== 'object') continue;
44
+ const command = String(entry.command ?? '').trim();
45
+ if (!command) continue;
46
+ const rel = String(entry.dir ?? '').trim().replace(/^[/\\]+/, '');
47
+ const cwd = path.resolve(root, rel || '.');
48
+ if (!isInside(root, cwd) || !existsSync(cwd)) continue;
49
+ const port = entry.port ? Number(entry.port) : undefined;
50
+ chips.push({
51
+ label: String(entry.label ?? command).trim() || command,
52
+ command,
53
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
54
+ cwd,
55
+ port: Number.isInteger(port) && port > 0 ? port : undefined,
56
+ source: 'project',
57
+ });
58
+ }
59
+ return chips;
60
+ }
61
+
62
+ function readJson(file) {
63
+ try {
64
+ return JSON.parse(readFileSync(file, 'utf8'));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
@@ -0,0 +1,17 @@
1
+ // Chip para servir un directorio como sitio estatico con el servidor sin
2
+ // dependencias. Lo usa la vista previa de un HTML en el explorador.
3
+
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const STATIC_SERVER = fileURLToPath(new URL('../../../../scripts/static-server.mjs', import.meta.url));
7
+ const STATIC_PORT = 8080;
8
+
9
+ export function staticServerChip(dir, { port = STATIC_PORT, label } = {}) {
10
+ return {
11
+ label: label ?? `sitio estático (${port})`,
12
+ command: process.execPath,
13
+ args: [STATIC_SERVER, dir, String(port)],
14
+ cwd: dir,
15
+ port,
16
+ };
17
+ }
@@ -12,11 +12,12 @@ export const PROVIDERS = [
12
12
  ];
13
13
 
14
14
  /** Lightweight metadata for the settings UI. */
15
- export const PROVIDER_META = PROVIDERS.map(({ id, label, defaultBaseUrl, requiresKey }) => ({
15
+ export const PROVIDER_META = PROVIDERS.map(({ id, label, defaultBaseUrl, requiresKey, reasoning }) => ({
16
16
  id,
17
17
  label,
18
18
  defaultBaseUrl,
19
19
  requiresKey,
20
+ reasoning: reasoning ?? 'none',
20
21
  }));
21
22
 
22
23
  /**
@@ -31,6 +32,7 @@ export function createCustomProvider(def) {
31
32
  defaultBaseUrl: def.baseUrl,
32
33
  requiresKey: true,
33
34
  custom: true,
35
+ reasoning: def.reasoning ?? 'none',
34
36
  manualModels: def.models ?? [],
35
37
  };
36
38
  }
@@ -1,4 +1,5 @@
1
1
  import { readNDJSON } from './stream.js';
2
+ import { applyReasoningEffort } from './reasoning.js';
2
3
  import { joinUrl, providerError, providerFetch, sortModels } from './utils.js';
3
4
 
4
5
  const OLLAMA_BASE_URL = 'http://localhost:11434';
@@ -44,6 +45,7 @@ export const ollamaProvider = {
44
45
  label: 'Ollama (local)',
45
46
  defaultBaseUrl: OLLAMA_BASE_URL,
46
47
  requiresKey: false,
48
+ reasoning: 'think',
47
49
 
48
50
  async listModels({ baseUrl }) {
49
51
  const res = await providerFetch(joinUrl(baseUrl, '/api/tags'));
@@ -52,10 +54,11 @@ export const ollamaProvider = {
52
54
  return sortModels((data.models ?? []).map(toOllamaModel).filter((m) => m.id));
53
55
  },
54
56
 
55
- async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent, maxOutputTokens }) {
57
+ async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent, maxOutputTokens, reasoningEffort }) {
56
58
  const body = { model, messages: toOllamaMessages(messages), stream: true };
57
59
  if (tools?.length) body.tools = tools;
58
60
  if (maxOutputTokens > 0) body.options = { ...(body.options ?? {}), num_predict: maxOutputTokens };
61
+ applyReasoningEffort(body, reasoningEffort, this);
59
62
 
60
63
  const res = await providerFetch(joinUrl(baseUrl, '/api/chat'), {
61
64
  method: 'POST',
@@ -1,5 +1,6 @@
1
1
  import { readOpenAISSE } from './stream.js';
2
2
  import { applyPromptCache } from './cache.js';
3
+ import { applyReasoningEffort } from './reasoning.js';
3
4
  import { joinUrl, authHeaders, providerError, providerFetch, normalizeModel, sortModels } from './utils.js';
4
5
 
5
6
  /**
@@ -11,6 +12,9 @@ export const openaiCompatibleProvider = {
11
12
  label: 'OpenAI-compatible',
12
13
  defaultBaseUrl: 'https://api.openai.com/v1',
13
14
  requiresKey: true,
15
+ // El adaptador generico no sabe si el servidor entiende `reasoning_effort`;
16
+ // los presets que si lo soportan lo declaran (`reasoning: 'effort'`).
17
+ reasoning: 'none',
14
18
 
15
19
  async listModels({ baseUrl, apiKey, headers }) {
16
20
  const res = await providerFetch(joinUrl(baseUrl, '/models'), {
@@ -22,7 +26,7 @@ export const openaiCompatibleProvider = {
22
26
  return sortModels(list.map(normalizeModel).filter((m) => m.id));
23
27
  },
24
28
 
25
- async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
29
+ async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching, reasoningEffort }) {
26
30
  const buildBody = () => {
27
31
  const body = { model, messages, stream: true };
28
32
  if (tools?.length) body.tools = tools;
@@ -32,6 +36,7 @@ export const openaiCompatibleProvider = {
32
36
  body[field] = maxOutputTokens;
33
37
  }
34
38
  if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
39
+ applyReasoningEffort(body, reasoningEffort, this);
35
40
  return body;
36
41
  };
37
42
  const run = (body) =>
@@ -1,5 +1,6 @@
1
1
  import { requestStream } from './openaiCompatible.js';
2
2
  import { applyPromptCache } from './cache.js';
3
+ import { applyReasoningEffort } from './reasoning.js';
3
4
  import { joinUrl, providerFetch, normalizeModel, sortModels } from './utils.js';
4
5
 
5
6
  const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
@@ -26,6 +27,8 @@ export const openrouterProvider = {
26
27
  requiresKey: true,
27
28
  // OpenRouter pasa cache_control a Anthropic; en otros modelos queda sin efecto.
28
29
  caching: 'anthropic',
30
+ // OpenRouter acepta el enum completo (max/xhigh/high/medium/low/minimal/none).
31
+ reasoning: 'effort',
29
32
 
30
33
  async listModels({ apiKey, siteUrl, siteName }) {
31
34
  const res = await providerFetch(joinUrl(OPENROUTER_BASE_URL, '/models'), {
@@ -41,11 +44,12 @@ export const openrouterProvider = {
41
44
  ).sort((a, b) => Number(b.free) - Number(a.free));
42
45
  },
43
46
 
44
- async streamChat(ctx, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
47
+ async streamChat(ctx, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching, reasoningEffort }) {
45
48
  const body = { model, messages, stream: true };
46
49
  if (tools?.length) body.tools = tools;
47
50
  if (maxOutputTokens > 0) body.max_tokens = maxOutputTokens;
48
51
  if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
52
+ applyReasoningEffort(body, reasoningEffort, this);
49
53
 
50
54
  return requestStream({
51
55
  url: joinUrl(OPENROUTER_BASE_URL, '/chat/completions'),
@@ -7,18 +7,19 @@ function preset(id, label, baseUrl, extra = {}) {
7
7
  }
8
8
 
9
9
  export const OPENAI_COMPATIBLE_PRESETS = [
10
- preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1'),
10
+ preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1', { reasoning: 'deepseek' }),
11
11
  preset('openai', 'OpenAI', 'https://api.openai.com/v1', {
12
12
  maxTokensField: 'max_completion_tokens',
13
13
  caching: 'openai',
14
+ reasoning: 'openai',
14
15
  }),
15
16
  // Anthropic y Gemini exponen una capa compatible con OpenAI (chat/completions
16
17
  // + tools) que usa el mismo Bearer; sirven con este adaptador sin cambios.
17
18
  preset('anthropic', 'Anthropic (Claude)', 'https://api.anthropic.com/v1', { caching: 'anthropic' }),
18
- preset('gemini', 'Google Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai'),
19
+ preset('gemini', 'Google Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai', { reasoning: 'effort' }),
19
20
  preset('groq', 'Groq', 'https://api.groq.com/openai/v1'),
20
21
  preset('mistral', 'Mistral AI', 'https://api.mistral.ai/v1'),
21
- preset('xai', 'xAI (Grok)', 'https://api.x.ai/v1'),
22
+ preset('xai', 'xAI (Grok)', 'https://api.x.ai/v1', { reasoning: 'effort' }),
22
23
  preset('together', 'Together AI', 'https://api.together.xyz/v1'),
23
24
  preset('fireworks', 'Fireworks AI', 'https://api.fireworks.ai/inference/v1'),
24
25
  preset('cerebras', 'Cerebras', 'https://api.cerebras.ai/v1'),