@danieltmn/tamnora 0.12.0 → 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.
@@ -195,7 +195,7 @@ function activateTab(name) {
195
195
  hideMoreMenu();
196
196
  if (name === 'runs') refreshRuns();
197
197
  if (name === 'providers') refreshProviders();
198
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
198
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshMemory(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
199
199
  }
200
200
 
201
201
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -3701,6 +3701,10 @@ const mcpForm = $('#mcp-form');
3701
3701
  const skillListEl = $('#skill-list');
3702
3702
  const skillModal = $('#skill-modal');
3703
3703
  const skillForm = $('#skill-form');
3704
+ const memoryListEl = $('#memory-list');
3705
+ const memoryIndexEl = $('#memory-index');
3706
+ const memoryModal = $('#memory-modal');
3707
+ const memoryForm = $('#memory-form');
3704
3708
  const tgTokenEl = $('#tg-token');
3705
3709
  const tgChatsEl = $('#tg-chats');
3706
3710
  const tgTranscribeEl = $('#tg-transcribe');
@@ -3989,6 +3993,88 @@ async function submitSkill(event) {
3989
3993
  refreshSkills();
3990
3994
  }
3991
3995
 
3996
+ /* --- ajustes: Memoria ---------------------------------------------------- */
3997
+
3998
+ async function refreshMemory() {
3999
+ if (!memoryListEl) return;
4000
+ const data = await api('/api/memory').catch(() => null);
4001
+ if (!data) return;
4002
+ if (memoryIndexEl && document.activeElement !== memoryIndexEl) memoryIndexEl.value = data.index ?? '';
4003
+ clear(memoryListEl);
4004
+ if (!data.topics?.length) {
4005
+ memoryListEl.appendChild(el('li', 'muted', 'sin memoria todavía'));
4006
+ return;
4007
+ }
4008
+ for (const t of data.topics) {
4009
+ const li = el('li', 'active');
4010
+ const main = el('div', 'grow');
4011
+ main.append(
4012
+ el('div', '', t.title || t.name),
4013
+ el('div', 'pmeta', `${t.name}${t.bytes ? ` · ${Math.max(1, Math.round(t.bytes / 1024))} KB` : ''}`)
4014
+ );
4015
+ li.appendChild(main);
4016
+ const actions = el('div', 'pactions');
4017
+ actions.appendChild(button('editar', (event) => { event.stopPropagation(); openMemoryForm(t.name); }));
4018
+ actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeMemory(t.name); }, 'btn danger'));
4019
+ li.appendChild(actions);
4020
+ li.addEventListener('click', () => openMemoryForm(t.name));
4021
+ memoryListEl.appendChild(li);
4022
+ }
4023
+ }
4024
+
4025
+ async function openMemoryForm(name = null) {
4026
+ $('#memory-title').textContent = name ? `editar ${name}` : 'nuevo tema';
4027
+ $('#mem-name').value = name ? name.replace(/\.md$/i, '') : '';
4028
+ $('#mem-name').readOnly = Boolean(name);
4029
+ $('#mem-title').value = '';
4030
+ $('#mem-body').value = '';
4031
+ setStatus('#mem-status', '');
4032
+ if (name) {
4033
+ const data = await api(`/api/memory/${encodeURIComponent(name)}`).catch(() => null);
4034
+ if (data?.topic) {
4035
+ $('#mem-title').value = data.topic.title ?? '';
4036
+ $('#mem-body').value = data.topic.body ?? '';
4037
+ }
4038
+ }
4039
+ memoryModal.showModal();
4040
+ if (!name) $('#mem-name').focus();
4041
+ }
4042
+
4043
+ function closeMemoryForm() {
4044
+ if (memoryModal.open) memoryModal.close();
4045
+ }
4046
+
4047
+ async function removeMemory(name) {
4048
+ await deleteEntity(`/api/memory/${encodeURIComponent(name)}`, {
4049
+ confirmText: `¿Borrar la memoria "${name}"?`,
4050
+ refresh: refreshMemory,
4051
+ });
4052
+ }
4053
+
4054
+ async function submitMemory(event) {
4055
+ event.preventDefault();
4056
+ const name = $('#mem-name').value.trim();
4057
+ if (!name) return setStatus('#mem-status', 'Falta el tema.', 'err');
4058
+ const body = { title: $('#mem-title').value.trim(), body: $('#mem-body').value };
4059
+ try {
4060
+ await api(`/api/memory/${encodeURIComponent(name)}`, { method: 'PUT', body });
4061
+ } catch (error) {
4062
+ return setStatus('#mem-status', error.message, 'err');
4063
+ }
4064
+ closeMemoryForm();
4065
+ refreshMemory();
4066
+ }
4067
+
4068
+ async function saveMemoryIndex() {
4069
+ try {
4070
+ await api('/api/memory', { method: 'PUT', body: { index: memoryIndexEl.value } });
4071
+ } catch (error) {
4072
+ return setStatus('#memory-status', error.message, 'err');
4073
+ }
4074
+ setStatus('#memory-status', 'guardado', 'ok');
4075
+ refreshMemory();
4076
+ }
4077
+
3992
4078
  async function refreshChannels() {
3993
4079
  if (!tgStatusEl) return;
3994
4080
  const data = await api('/api/channels').catch(() => null);
@@ -4665,6 +4751,15 @@ $('#asesor-test')?.addEventListener('click', testAsesor);
4665
4751
  skillModal.addEventListener('click', (event) => {
4666
4752
  if (event.target === skillModal) closeSkillForm();
4667
4753
  });
4754
+
4755
+ $('#memory-new')?.addEventListener('click', () => openMemoryForm());
4756
+ $('#memory-close')?.addEventListener('click', closeMemoryForm);
4757
+ $('#mem-cancel')?.addEventListener('click', closeMemoryForm);
4758
+ memoryForm?.addEventListener('submit', submitMemory);
4759
+ $('#memory-save-index')?.addEventListener('click', saveMemoryIndex);
4760
+ memoryModal?.addEventListener('click', (event) => {
4761
+ if (event.target === memoryModal) closeMemoryForm();
4762
+ });
4668
4763
  }
