@danieltmn/tamnora 0.11.3 → 0.13.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 +39 -0
- package/README.md +7 -1
- package/harness/packages/cli/src/commands/repl.js +18 -5
- package/harness/packages/cli/src/commands/run.js +11 -2
- package/harness/packages/cli/src/commands/tui.js +13 -3
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +22 -0
- package/harness/packages/core/src/agent/compact.js +38 -0
- package/harness/packages/core/src/agent/context.js +13 -8
- package/harness/packages/core/src/agent/runAgent.js +35 -4
- package/harness/packages/core/src/agent/scope.js +15 -0
- package/harness/packages/core/src/config/index.js +6 -0
- package/harness/packages/core/src/index.js +36 -1
- package/harness/packages/core/src/memory/index.js +220 -0
- package/harness/packages/core/src/providers/cache.js +29 -0
- package/harness/packages/core/src/providers/ollama.js +3 -2
- package/harness/packages/core/src/providers/openaiCompatible.js +9 -2
- package/harness/packages/core/src/providers/openrouter.js +6 -1
- package/harness/packages/core/src/providers/presets.js +7 -4
- package/harness/packages/core/src/providers/stream.js +45 -4
- package/harness/packages/core/src/rules/index.js +151 -0
- package/harness/packages/core/src/skills/index.js +21 -0
- package/harness/packages/core/src/tools/executors.js +21 -13
- package/harness/packages/core/src/tools/index.js +2 -2
- package/harness/packages/core/src/tools/registry.js +3 -2
- package/harness/packages/core/test/agent.test.js +38 -0
- package/harness/packages/core/test/compact.test.js +33 -0
- package/harness/packages/core/test/context.test.js +14 -0
- package/harness/packages/core/test/memory.test.js +96 -0
- package/harness/packages/core/test/providers.test.js +66 -0
- package/harness/packages/core/test/rules.test.js +64 -0
- package/harness/packages/core/test/skills.test.js +10 -1
- package/harness/packages/core/test/stream.test.js +20 -0
- package/harness/packages/core/test/tools.test.js +18 -0
- package/harness/packages/server/public/app.js +116 -5
- package/harness/packages/server/public/index.html +49 -1
- package/harness/packages/server/src/lib/agentRun.js +8 -8
- package/harness/packages/server/src/lib/agentTools.js +78 -8
- package/harness/packages/server/src/lib/approvals.js +7 -0
- package/harness/packages/server/src/lib/channels/telegram.js +12 -1
- package/harness/packages/server/src/lib/compact.js +77 -0
- package/harness/packages/server/src/lib/prompt.js +17 -3
- package/harness/packages/server/src/lib/rules.js +43 -0
- package/harness/packages/server/src/lib/skills.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +2 -1
- package/harness/packages/server/src/routes/chat.js +24 -2
- package/harness/packages/server/src/routes/memory.js +66 -0
- package/harness/packages/server/src/routes/providers.js +4 -1
- package/harness/packages/server/src/routes/sessions.js +4 -50
- package/harness/packages/server/src/server.js +12 -2
- package/harness/packages/server/test/scope.test.js +68 -0
- package/harness/packages/server/test/server.test.js +291 -49
- package/harness/tamnora.example.json +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Memoria durable por proyecto: `<cwd>/.tamnora/memory/` con un `MEMORY.md`
|
|
2
|
+
// (índice) y un archivo por tema. La escribe el agente (tool `memory`) y el
|
|
3
|
+
// usuario la edita desde la consola. Es pura: solo toca el filesystem del cwd.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const MEMORY_DIR = '.tamnora/memory';
|
|
10
|
+
export const MEMORY_INDEX_NAME = 'MEMORY.md';
|
|
11
|
+
export const MAX_INDEX_BYTES = 25 * 1024;
|
|
12
|
+
export const MAX_INDEX_LINES = 200;
|
|
13
|
+
export const MAX_TOPIC_BYTES = 64 * 1024;
|
|
14
|
+
|
|
15
|
+
/** Carpeta de memoria del proyecto. */
|
|
16
|
+
export function memoryRoot(cwd = process.cwd()) {
|
|
17
|
+
return path.join(cwd, '.tamnora', 'memory');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function memoryIndexPath(cwd) {
|
|
21
|
+
return path.join(memoryRoot(cwd), MEMORY_INDEX_NAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Slug de tema (a-z, 0-9, -). */
|
|
25
|
+
export function memorySlug(value) {
|
|
26
|
+
return String(value ?? '')
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
29
|
+
.replace(/^-+|-+$/g, '')
|
|
30
|
+
.slice(0, 60);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Un nombre de archivo de tema válido (`.md`, no el índice). */
|
|
34
|
+
export function isMemoryTopic(name) {
|
|
35
|
+
const slug = String(name ?? '');
|
|
36
|
+
if (!/^[a-z0-9][a-z0-9_-]*\.md$/i.test(slug)) return false;
|
|
37
|
+
return slug.toLowerCase() !== MEMORY_INDEX_NAME.toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clampIndex(text) {
|
|
41
|
+
const lines = String(text ?? '').split(/\r?\n/).slice(0, MAX_INDEX_LINES);
|
|
42
|
+
let out = lines.join('\n');
|
|
43
|
+
if (Buffer.byteLength(out, 'utf8') > MAX_INDEX_BYTES) out = out.slice(0, MAX_INDEX_BYTES);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function firstLine(text) {
|
|
48
|
+
return (
|
|
49
|
+
String(text ?? '')
|
|
50
|
+
.split(/\r?\n/)
|
|
51
|
+
.map((l) => l.replace(/^#+\s*/, '').trim())
|
|
52
|
+
.find((l) => l) ?? ''
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function headingOf(text) {
|
|
57
|
+
const line = String(text ?? '')
|
|
58
|
+
.split(/\r?\n/)
|
|
59
|
+
.find((l) => /^#\s/.test(l));
|
|
60
|
+
return line ? line.replace(/^#+\s*/, '').trim() : '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Índice `MEMORY.md` (acotado). '' si no existe. */
|
|
64
|
+
export function readMemoryIndex(cwd) {
|
|
65
|
+
try {
|
|
66
|
+
return clampIndex(readFileSync(memoryIndexPath(cwd), 'utf8'));
|
|
67
|
+
} catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Lista los temas (sin el índice). */
|
|
73
|
+
export function listMemoryTopics(cwd) {
|
|
74
|
+
const root = memoryRoot(cwd);
|
|
75
|
+
let entries = [];
|
|
76
|
+
try {
|
|
77
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const topics = [];
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.isFile() || !isMemoryTopic(entry.name)) continue;
|
|
84
|
+
const file = path.join(root, entry.name);
|
|
85
|
+
let bytes = 0;
|
|
86
|
+
let mtime = 0;
|
|
87
|
+
let title = entry.name.replace(/\.md$/i, '');
|
|
88
|
+
let summary = '';
|
|
89
|
+
try {
|
|
90
|
+
const st = statSync(file);
|
|
91
|
+
bytes = st.size;
|
|
92
|
+
mtime = st.mtimeMs;
|
|
93
|
+
const text = readFileSync(file, 'utf8');
|
|
94
|
+
title = headingOf(text) || title;
|
|
95
|
+
summary = firstLine(text.replace(/^#.*$/m, ''));
|
|
96
|
+
} catch {
|
|
97
|
+
/* ignore */
|
|
98
|
+
}
|
|
99
|
+
topics.push({ name: entry.name, title, summary, bytes, mtime });
|
|
100
|
+
}
|
|
101
|
+
return topics.sort((a, b) => a.name.localeCompare(b.name, 'es'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function readMemoryTopic(cwd, name) {
|
|
105
|
+
if (!isMemoryTopic(name)) return null;
|
|
106
|
+
const file = path.join(memoryRoot(cwd), name);
|
|
107
|
+
try {
|
|
108
|
+
const raw = readFileSync(file, 'utf8');
|
|
109
|
+
return {
|
|
110
|
+
name,
|
|
111
|
+
title: headingOf(raw) || name.replace(/\.md$/i, ''),
|
|
112
|
+
body: raw.slice(0, MAX_TOPIC_BYTES),
|
|
113
|
+
truncated: Buffer.byteLength(raw, 'utf8') > MAX_TOPIC_BYTES,
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function writeIndexBody(cwd, lines) {
|
|
121
|
+
const root = memoryRoot(cwd);
|
|
122
|
+
await mkdir(root, { recursive: true });
|
|
123
|
+
const body = ['# Memoria del proyecto', '', ...lines.filter(Boolean)].join('\n');
|
|
124
|
+
await writeFile(memoryIndexPath(cwd), `${clampIndex(body)}\n`, 'utf8');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function indexLines(existing) {
|
|
128
|
+
return String(existing ?? '')
|
|
129
|
+
.split(/\r?\n/)
|
|
130
|
+
.filter((l) => /^\s*[-*]\s/.test(l));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Fija (o agrega) la línea del tema en el índice. */
|
|
134
|
+
async function upsertIndexLine(cwd, { name, title, summary }) {
|
|
135
|
+
const line = `- [${title}](${name})${summary ? ` — ${summary}` : ''}`;
|
|
136
|
+
const existing = indexLines(readMemoryIndex(cwd)).filter((l) => !l.includes(`(${name})`));
|
|
137
|
+
existing.push(line);
|
|
138
|
+
await writeIndexBody(cwd, existing);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function removeIndexLine(cwd, name) {
|
|
142
|
+
const existing = indexLines(readMemoryIndex(cwd));
|
|
143
|
+
const next = existing.filter((l) => !l.includes(`(${name})`));
|
|
144
|
+
if (next.length !== existing.length) await writeIndexBody(cwd, next);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Escribe un tema (y su línea en el índice). Devuelve `{ name, title }`. */
|
|
148
|
+
export async function writeMemory(cwd, { topic, title, content } = {}) {
|
|
149
|
+
const slug = memorySlug(topic ?? title);
|
|
150
|
+
if (!slug) throw new Error('Tema de memoria inválido.');
|
|
151
|
+
const name = `${slug}.md`;
|
|
152
|
+
if (!isMemoryTopic(name)) throw new Error('Tema de memoria inválido.');
|
|
153
|
+
const clean = String(content ?? '').trim();
|
|
154
|
+
if (!clean) throw new Error('El contenido de la memoria está vacío.');
|
|
155
|
+
const named = title ? String(title).trim() : slug;
|
|
156
|
+
const hasHeading = /^#\s/.test(clean);
|
|
157
|
+
const body = hasHeading ? clean : `# ${named}\n\n${clean}`;
|
|
158
|
+
const root = memoryRoot(cwd);
|
|
159
|
+
await mkdir(root, { recursive: true });
|
|
160
|
+
await writeFile(path.join(root, name), `${body.slice(0, MAX_TOPIC_BYTES)}\n`, 'utf8');
|
|
161
|
+
await upsertIndexLine(cwd, { name, title: named, summary: firstLine(clean) });
|
|
162
|
+
return { name, title: named };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function deleteMemory(cwd, name) {
|
|
166
|
+
if (!isMemoryTopic(name)) return false;
|
|
167
|
+
await rm(path.join(memoryRoot(cwd), name), { force: true });
|
|
168
|
+
await removeIndexLine(cwd, name);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Guarda el índice completo (`MEMORY.md`), acotado. */
|
|
173
|
+
export async function writeIndex(cwd, content) {
|
|
174
|
+
const root = memoryRoot(cwd);
|
|
175
|
+
await mkdir(root, { recursive: true });
|
|
176
|
+
await writeFile(memoryIndexPath(cwd), `${clampIndex(String(content ?? ''))}\n`, 'utf8');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Busca `query` (case-insensitive) en índice y temas. */
|
|
180
|
+
export function searchMemory(cwd, query) {
|
|
181
|
+
const needle = String(query ?? '').trim().toLowerCase();
|
|
182
|
+
if (!needle) return [];
|
|
183
|
+
const results = [];
|
|
184
|
+
const index = readMemoryIndex(cwd);
|
|
185
|
+
if (index.toLowerCase().includes(needle)) {
|
|
186
|
+
results.push({ name: MEMORY_INDEX_NAME, title: 'Índice', snippet: snippetAround(index, needle) });
|
|
187
|
+
}
|
|
188
|
+
for (const topic of listMemoryTopics(cwd)) {
|
|
189
|
+
const read = readMemoryTopic(cwd, topic.name);
|
|
190
|
+
if (!read) continue;
|
|
191
|
+
if (read.body.toLowerCase().includes(needle)) {
|
|
192
|
+
results.push({ name: topic.name, title: read.title, snippet: snippetAround(read.body, needle) });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function snippetAround(text, needle, radius = 120) {
|
|
199
|
+
const at = text.toLowerCase().indexOf(needle);
|
|
200
|
+
if (at < 0) return text.slice(0, radius * 2);
|
|
201
|
+
const start = Math.max(0, at - radius);
|
|
202
|
+
return text.slice(start, at + needle.length + radius).replace(/\s+/g, ' ').trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Bloque de system prompt con la memoria del proyecto (índice + temas), acotado.
|
|
207
|
+
* '' si no hay nada.
|
|
208
|
+
*/
|
|
209
|
+
export function memoryPrompt(cwd) {
|
|
210
|
+
const index = readMemoryIndex(cwd).trim();
|
|
211
|
+
const topics = listMemoryTopics(cwd);
|
|
212
|
+
if (!index && !topics.length) return '';
|
|
213
|
+
const lines = [
|
|
214
|
+
'## Memoria del proyecto',
|
|
215
|
+
'Lo que ya sabés de este proyecto (de sesiones anteriores). Mantenela con la herramienta `memory`; no hace falta repetirla.',
|
|
216
|
+
];
|
|
217
|
+
if (index) lines.push('', index);
|
|
218
|
+
else if (topics.length) lines.push('', ...topics.map((t) => `- [${t.title}](${t.name})${t.summary ? ` — ${t.summary}` : ''}`));
|
|
219
|
+
return lines.join('\n');
|
|
220
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Prompt caching. `mode` es una capability del provider:
|
|
2
|
+
// - 'anthropic': marca el system prompt con cache_control (OpenRouter y la capa
|
|
3
|
+
// OpenAI-compatible de Anthropic aceptan bloques de contenido con cache_control).
|
|
4
|
+
// - 'openai': expone una clave estable para el cacheo automático de prefijos.
|
|
5
|
+
// Es opt-in: sin `promptCaching` en la config, no se toca nada.
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
/** Clave estable (system + tools) para el cacheo automático de OpenAI. */
|
|
10
|
+
export function promptCacheKey(messages = [], tools = []) {
|
|
11
|
+
const base = JSON.stringify([messages?.[0]?.content ?? '', tools ?? []]);
|
|
12
|
+
return createHash('sha1').update(base).digest('hex').slice(0, 32);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Aplica las pistas de cache al body (muta y devuelve `body`). */
|
|
16
|
+
export function applyPromptCache(body, { mode = 'none' } = {}) {
|
|
17
|
+
if (mode === 'anthropic' && Array.isArray(body.messages) && body.messages.length) {
|
|
18
|
+
const first = body.messages[0];
|
|
19
|
+
if (first?.role === 'system' && typeof first.content === 'string') {
|
|
20
|
+
body.messages = [
|
|
21
|
+
{ ...first, content: [{ type: 'text', text: first.content, cache_control: { type: 'ephemeral' } }] },
|
|
22
|
+
...body.messages.slice(1),
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
} else if (mode === 'openai') {
|
|
26
|
+
body.prompt_cache_key = promptCacheKey(body.messages, body.tools);
|
|
27
|
+
}
|
|
28
|
+
return body;
|
|
29
|
+
}
|
|
@@ -52,9 +52,10 @@ export const ollamaProvider = {
|
|
|
52
52
|
return sortModels((data.models ?? []).map(toOllamaModel).filter((m) => m.id));
|
|
53
53
|
},
|
|
54
54
|
|
|
55
|
-
async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent }) {
|
|
55
|
+
async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent, maxOutputTokens }) {
|
|
56
56
|
const body = { model, messages: toOllamaMessages(messages), stream: true };
|
|
57
57
|
if (tools?.length) body.tools = tools;
|
|
58
|
+
if (maxOutputTokens > 0) body.options = { ...(body.options ?? {}), num_predict: maxOutputTokens };
|
|
58
59
|
|
|
59
60
|
const res = await providerFetch(joinUrl(baseUrl, '/api/chat'), {
|
|
60
61
|
method: 'POST',
|
|
@@ -71,6 +72,6 @@ export const ollamaProvider = {
|
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
return readNDJSON(res, { onEvent });
|
|
75
|
+
return readNDJSON(res, { onEvent, signal });
|
|
75
76
|
},
|
|
76
77
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readOpenAISSE } from './stream.js';
|
|
2
|
+
import { applyPromptCache } from './cache.js';
|
|
2
3
|
import { joinUrl, authHeaders, providerError, providerFetch, normalizeModel, sortModels } from './utils.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -21,9 +22,15 @@ export const openaiCompatibleProvider = {
|
|
|
21
22
|
return sortModels(list.map(normalizeModel).filter((m) => m.id));
|
|
22
23
|
},
|
|
23
24
|
|
|
24
|
-
async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent }) {
|
|
25
|
+
async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
|
|
25
26
|
const body = { model, messages, stream: true };
|
|
26
27
|
if (tools?.length) body.tools = tools;
|
|
28
|
+
if (maxOutputTokens > 0) {
|
|
29
|
+
// OpenAI o-series exige `max_completion_tokens`; el resto usa `max_tokens`.
|
|
30
|
+
const field = this?.maxTokensField ?? 'max_tokens';
|
|
31
|
+
body[field] = maxOutputTokens;
|
|
32
|
+
}
|
|
33
|
+
if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
|
|
27
34
|
|
|
28
35
|
return requestStream({
|
|
29
36
|
url: joinUrl(baseUrl, '/chat/completions'),
|
|
@@ -53,5 +60,5 @@ export async function requestStream({ url, headers, providerLabel, model, body,
|
|
|
53
60
|
});
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
return readOpenAISSE(res, { onEvent });
|
|
63
|
+
return readOpenAISSE(res, { onEvent, signal });
|
|
57
64
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requestStream } from './openaiCompatible.js';
|
|
2
|
+
import { applyPromptCache } from './cache.js';
|
|
2
3
|
import { joinUrl, providerFetch, normalizeModel, sortModels } from './utils.js';
|
|
3
4
|
|
|
4
5
|
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
@@ -21,6 +22,8 @@ export const openrouterProvider = {
|
|
|
21
22
|
label: 'OpenRouter',
|
|
22
23
|
defaultBaseUrl: OPENROUTER_BASE_URL,
|
|
23
24
|
requiresKey: true,
|
|
25
|
+
// OpenRouter pasa cache_control a Anthropic; en otros modelos queda sin efecto.
|
|
26
|
+
caching: 'anthropic',
|
|
24
27
|
|
|
25
28
|
async listModels({ apiKey, siteUrl, siteName }) {
|
|
26
29
|
const res = await providerFetch(joinUrl(OPENROUTER_BASE_URL, '/models'), {
|
|
@@ -40,9 +43,11 @@ export const openrouterProvider = {
|
|
|
40
43
|
);
|
|
41
44
|
},
|
|
42
45
|
|
|
43
|
-
async streamChat(ctx, { model, messages, tools, signal, onEvent }) {
|
|
46
|
+
async streamChat(ctx, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
|
|
44
47
|
const body = { model, messages, stream: true };
|
|
45
48
|
if (tools?.length) body.tools = tools;
|
|
49
|
+
if (maxOutputTokens > 0) body.max_tokens = maxOutputTokens;
|
|
50
|
+
if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
|
|
46
51
|
|
|
47
52
|
return requestStream({
|
|
48
53
|
url: joinUrl(OPENROUTER_BASE_URL, '/chat/completions'),
|
|
@@ -2,16 +2,19 @@ import { openaiCompatibleProvider } from './openaiCompatible.js';
|
|
|
2
2
|
|
|
3
3
|
// Most commercial providers expose an OpenAI-compatible chat completions API,
|
|
4
4
|
// so they only differ by label and base URL. Adding one is a one-liner.
|
|
5
|
-
function preset(id, label, baseUrl) {
|
|
6
|
-
return { ...openaiCompatibleProvider, id, label, defaultBaseUrl: baseUrl, requiresKey: true };
|
|
5
|
+
function preset(id, label, baseUrl, extra = {}) {
|
|
6
|
+
return { ...openaiCompatibleProvider, id, label, defaultBaseUrl: baseUrl, requiresKey: true, ...extra };
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export const OPENAI_COMPATIBLE_PRESETS = [
|
|
10
10
|
preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1'),
|
|
11
|
-
preset('openai', 'OpenAI', 'https://api.openai.com/v1'
|
|
11
|
+
preset('openai', 'OpenAI', 'https://api.openai.com/v1', {
|
|
12
|
+
maxTokensField: 'max_completion_tokens',
|
|
13
|
+
caching: 'openai',
|
|
14
|
+
}),
|
|
12
15
|
// Anthropic y Gemini exponen una capa compatible con OpenAI (chat/completions
|
|
13
16
|
// + tools) que usa el mismo Bearer; sirven con este adaptador sin cambios.
|
|
14
|
-
preset('anthropic', 'Anthropic (Claude)', 'https://api.anthropic.com/v1'),
|
|
17
|
+
preset('anthropic', 'Anthropic (Claude)', 'https://api.anthropic.com/v1', { caching: 'anthropic' }),
|
|
15
18
|
preset('gemini', 'Google Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai'),
|
|
16
19
|
preset('groq', 'Groq', 'https://api.groq.com/openai/v1'),
|
|
17
20
|
preset('mistral', 'Mistral AI', 'https://api.mistral.ai/v1'),
|
|
@@ -9,6 +9,47 @@
|
|
|
9
9
|
* string or as an object (`message`, `data.message`, serialized `data`).
|
|
10
10
|
* (openbridge-style tolerance: errors come in many shapes per provider.)
|
|
11
11
|
*/
|
|
12
|
+
function abortError() {
|
|
13
|
+
return new DOMException('Aborted', 'AbortError');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `reader.read()` abortable. Aunque `fetch` deberia cancelar el body al abortar,
|
|
18
|
+
* no todos los runtimes lo hacen de forma inmediata; esto garantiza que el
|
|
19
|
+
* stream se corte apenas el usuario detiene el run.
|
|
20
|
+
*/
|
|
21
|
+
function readChunk(reader, signal) {
|
|
22
|
+
if (!signal) return reader.read();
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
reader.cancel().catch(() => {});
|
|
25
|
+
return Promise.reject(abortError());
|
|
26
|
+
}
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let settled = false;
|
|
29
|
+
const onAbort = () => {
|
|
30
|
+
if (settled) return;
|
|
31
|
+
settled = true;
|
|
32
|
+
reader.cancel().catch(() => {});
|
|
33
|
+
reject(abortError());
|
|
34
|
+
};
|
|
35
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
36
|
+
reader.read().then(
|
|
37
|
+
(result) => {
|
|
38
|
+
if (settled) return;
|
|
39
|
+
settled = true;
|
|
40
|
+
signal.removeEventListener('abort', onAbort);
|
|
41
|
+
resolve(result);
|
|
42
|
+
},
|
|
43
|
+
(error) => {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
signal.removeEventListener('abort', onAbort);
|
|
47
|
+
reject(error);
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
12
53
|
export function providerErrorMessage(payload) {
|
|
13
54
|
const err = typeof payload === 'string' ? payload : (payload?.error ?? payload);
|
|
14
55
|
if (typeof err === 'string' && err) return err;
|
|
@@ -39,7 +80,7 @@ export function providerErrorMessage(payload) {
|
|
|
39
80
|
* Parse an OpenAI-compatible SSE stream.
|
|
40
81
|
* @returns {Promise<{content:string, reasoning:string, toolCalls:object[], usage:object|null}>}
|
|
41
82
|
*/
|
|
42
|
-
export async function readOpenAISSE(response, { onEvent } = {}) {
|
|
83
|
+
export async function readOpenAISSE(response, { onEvent, signal } = {}) {
|
|
43
84
|
const reader = response.body.getReader();
|
|
44
85
|
const decoder = new TextDecoder('utf-8');
|
|
45
86
|
let partial = '';
|
|
@@ -50,7 +91,7 @@ export async function readOpenAISSE(response, { onEvent } = {}) {
|
|
|
50
91
|
let usage = null;
|
|
51
92
|
|
|
52
93
|
while (true) {
|
|
53
|
-
const { done, value } = await reader
|
|
94
|
+
const { done, value } = await readChunk(reader, signal);
|
|
54
95
|
if (done) break;
|
|
55
96
|
partial += decoder.decode(value, { stream: true });
|
|
56
97
|
const lines = partial.split('\n');
|
|
@@ -108,7 +149,7 @@ export async function readOpenAISSE(response, { onEvent } = {}) {
|
|
|
108
149
|
* Parse Ollama's native NDJSON stream (`/api/chat`).
|
|
109
150
|
* @returns {Promise<{content:string, reasoning:string, toolCalls:object[], usage:object|null}>}
|
|
110
151
|
*/
|
|
111
|
-
export async function readNDJSON(response, { onEvent } = {}) {
|
|
152
|
+
export async function readNDJSON(response, { onEvent, signal } = {}) {
|
|
112
153
|
const reader = response.body.getReader();
|
|
113
154
|
const decoder = new TextDecoder('utf-8');
|
|
114
155
|
let partial = '';
|
|
@@ -155,7 +196,7 @@ export async function readNDJSON(response, { onEvent } = {}) {
|
|
|
155
196
|
};
|
|
156
197
|
|
|
157
198
|
while (true) {
|
|
158
|
-
const { done, value } = await reader
|
|
199
|
+
const { done, value } = await readChunk(reader, signal);
|
|
159
200
|
if (done) break;
|
|
160
201
|
partial += decoder.decode(value, { stream: true });
|
|
161
202
|
const lines = partial.split('\n');
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Reglas de proyecto por path: archivos `.md` con frontmatter `paths:` (globs).
|
|
2
|
+
// Las que no tienen `paths` son estáticas (van siempre al system prompt); las
|
|
3
|
+
// que sí, se inyectan en runtime cuando una tool toca un archivo que matchea.
|
|
4
|
+
|
|
5
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { globToRegExp } from '../tools/glob.js';
|
|
8
|
+
|
|
9
|
+
/** Parsea un archivo de reglas: frontmatter (name, paths/globs) + body. */
|
|
10
|
+
export function parseRuleMarkdown(text, fallbackId = '') {
|
|
11
|
+
const source = String(text ?? '');
|
|
12
|
+
let name = fallbackId;
|
|
13
|
+
let paths = [];
|
|
14
|
+
let body = source.trim();
|
|
15
|
+
const match = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/);
|
|
16
|
+
if (match) {
|
|
17
|
+
body = match[2].trim();
|
|
18
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
19
|
+
const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
|
|
20
|
+
if (!kv) continue;
|
|
21
|
+
const key = kv[1].toLowerCase();
|
|
22
|
+
const value = kv[2].trim();
|
|
23
|
+
if (key === 'name') name = value.replace(/^["']|["']$/g, '');
|
|
24
|
+
else if (key === 'paths' || key === 'globs' || key === 'path') paths = parseList(value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { name: name || fallbackId, paths: normalizePaths(paths), body };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseList(value) {
|
|
31
|
+
const raw = String(value ?? '').trim();
|
|
32
|
+
if (!raw) return [];
|
|
33
|
+
const inner = raw.startsWith('[') && raw.endsWith(']') ? raw.slice(1, -1) : raw;
|
|
34
|
+
return inner
|
|
35
|
+
.split(',')
|
|
36
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizePaths(paths) {
|
|
41
|
+
return [...new Set((paths ?? []).map((p) => String(p).replace(/\\/g, '/').replace(/^\.?\//, '').trim()).filter(Boolean))];
|
|
42
|
+
}
|
|
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
|
+
/** Carga y parsea los archivos de reglas que matchean `patterns` (rel a `root`). */
|
|
56
|
+
export async function loadRules(root, patterns = []) {
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
const rules = [];
|
|
59
|
+
for (const pattern of patterns) {
|
|
60
|
+
const normalized = String(pattern).replace(/\\/g, '/');
|
|
61
|
+
const candidates = await matchFiles(root, normalized);
|
|
62
|
+
for (const rel of candidates) {
|
|
63
|
+
if (seen.has(rel)) continue;
|
|
64
|
+
seen.add(rel);
|
|
65
|
+
try {
|
|
66
|
+
const text = await readFile(path.join(root, rel), 'utf8');
|
|
67
|
+
const parsed = parseRuleMarkdown(text, rel);
|
|
68
|
+
if (!parsed.body) continue;
|
|
69
|
+
rules.push({ id: rel, name: parsed.name, paths: parsed.paths, body: parsed.body });
|
|
70
|
+
} catch {
|
|
71
|
+
/* ignore unreadable rule */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return rules.sort((a, b) => a.id.localeCompare(b.id, 'es'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function matchFiles(root, pattern) {
|
|
79
|
+
if (!hasGlob(pattern)) {
|
|
80
|
+
try {
|
|
81
|
+
return (await stat(path.join(root, pattern))).isFile() ? [pattern] : [];
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const prefix = fixedPrefix(pattern);
|
|
87
|
+
const base = prefix ? path.join(root, prefix) : root;
|
|
88
|
+
const files = [];
|
|
89
|
+
const walk = async (dir, depth) => {
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
98
|
+
const full = path.join(dir, entry.name);
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
if (depth < 6 && entry.name !== '.git' && entry.name !== 'node_modules') {
|
|
101
|
+
await walk(full, depth + 1);
|
|
102
|
+
}
|
|
103
|
+
} else if (entry.isFile()) {
|
|
104
|
+
const rel = path.relative(root, full).replace(/\\/g, '/');
|
|
105
|
+
files.push(rel);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
await walk(base, 0);
|
|
110
|
+
const re = globToRegExp(pattern);
|
|
111
|
+
return files.filter((rel) => re.test(rel));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Reglas sin `paths` (aplican siempre). */
|
|
115
|
+
export function staticRules(rules = []) {
|
|
116
|
+
return rules.filter((r) => !r.paths?.length);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Reglas con `paths` (contextuales). */
|
|
120
|
+
export function pathRules(rules = []) {
|
|
121
|
+
return rules.filter((r) => r.paths?.length);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** ¿La regla aplica a `relPath`? */
|
|
125
|
+
export function ruleMatchesPath(rule, relPath) {
|
|
126
|
+
const rel = String(relPath ?? '').replace(/\\/g, '/').replace(/^\.?\//, '');
|
|
127
|
+
if (!rel) return false;
|
|
128
|
+
return (rule.paths ?? []).some((pattern) => globToRegExp(pattern).test(rel));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Reglas contextuales que aplican a `relPath`. */
|
|
132
|
+
export function rulesForPath(rules, relPath) {
|
|
133
|
+
return pathRules(rules).filter((r) => ruleMatchesPath(r, relPath));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Bloque de system prompt con reglas estáticas. */
|
|
137
|
+
export function buildStaticRulesBlock(rules) {
|
|
138
|
+
const list = staticRules(rules);
|
|
139
|
+
if (!list.length) return '';
|
|
140
|
+
const parts = ['## Reglas del proyecto'];
|
|
141
|
+
for (const rule of list) parts.push('', rule.body);
|
|
142
|
+
return parts.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Bloque inyectable con las reglas que aplican a un archivo. */
|
|
146
|
+
export function buildPathRulesBlock(rules, relPath) {
|
|
147
|
+
if (!rules.length) return '';
|
|
148
|
+
const parts = [`## Reglas del proyecto para ${relPath}`];
|
|
149
|
+
for (const rule of rules) parts.push('', rule.body);
|
|
150
|
+
return parts.join('\n');
|
|
151
|
+
}
|
|
@@ -77,6 +77,27 @@ export async function writeSkillFiles(dir, files = []) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
const INDEX_WHEN_CHARS = 220;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Índice compacto (name + when/description, sin body) para *progressive
|
|
84
|
+
* disclosure*: el modelo ve qué skills hay y carga el body on-demand con la
|
|
85
|
+
* herramienta `skill`. Mucho más barato en tokens que inyectar todos los bodies.
|
|
86
|
+
*/
|
|
87
|
+
export function buildSkillsIndex(skills = []) {
|
|
88
|
+
if (!skills.length) return '';
|
|
89
|
+
const lines = [
|
|
90
|
+
'Tenés skills disponibles (instrucciones especializadas). Cuando una aplique a la tarea, cargá su contenido con la herramienta `skill({ name })`:',
|
|
91
|
+
];
|
|
92
|
+
for (const skill of skills) {
|
|
93
|
+
const title = skill.name || skill.id;
|
|
94
|
+
const when = String(skill.when || skill.description || '').replace(/\s+/g, ' ').trim();
|
|
95
|
+
const hint = when.length > INDEX_WHEN_CHARS ? `${when.slice(0, INDEX_WHEN_CHARS)}…` : when;
|
|
96
|
+
lines.push(`- ${skill.id}: ${title}${hint ? ` — ${hint}` : ''}`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
/** Text block appended to the system prompt with the enabled skills. */
|
|
81
102
|
export function buildSkillsPrompt(skills = []) {
|
|
82
103
|
if (!skills.length) return '';
|
|
@@ -301,12 +301,16 @@ export async function grepTool(root, { pattern, include, path: p } = {}) {
|
|
|
301
301
|
return results.length ? results.join('\n') : 'Sin coincidencias';
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
export function bashTool(root, { command, timeout } = {}) {
|
|
304
|
+
export function bashTool(root, { command, timeout } = {}, { signal } = {}) {
|
|
305
305
|
return new Promise((resolve) => {
|
|
306
306
|
if (!command || typeof command !== 'string') {
|
|
307
307
|
resolve('Error: falta "command".');
|
|
308
308
|
return;
|
|
309
309
|
}
|
|
310
|
+
if (signal?.aborted) {
|
|
311
|
+
resolve('Comando cancelado por el usuario.');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
310
314
|
const seconds = Math.min(Math.max(Number(timeout) || 30, 1), 300);
|
|
311
315
|
let child;
|
|
312
316
|
try {
|
|
@@ -318,31 +322,35 @@ export function bashTool(root, { command, timeout } = {}) {
|
|
|
318
322
|
|
|
319
323
|
let output = '';
|
|
320
324
|
let settled = false;
|
|
325
|
+
let timer = null;
|
|
321
326
|
const append = (chunk) => {
|
|
322
327
|
if (output.length < MAX_BASH_OUTPUT) output += chunk.toString();
|
|
323
328
|
};
|
|
324
329
|
|
|
325
|
-
const
|
|
330
|
+
const finish = (text) => {
|
|
326
331
|
if (settled) return;
|
|
327
332
|
settled = true;
|
|
333
|
+
if (timer) clearTimeout(timer);
|
|
334
|
+
signal?.removeEventListener('abort', onAbort);
|
|
335
|
+
resolve(text);
|
|
336
|
+
};
|
|
337
|
+
const onAbort = () => {
|
|
328
338
|
child.kill('SIGKILL');
|
|
329
|
-
|
|
339
|
+
finish(`${output}\n[comando cancelado por el usuario]\n[exit -1]`);
|
|
340
|
+
};
|
|
341
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
342
|
+
|
|
343
|
+
timer = setTimeout(() => {
|
|
344
|
+
child.kill('SIGKILL');
|
|
345
|
+
finish(`${output}\n[timeout tras ${seconds}s]\n[exit -1]`);
|
|
330
346
|
}, seconds * 1000);
|
|
331
347
|
|
|
332
348
|
child.stdout?.on('data', append);
|
|
333
349
|
child.stderr?.on('data', append);
|
|
334
|
-
child.on('error', (err) => {
|
|
335
|
-
if (settled) return;
|
|
336
|
-
settled = true;
|
|
337
|
-
clearTimeout(timer);
|
|
338
|
-
resolve(`Error ejecutando el comando: ${err.message}`);
|
|
339
|
-
});
|
|
350
|
+
child.on('error', (err) => finish(`Error ejecutando el comando: ${err.message}`));
|
|
340
351
|
child.on('close', (code) => {
|
|
341
|
-
if (settled) return;
|
|
342
|
-
settled = true;
|
|
343
|
-
clearTimeout(timer);
|
|
344
352
|
const truncated = output.length >= MAX_BASH_OUTPUT ? '\n[output truncado]' : '';
|
|
345
|
-
|
|
353
|
+
finish(`${output}${truncated}\n[exit ${code}]`);
|
|
346
354
|
});
|
|
347
355
|
});
|
|
348
356
|
}
|