@danieltmn/tamnora 0.12.0 → 0.13.1
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 +23 -0
- package/harness/packages/core/src/agent/runAgent.js +23 -5
- package/harness/packages/core/src/config/index.js +2 -0
- package/harness/packages/core/src/index.js +26 -0
- package/harness/packages/core/src/memory/index.js +220 -0
- package/harness/packages/core/src/rules/index.js +151 -0
- package/harness/packages/core/test/memory.test.js +96 -0
- package/harness/packages/core/test/rules.test.js +64 -0
- package/harness/packages/server/public/app.js +96 -1
- package/harness/packages/server/public/index.html +46 -1
- package/harness/packages/server/src/lib/agentRun.js +2 -2
- package/harness/packages/server/src/lib/agentTools.js +60 -5
- package/harness/packages/server/src/lib/channels/telegram.js +3 -0
- package/harness/packages/server/src/lib/prompt.js +14 -3
- package/harness/packages/server/src/lib/rules.js +43 -0
- package/harness/packages/server/src/routes/chat.js +3 -0
- package/harness/packages/server/src/routes/memory.js +66 -0
- package/harness/packages/server/src/server.js +4 -0
- package/harness/packages/server/test/server.test.js +170 -5
- package/harness/tamnora.example.json +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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.13.1] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Arreglado
|
|
11
|
+
|
|
12
|
+
- El test de permisos ya no es flaky: verifica que el `askId` resuelto no
|
|
13
|
+
reaparezca (antes asumía que no quedaba ninguno pendiente, lo que fallaba por
|
|
14
|
+
carrera cuando el agente ya pedía el siguiente permiso).
|
|
15
|
+
|
|
16
|
+
## [0.13.0] - 2026-10-10
|
|
17
|
+
|
|
18
|
+
### Agregado
|
|
19
|
+
|
|
20
|
+
- **Fase 2 del roadmap del server (memoria y reglas)**:
|
|
21
|
+
- **Memoria durable por proyecto** (`.tamnora/memory/`): `MEMORY.md` como índice
|
|
22
|
+
y un archivo por tema. La maneja el agente con la tool `memory`
|
|
23
|
+
(`read | write | search | list`; `write` = `ask`) y se inyecta un resumen en el
|
|
24
|
+
system prompt de cada corrida. Nueva pestaña **Memoria** en la consola y
|
|
25
|
+
`GET/PUT/DELETE /api/memory`.
|
|
26
|
+
- **Reglas por path** (`rules: ['.tamnora/rules/*.md']`): archivos `.md` con
|
|
27
|
+
frontmatter `paths:` (globs). Las estáticas entran al system prompt; las
|
|
28
|
+
contextuales se inyectan en runtime cuando una tool toca un archivo que
|
|
29
|
+
matchea, de forma efímera (no ensucian el historial).
|
|
30
|
+
|
|
8
31
|
## [0.12.0] - 2026-10-10
|
|
9
32
|
|
|
10
33
|
### Agregado
|
|
@@ -68,7 +68,17 @@ export async function runAgent({
|
|
|
68
68
|
onLifecycle,
|
|
69
69
|
}) {
|
|
70
70
|
const working = [...messages];
|
|
71
|
-
|
|
71
|
+
// Mensajes efimeros (p. ej. reglas por path inyectadas en runtime): entran al
|
|
72
|
+
// contexto de las proximas rondas pero NO se persisten en el historial.
|
|
73
|
+
const transient = [];
|
|
74
|
+
const emitLifecycle = (type, payload) => {
|
|
75
|
+
if (!onLifecycle) return undefined;
|
|
76
|
+
try {
|
|
77
|
+
return onLifecycle({ type, ...payload });
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
72
82
|
emitLifecycle('run.start', { model });
|
|
73
83
|
let usageTotal = null;
|
|
74
84
|
const startedAt = Date.now();
|
|
@@ -110,9 +120,9 @@ export async function runAgent({
|
|
|
110
120
|
|
|
111
121
|
// Presupuesto de contexto: el recorte va sobre una copia; `working` sigue
|
|
112
122
|
// completo para persistir el historial con indices estables.
|
|
113
|
-
let contextMessages = working;
|
|
123
|
+
let contextMessages = transient.length ? [...working, ...transient] : working;
|
|
114
124
|
if (contextTokens > 0) {
|
|
115
|
-
const trimmedResult = trimContext(
|
|
125
|
+
const trimmedResult = trimContext(contextMessages, { maxTokens: contextTokens });
|
|
116
126
|
contextMessages = trimmedResult.messages;
|
|
117
127
|
if (trimmedResult.trimmed || trimmedResult.dropped) {
|
|
118
128
|
onEvent?.({
|
|
@@ -216,13 +226,12 @@ export async function runAgent({
|
|
|
216
226
|
output = NO_PROGRESS_MESSAGE;
|
|
217
227
|
} else {
|
|
218
228
|
newCalls++;
|
|
219
|
-
emitLifecycle('tool.before', { id: call.id, name, args });
|
|
229
|
+
await emitLifecycle('tool.before', { id: call.id, name, args });
|
|
220
230
|
try {
|
|
221
231
|
output = await execute(name, args, toolContext);
|
|
222
232
|
} catch (err) {
|
|
223
233
|
output = `Error ejecutando ${name}: ${err?.message ?? err}`;
|
|
224
234
|
}
|
|
225
|
-
emitLifecycle('tool.after', { id: call.id, name, args, result: output });
|
|
226
235
|
}
|
|
227
236
|
|
|
228
237
|
const normalized = normalizeToolOutput(output);
|
|
@@ -239,6 +248,15 @@ export async function runAgent({
|
|
|
239
248
|
name,
|
|
240
249
|
content: normalized.content,
|
|
241
250
|
});
|
|
251
|
+
|
|
252
|
+
if (!(repeatLimit > 0 && seen > repeatLimit)) {
|
|
253
|
+
const after = await emitLifecycle('tool.after', { id: call.id, name, args, result: output });
|
|
254
|
+
const inject = after && typeof after === 'object' ? after.inject : undefined;
|
|
255
|
+
if (typeof inject === 'string' && inject.trim()) {
|
|
256
|
+
transient.push({ role: 'system', content: inject });
|
|
257
|
+
if (transient.length > 6) transient.shift();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
242
260
|
}
|
|
243
261
|
|
|
244
262
|
noProgressStreak = newCalls === 0 ? noProgressStreak + 1 : 0;
|
|
@@ -97,6 +97,32 @@ export {
|
|
|
97
97
|
buildSkillsIndex,
|
|
98
98
|
} from './skills/index.js';
|
|
99
99
|
export { parseSkillRef, fetchSkillFromGithub } from './skills/remote.js';
|
|
100
|
+
export {
|
|
101
|
+
MEMORY_DIR,
|
|
102
|
+
MEMORY_INDEX_NAME,
|
|
103
|
+
memoryRoot,
|
|
104
|
+
memoryIndexPath,
|
|
105
|
+
memorySlug,
|
|
106
|
+
isMemoryTopic,
|
|
107
|
+
readMemoryIndex,
|
|
108
|
+
listMemoryTopics,
|
|
109
|
+
readMemoryTopic,
|
|
110
|
+
writeMemory,
|
|
111
|
+
writeIndex as writeMemoryIndex,
|
|
112
|
+
deleteMemory,
|
|
113
|
+
searchMemory,
|
|
114
|
+
memoryPrompt,
|
|
115
|
+
} from './memory/index.js';
|
|
116
|
+
export {
|
|
117
|
+
parseRuleMarkdown,
|
|
118
|
+
loadRules,
|
|
119
|
+
staticRules,
|
|
120
|
+
pathRules,
|
|
121
|
+
ruleMatchesPath,
|
|
122
|
+
rulesForPath,
|
|
123
|
+
buildStaticRulesBlock,
|
|
124
|
+
buildPathRulesBlock,
|
|
125
|
+
} from './rules/index.js';
|
|
100
126
|
export {
|
|
101
127
|
searchMcpRegistry,
|
|
102
128
|
getMcpRegistryServer,
|
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
memoryRoot,
|
|
8
|
+
memorySlug,
|
|
9
|
+
isMemoryTopic,
|
|
10
|
+
readMemoryIndex,
|
|
11
|
+
listMemoryTopics,
|
|
12
|
+
readMemoryTopic,
|
|
13
|
+
writeMemory,
|
|
14
|
+
writeIndex as writeMemoryIndex,
|
|
15
|
+
deleteMemory,
|
|
16
|
+
searchMemory,
|
|
17
|
+
memoryPrompt,
|
|
18
|
+
} from '../src/memory/index.js';
|
|
19
|
+
|
|
20
|
+
async function workspace() {
|
|
21
|
+
return mkdtemp(path.join(tmpdir(), 'tamnora-memory-'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('memorySlug / isMemoryTopic normalize names', () => {
|
|
25
|
+
assert.equal(memorySlug('Revisión de PR!'), 'revisi-n-de-pr');
|
|
26
|
+
assert.equal(isMemoryTopic('build.md'), true);
|
|
27
|
+
assert.equal(isMemoryTopic('MEMORY.md'), false);
|
|
28
|
+
assert.equal(isMemoryTopic('../evil.md'), false);
|
|
29
|
+
assert.equal(isMemoryTopic('note.txt'), false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('writeMemory creates a topic file and an index line', async () => {
|
|
33
|
+
const cwd = await workspace();
|
|
34
|
+
const saved = await writeMemory(cwd, { topic: 'arquitectura', title: 'Arquitectura', content: 'Usa SQLite.' });
|
|
35
|
+
assert.equal(saved.name, 'arquitectura.md');
|
|
36
|
+
|
|
37
|
+
const file = await readFile(path.join(memoryRoot(cwd), 'arquitectura.md'), 'utf8');
|
|
38
|
+
assert.match(file, /# Arquitectura/);
|
|
39
|
+
assert.match(file, /Usa SQLite\./);
|
|
40
|
+
|
|
41
|
+
const index = readMemoryIndex(cwd);
|
|
42
|
+
assert.match(index, /arquitectura\.md/);
|
|
43
|
+
assert.match(index, /Arquitectura/);
|
|
44
|
+
|
|
45
|
+
const topics = listMemoryTopics(cwd);
|
|
46
|
+
assert.deepEqual(topics.map((t) => t.name), ['arquitectura.md']);
|
|
47
|
+
assert.equal(topics[0].title, 'Arquitectura');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('upsert keeps a single index line per topic', async () => {
|
|
51
|
+
const cwd = await workspace();
|
|
52
|
+
await writeMemory(cwd, { topic: 'deploy', content: 'primero' });
|
|
53
|
+
await writeMemory(cwd, { topic: 'deploy', content: 'segundo' });
|
|
54
|
+
const lines = readMemoryIndex(cwd)
|
|
55
|
+
.split(/\r?\n/)
|
|
56
|
+
.filter((l) => l.includes('deploy.md'));
|
|
57
|
+
assert.equal(lines.length, 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('readMemoryTopic returns null for invalid names', async () => {
|
|
61
|
+
const cwd = await workspace();
|
|
62
|
+
assert.equal(readMemoryTopic(cwd, '../x.md'), null);
|
|
63
|
+
assert.equal(readMemoryTopic(cwd, 'missing.md'), null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('deleteMemory removes the file and its index line', async () => {
|
|
67
|
+
const cwd = await workspace();
|
|
68
|
+
await writeMemory(cwd, { topic: 'temp', content: 'algo' });
|
|
69
|
+
assert.equal(await deleteMemory(cwd, 'temp.md'), true);
|
|
70
|
+
assert.equal(readMemoryTopic(cwd, 'temp.md'), null);
|
|
71
|
+
assert.doesNotMatch(readMemoryIndex(cwd), /temp\.md/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('writeMemoryIndex replaces the whole index (capped)', async () => {
|
|
75
|
+
const cwd = await workspace();
|
|
76
|
+
await writeMemoryIndex(cwd, '# Memoria\n\n- a\n- b');
|
|
77
|
+
assert.match(readMemoryIndex(cwd), /- a/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('searchMemory finds matches in index and topics', async () => {
|
|
81
|
+
const cwd = await workspace();
|
|
82
|
+
await writeMemory(cwd, { topic: 'api', content: 'El endpoint es /v1/lento.' });
|
|
83
|
+
const hits = searchMemory(cwd, 'lento');
|
|
84
|
+
assert.ok(hits.some((h) => h.name === 'api.md'));
|
|
85
|
+
assert.ok(hits.every((h) => /lento/.test(h.snippet)));
|
|
86
|
+
assert.deepEqual(searchMemory(cwd, 'inexistente'), []);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('memoryPrompt is empty without memory and includes the index otherwise', async () => {
|
|
90
|
+
const cwd = await workspace();
|
|
91
|
+
assert.equal(memoryPrompt(cwd), '');
|
|
92
|
+
await writeMemory(cwd, { topic: 'notas', title: 'Notas', content: 'Recordar X.' });
|
|
93
|
+
const prompt = memoryPrompt(cwd);
|
|
94
|
+
assert.match(prompt, /Memoria del proyecto/);
|
|
95
|
+
assert.match(prompt, /notas\.md/);
|
|
96
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
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 {
|
|
7
|
+
parseRuleMarkdown,
|
|
8
|
+
loadRules,
|
|
9
|
+
staticRules,
|
|
10
|
+
pathRules,
|
|
11
|
+
ruleMatchesPath,
|
|
12
|
+
rulesForPath,
|
|
13
|
+
buildStaticRulesBlock,
|
|
14
|
+
buildPathRulesBlock,
|
|
15
|
+
} from '../src/rules/index.js';
|
|
16
|
+
|
|
17
|
+
test('parseRuleMarkdown reads paths and body', () => {
|
|
18
|
+
const rule = parseRuleMarkdown('---\nname: Estilo\npaths: ["src/**/*.js", "lib/*.ts"]\n---\nUsá comillas simples.', 'x');
|
|
19
|
+
assert.equal(rule.name, 'Estilo');
|
|
20
|
+
assert.deepEqual(rule.paths, ['src/**/*.js', 'lib/*.ts']);
|
|
21
|
+
assert.match(rule.body, /comillas simples/);
|
|
22
|
+
|
|
23
|
+
const plain = parseRuleMarkdown('Sin frontmatter.', 'y');
|
|
24
|
+
assert.deepEqual(plain.paths, []);
|
|
25
|
+
assert.equal(plain.body, 'Sin frontmatter.');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('ruleMatchesPath uses glob semantics', () => {
|
|
29
|
+
const rule = { paths: ['src/**/*.js', 'docs/*.md'] };
|
|
30
|
+
assert.equal(ruleMatchesPath(rule, 'src/a/b.js'), true);
|
|
31
|
+
assert.equal(ruleMatchesPath(rule, 'src/a/b.ts'), false);
|
|
32
|
+
assert.equal(ruleMatchesPath(rule, 'docs/guia.md'), true);
|
|
33
|
+
assert.equal(ruleMatchesPath(rule, 'docs/sub/guia.md'), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('loadRules scans glob patterns and splits static/path rules', async () => {
|
|
37
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-rules-'));
|
|
38
|
+
await mkdir(path.join(cwd, '.tamnora', 'rules'), { recursive: true });
|
|
39
|
+
await writeFile(
|
|
40
|
+
path.join(cwd, '.tamnora', 'rules', 'estilo.md'),
|
|
41
|
+
'---\npaths: src/**\n---\n# Estilo\nUsá punto y coma.'
|
|
42
|
+
);
|
|
43
|
+
await writeFile(path.join(cwd, '.tamnora', 'rules', 'general.md'), '# General\nSé conciso.');
|
|
44
|
+
|
|
45
|
+
const rules = await loadRules(cwd, ['.tamnora/rules/*.md']);
|
|
46
|
+
assert.equal(rules.length, 2);
|
|
47
|
+
assert.equal(staticRules(rules).length, 1);
|
|
48
|
+
assert.equal(pathRules(rules).length, 1);
|
|
49
|
+
|
|
50
|
+
const matched = rulesForPath(rules, 'src/index.js');
|
|
51
|
+
assert.equal(matched.length, 1);
|
|
52
|
+
assert.match(matched[0].body, /punto y coma/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('buildStaticRulesBlock and buildPathRulesBlock render bodies', () => {
|
|
56
|
+
const rules = [
|
|
57
|
+
{ id: 'a', paths: [], body: 'Sé conciso.' },
|
|
58
|
+
{ id: 'b', paths: ['src/**'], body: 'Usá 2 espacios.' },
|
|
59
|
+
];
|
|
60
|
+
assert.equal(buildStaticRulesBlock([]), '');
|
|
61
|
+
assert.match(buildStaticRulesBlock(rules), /Sé conciso\./);
|
|
62
|
+
assert.doesNotMatch(buildStaticRulesBlock(rules), /2 espacios/);
|
|
63
|
+
assert.match(buildPathRulesBlock([rules[1]], 'src/x.js'), /2 espacios/);
|
|
64
|
+
});
|