4669
4764
 
4670
4765
  /* --- eval ---------------------------------------------------------------- */
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.12.0</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.13.0</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -461,6 +461,7 @@
461
461
  <button type="button" class="ajustes-tab" data-panel="sesiones" role="tab">Sesiones</button>
462
462
  <button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
463
463
  <button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
464
+ <button type="button" class="ajustes-tab" data-panel="memoria" role="tab">Memoria</button>
464
465
  <button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
465
466
  <button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
466
467
  <button type="button" class="ajustes-tab" data-panel="asesor" role="tab">Asesor</button>
@@ -583,6 +584,25 @@
583
584
  <ul id="skill-list" class="providers"></ul>
584
585
  </div>
585
586
 
587
+ <div class="panel ajustes-panel" data-panel="memoria">
588
+ <div class="row between">
589
+ <h2>Memoria</h2>
590
+ <button id="memory-new" class="btn">+ tema</button>
591
+ </div>
592
+ <p class="muted verify-line">
593
+ Lo que el proyecto recuerda entre sesiones (<code>.tamnora/memory/</code>). El agente la mantiene con la
594
+ herramienta <code>memory</code>; acá la podés revisar y editar.
595
+ </p>
596
+ <label class="field"><span>índice (MEMORY.md)</span>
597
+ <textarea id="memory-index" rows="6" spellcheck="false" placeholder="# Memoria del proyecto"></textarea>
598
+ </label>
599
+ <div class="row between settings-actions">
600
+ <span id="memory-status" class="summary"></span>
601
+ <button id="memory-save-index" class="btn" type="button">guardar índice</button>
602
+ </div>
603
+ <ul id="memory-list" class="providers"></ul>
604
+ </div>
605
+
586
606
  <div class="panel ajustes-panel" data-panel="telegram">
587
607
  <div class="row between">
588
608
  <h2>Telegram</h2>
@@ -956,6 +976,31 @@
956
976
  </form>
957
977
  </dialog>
958
978
 
979
+ <dialog id="memory-modal" class="modal">
980
+ <form id="memory-form" class="modal-card">
981
+ <div class="row between">
982
+ <h3 id="memory-title">nuevo tema</h3>
983
+ <button type="button" id="memory-close" class="btn icon" title="cerrar" aria-label="cerrar">
984
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
985
+ <path d="M18 6 6 18M6 6l12 12" />
986
+ </svg>
987
+ </button>
988
+ </div>
989
+ <div class="row">
990
+ <label class="field"><span>tema (slug)</span><input id="mem-name" spellcheck="false" placeholder="arquitectura" /></label>
991
+ <label class="field grow"><span>título</span><input id="mem-title" spellcheck="false" placeholder="Arquitectura" /></label>
992
+ </div>
993
+ <label class="field"><span>contenido (markdown)</span>
994
+ <textarea id="mem-body" rows="10" spellcheck="false" placeholder="Qué recordar del proyecto…"></textarea>
995
+ </label>
996
+ <div class="row between settings-actions">
997
+ <button type="button" id="mem-cancel" class="btn">cancelar</button>
998
+ <button type="submit" class="btn primary">guardar</button>
999
+ </div>
1000
+ <div id="mem-status" class="summary"></div>
1001
+ </form>
1002
+ </dialog>
1003
+
959
1004
  <dialog id="palette-modal" class="modal palette-modal">
960
1005
  <div class="palette">
961
1006
  <div class="palette-row">
@@ -27,9 +27,9 @@ export function combineToolset({ local, admin = null, tasks = null, exposeAdmin
27
27
  ...(admin ? admin.definitions.filter((d) => adminAllowed(d.function.name)) : []),
28
28
  ...(tasks ? tasks.definitions : []),
29
29
  ];
30
- const execute = (name, args, context) => {
30
+ const execute = (name, args, context = {}) => {
31
31
  if (tasks?.handles(name)) return tasks.execute(name, args);
32
- if (adminAllowed(name)) return admin.execute(name, args, approve ? { approve } : undefined);
32
+ if (adminAllowed(name)) return admin.execute(name, args, { approve, ...context });
33
33
  return local.execute(name, args, context);
34
34
  };
35
35
  return { definitions, execute };
@@ -12,6 +12,12 @@ import {
12
12
  registryServerToConfig,
13
13
  normalizeMcpConfig,
14
14
  homeDir,
15
+ memorySlug,
16
+ listMemoryTopics,
17
+ readMemoryIndex,
18
+ readMemoryTopic,
19
+ writeMemory,
20
+ searchMemory,
15
21
  } from './core.js';
16
22
  import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled, findSkill } from './skills.js';
17
23
 
@@ -71,6 +77,18 @@ const DEFINITIONS = [
71
77
  },
72
78
  []
73
79
  ),
80
+ definition(
81
+ 'memory',
82
+ 'Memoria durable del proyecto, persiste entre sesiones. Acciones: list (temas), read (índice o un tema), search (busca) y write (crea/actualiza un tema). Guardá ahí lo que valga la pena recordar del proyecto.',
83
+ {
84
+ action: { type: 'string', enum: ['list', 'read', 'search', 'write'], description: 'Acción a ejecutar' },
85
+ topic: { type: 'string', description: 'Tema (slug) para read/write' },
86
+ query: { type: 'string', description: 'Texto a buscar (action=search)' },
87
+ title: { type: 'string', description: 'Título del tema (action=write)' },
88
+ content: { type: 'string', description: 'Contenido markdown (action=write)' },
89
+ },
90
+ ['action']
91
+ ),
74
92
  ];
75
93
 
76
94
  async function fetchText(url) {
@@ -90,24 +108,26 @@ async function fetchText(url) {
90
108
  }
91
109
  }
92
110
 
93
- function decisionFor(ctx, name) {
111
+ function decisionFor(ctx, name, args) {
94
112
  const perms = ctx.config.permissions ?? {};
95
113
  const value = perms[name];
96
114
  if (value === 'allow' || value === 'ask' || value === 'deny') return value;
115
+ if (name === 'memory') return String(args?.action ?? 'read').toLowerCase() === 'write' ? 'ask' : 'allow';
97
116
  return name.startsWith('list_') || name === 'skill' ? 'allow' : 'ask';
98
117
  }
99
118
 
100
119
  /**
101
- * Crea las tools de administracion. `execute(name, args, { approve })`, donde
102
- * `approve(name, args)` resuelve true/false para los permisos "ask".
120
+ * Crea las tools de administracion. `execute(name, args, { approve, cwd })`,
121
+ * donde `approve(name, args)` resuelve true/false para los permisos "ask" y
122
+ * `cwd` es la carpeta de la sesion (para la memoria del proyecto).
103
123
  */
104
124
  export function createAgentTools(ctx) {
105
125
  function handles(name) {
106
126
  return DEFINITIONS.some((d) => d.function.name === name);
107
127
  }
108
128
 
109
- async function execute(name, args = {}, { approve } = {}) {
110
- const decision = decisionFor(ctx, name);
129
+ async function execute(name, args = {}, { approve, cwd } = {}) {
130
+ const decision = decisionFor(ctx, name, args);
111
131
  if (decision === 'deny') return `Permiso denegado para "${name}".`;
112
132
  if (decision === 'ask') {
113
133
  const ok = approve ? await approve(name, args) : false;
@@ -248,6 +268,41 @@ export function createAgentTools(ctx) {
248
268
  return `Skill "${id}" instalada.`;
249
269
  }
250
270
 
271
+ case 'memory': {
272
+ const root = cwd ?? ctx.cwd;
273
+ const action = String(args.action ?? 'read').toLowerCase();
274
+ if (action === 'list') {
275
+ const topics = listMemoryTopics(root);
276
+ if (!topics.length) return 'No hay memoria guardada todavía para este proyecto.';
277
+ return JSON.stringify(topics.map(({ name, title, bytes }) => ({ name, title, bytes })), null, 2);
278
+ }
279
+ if (action === 'search') {
280
+ const query = String(args.query ?? '').trim();
281
+ if (!query) return 'Pasá "query" para buscar en la memoria.';
282
+ const hits = searchMemory(root, query);
283
+ if (!hits.length) return `Sin resultados para "${query}".`;
284
+ return hits.map((h) => `### ${h.title} (${h.name})\n${h.snippet}`).join('\n\n');
285
+ }
286
+ if (action === 'write') {
287
+ const content = String(args.content ?? '').trim();
288
+ if (!content) return 'Pasá "content" para guardar en memoria.';
289
+ try {
290
+ const saved = await writeMemory(root, { topic: args.topic ?? args.title, title: args.title, content });
291
+ return `Guardado en memoria: ${saved.name} (${saved.title}).`;
292
+ } catch (error) {
293
+ return `No se pudo guardar la memoria: ${error.message}`;
294
+ }
295
+ }
296
+ const topic = String(args.topic ?? '').trim();
297
+ if (topic) {
298
+ const name = topic.endsWith('.md') ? topic : `${memorySlug(topic)}.md`;
299
+ const read = readMemoryTopic(root, name);
300
+ if (!read) return `No hay memoria del tema "${topic}".`;
301
+ return read.body;
302
+ }
303
+ return readMemoryIndex(root).trim() || 'No hay índice de memoria todavía.';
304
+ }
305
+
251
306
  default:
252
307
  return `Herramienta desconocida: ${name}`;
253
308
  }
@@ -25,6 +25,7 @@ import {
25
25
  toolCallPayload,
26
26
  } from '../agentRun.js';
27
27
  import { mp3AOgg } from '../ffmpeg.js';
28
+ import { createRulesInjector } from '../rules.js';
28
29
  import { resolveTts } from '../ttsSettings.js';
29
30
  import { transcribeAudio } from '../transcribe.js';
30
31
 
@@ -505,6 +506,8 @@ export function startTelegramChannel(
505
506
  messages,
506
507
  tools: definitions,
507
508
  execute,
509
+ toolContext: { cwd, sessionId },
510
+ onLifecycle: createRulesInjector({ cwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
508
511
  signal: controller.signal,
509
512
  maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
510
513
  maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
@@ -1,4 +1,4 @@
1
- import { loadInstructions } from './core.js';
1
+ import { loadInstructions, loadRules, buildStaticRulesBlock, memoryPrompt } from './core.js';
2
2
 
3
3
  export const DEFAULT_SYSTEM_PROMPT =
4
4
  'Eres Tamnora, un asistente útil. Responde en el idioma del usuario y usa las herramientas cuando aporten valor.';
@@ -34,14 +34,21 @@ function workspacePreamble(cwd) {
34
34
  * @param {() => string} [opts.profileText] - user profile block, re-read on every call
35
35
  * @param {() => string} [opts.skillsText] - skills block, re-read on every call
36
36
  * @param {() => string} [opts.scopeText] - strict-scope block, re-read on every call
37
+ * @param {string[]} [opts.ruleFiles] - glob patterns of project rule files
38
+ * @param {boolean} [opts.memory] - inject the project memory block
37
39
  */
38
- export function createPromptBuilder({ cwd, files, profileText, skillsText, scopeText }) {
40
+ export function createPromptBuilder({ cwd, files, profileText, skillsText, scopeText, ruleFiles = [], memory = false }) {
39
41
  const cache = new Map();
40
42
 
41
43
  async function buildFor(workspace) {
42
44
  const instructions = (await loadInstructions(workspace, files)).trim();
43
45
  const base = workspacePreamble(workspace);
44
- return instructions ? `${base}\n\n---\n\n${instructions}` : base;
46
+ let text = instructions ? `${base}\n\n---\n\n${instructions}` : base;
47
+ if (ruleFiles.length) {
48
+ const block = buildStaticRulesBlock(await loadRules(workspace, ruleFiles).catch(() => []));
49
+ if (block) text = `${text}\n\n---\n\n${block}`;
50
+ }
51
+ return text;
45
52
  }
46
53
 
47
54
  return async function systemPromptFor(workspace = cwd) {
@@ -53,6 +60,10 @@ export function createPromptBuilder({ cwd, files, profileText, skillsText, scope
53
60
  if (profile) parts.push(profile);
54
61
  const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
55
62
  if (extra) parts.push(extra);
63
+ if (memory) {
64
+ const mem = memoryPrompt(key);
65
+ if (mem) parts.push(mem);
66
+ }
56
67
  const scope = typeof scopeText === 'function' ? String(scopeText() ?? '').trim() : '';
57
68
  if (scope) parts.push(scope);
58
69
  return parts.join('\n\n---\n\n');
@@ -0,0 +1,43 @@
1
+ // Reglas de proyecto por path inyectadas en runtime. Se engancha al seam de
2
+ // ciclo de vida del agente: cuando una tool toca un archivo que matchea el
3
+ // `paths:` de una regla, se agrega al contexto de las proximas rondas (efimera,
4
+ // no se persiste). Dedup: una instancia por run, cada regla una vez.
5
+
6
+ import { loadRules, rulesForPath, buildPathRulesBlock } from './core.js';
7
+
8
+ const PATH_TOOLS = new Set(['read', 'write', 'edit', 'list']);
9
+
10
+ /**
11
+ * Crea un handler `onLifecycle(event, payload)` para las reglas por path.
12
+ * Devuelve `null` si no hay patrones configurados.
13
+ */
14
+ export function createRulesInjector({ cwd, patterns = [] }) {
15
+ if (!patterns.length) return null;
16
+ let cached = null;
17
+ let loading = null;
18
+ const injected = new Set();
19
+
20
+ async function all() {
21
+ if (cached) return cached;
22
+ if (!loading) {
23
+ loading = loadRules(cwd, patterns)
24
+ .catch(() => [])
25
+ .then((rules) => {
26
+ cached = rules;
27
+ return rules;
28
+ });
29
+ }
30
+ return loading;
31
+ }
32
+
33
+ return async function onLifecycle(event) {
34
+ if (event?.type !== 'tool.after' || !PATH_TOOLS.has(event.name)) return undefined;
35
+ const rel = event.args?.path ?? event.args?.file;
36
+ if (!rel || typeof rel !== 'string') return undefined;
37
+ const rules = await all();
38
+ const matches = rulesForPath(rules, rel).filter((rule) => !injected.has(rule.id));
39
+ if (!matches.length) return undefined;
40
+ for (const rule of matches) injected.add(rule.id);
41
+ return { inject: buildPathRulesBlock(matches, rel) };
42
+ };
43
+ }
@@ -19,6 +19,7 @@ import {
19
19
  import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/announcer.js';
20
20
  import { resolveAdvisor, advisorShouldRun, consultAdvisor } from '../lib/advisor.js';
21
21
  import { maybeAutoCompact } from '../lib/compact.js';
22
+ import { createRulesInjector } from '../lib/rules.js';
22
23
 
23
24
  /**
24
25
  * Emit streaming partials at most once per `ms` window, keeping only the
@@ -268,6 +269,8 @@ export function registerChat(route, ctx) {
268
269
  messages,
269
270
  tools: definitions,
270
271
  execute,
272
+ toolContext: { cwd: sessionCwd, sessionId },
273
+ onLifecycle: createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
271
274
  signal: ctx.runs.signal(sessionId),
272
275
  maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
273
276
  maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
@@ -0,0 +1,66 @@
1
+ // Memoria durable del proyecto (`<cwd>/.tamnora/memory`). El agente la usa con la
2
+ // tool `memory`; la consola la lista, edita y borra desde la pestaña Memoria.
3
+
4
+ import {
5
+ memoryRoot,
6
+ isMemoryTopic,
7
+ readMemoryIndex,
8
+ listMemoryTopics,
9
+ readMemoryTopic,
10
+ writeMemory,
11
+ deleteMemory,
12
+ writeMemoryIndex,
13
+ } from '../lib/core.js';
14
+ import { readJson, sendJson, sendError } from '../lib/http.js';
15
+
16
+ /** Acepta el slug o el nombre de archivo (`x` o `x.md`). */
17
+ function topicFile(name) {
18
+ const raw = String(name ?? '');
19
+ return /\.md$/i.test(raw) ? raw : `${raw}.md`;
20
+ }
21
+
22
+ function memoryView(cwd) {
23
+ return {
24
+ root: memoryRoot(cwd),
25
+ index: readMemoryIndex(cwd),
26
+ topics: listMemoryTopics(cwd),
27
+ };
28
+ }
29
+
30
+ export function registerMemory(route, ctx) {
31
+ route('GET', '/api/memory', ({ res }) => {
32
+ sendJson(res, 200, memoryView(ctx.cwd));
33
+ });
34
+
35
+ route('PUT', '/api/memory', async ({ req, res }) => {
36
+ const body = await readJson(req);
37
+ await writeMemoryIndex(ctx.cwd, String(body.index ?? ''));
38
+ sendJson(res, 200, memoryView(ctx.cwd));
39
+ });
40
+
41
+ route('GET', '/api/memory/:name', ({ res, params }) => {
42
+ const topic = readMemoryTopic(ctx.cwd, topicFile(params.name));
43
+ if (!topic) return sendError(res, 404, 'Memoria no encontrada.');
44
+ sendJson(res, 200, { topic });
45
+ });
46
+
47
+ route('PUT', '/api/memory/:name', async ({ req, res, params }) => {
48
+ const file = topicFile(params.name);
49
+ if (!isMemoryTopic(file)) return sendError(res, 400, 'Nombre de memoria inválido.');
50
+ const body = await readJson(req);
51
+ const content = String(body.body ?? body.content ?? '').trim();
52
+ if (!content) return sendError(res, 400, 'La memoria necesita contenido.');
53
+ await writeMemory(ctx.cwd, {
54
+ topic: file.replace(/\.md$/i, ''),
55
+ title: body.title,
56
+ content,
57
+ });
58
+ sendJson(res, 200, memoryView(ctx.cwd));
59
+ });
60
+
61
+ route('DELETE', '/api/memory/:name', async ({ res, params }) => {
62
+ const removed = await deleteMemory(ctx.cwd, topicFile(params.name));
63
+ if (!removed) return sendError(res, 404, 'Memoria no encontrada.');
64
+ sendJson(res, 200, memoryView(ctx.cwd));
65
+ });
66
+ }
@@ -36,6 +36,7 @@ import { registerModels } from './routes/models.js';
36
36
  import { registerProviders } from './routes/providers.js';
37
37
  import { registerMcp } from './routes/mcp.js';
38
38
  import { registerSkills } from './routes/skills.js';
39
+ import { registerMemory } from './routes/memory.js';
39
40
  import { registerChannels } from './routes/channels.js';
40
41
  import { registerProfile } from './routes/profile.js';
41
42
  import { registerTts } from './routes/tts.js';
@@ -208,6 +209,8 @@ export async function createTamnoraServer({
208
209
  profileText: () => profilePrompt(resolveProfile(ctx.app)),
209
210
  skillsText: () => skillsBlock(ctx),
210
211
  scopeText: () => scopeDirective(ctx.effectiveStrictScope()),
212
+ ruleFiles: config.rules ?? [],
213
+ memory: config.memory?.enabled !== false,
211
214
  });
212
215
  ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
213
216
  ctx.agentTools = createAgentTools(ctx);
@@ -245,6 +248,7 @@ export async function createTamnoraServer({
245
248
  registerProviders(route, ctx);
246
249
  registerMcp(route, ctx);
247
250
  registerSkills(route, ctx);
251
+ registerMemory(route, ctx);
248
252
  registerChannels(route, ctx);
249
253
  registerProfile(route, ctx);
250
254
  registerTts(route, ctx);