@danieltmn/tamnora 0.5.0 → 0.6.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 CHANGED
@@ -5,6 +5,19 @@ 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.6.0] - 2026-10-04
9
+
10
+ ### Cambios
11
+
12
+ - docs: actividad colapsable, runs durables, subagentes y canales
13
+ - feat(server): experiencia remota con actividad colapsable, runs durables, subagentes y canales
14
+
15
+ ## [0.5.1] - 2026-10-02
16
+
17
+ ### Cambios
18
+
19
+ - docs: como actualizar desde versiones viejas
20
+
8
21
  ## [0.5.0] - 2026-10-02
9
22
 
10
23
  ### Agregado
package/README.md CHANGED
@@ -63,6 +63,11 @@ Tamnora (override con `--home` o `TAMNORA_HOME`):
63
63
  - macOS: `~/Library/Application Support/tamnora`
64
64
  - Linux: `~/.config/tamnora`
65
65
 
66
+ > **Actualizar**: en `0.4.1+` alcanza con `tamnora update` (o `--yes`). Si venis de
67
+ > **`0.4.0` o anterior**, `tamnora update` fallaba en Windows, asi que hace una vez
68
+ > `npm i -g @danieltmn/tamnora@latest` (agrega `--prefer-online` si el cache de npm
69
+ > devuelve la version vieja) o corre con `npx @danieltmn/tamnora@latest`.
70
+
66
71
  ### Consola web
67
72
 
68
73
  `tamnora serve` sirve la consola en `http://127.0.0.1:4517`. Las pestanas
@@ -15,6 +15,7 @@ export const DEFAULT_CONFIG = {
15
15
  processes: { enabled: true, allow: [] },
16
16
  preview: { provider: 'tunnelmole' },
17
17
  telegram: { token: '', allowedChatIds: [] },
18
+ agents: { maxRounds: 6 },
18
19
  };
19
20
 
20
21
  /** Directory (relative to the project) where harness state lived historically. */
@@ -3,6 +3,7 @@ export { ensureUtf8Console } from './console.js';
3
3
  export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
4
4
  export { estimateTokens, trimContext } from './agent/context.js';
5
5
  export { diffLines } from './tools/diff.js';
6
+ export { summarizeToolCall, previewToolResult } from './tools/labels.js';
6
7
  export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
7
8
  export { withRetry, isRetryableError, isAbortError } from './providers/resilience.js';
8
9
  export {
@@ -28,12 +28,37 @@ CREATE TABLE IF NOT EXISTS messages (
28
28
  tool_call_id TEXT,
29
29
  name TEXT,
30
30
  mode TEXT,
31
+ agent_id TEXT,
31
32
  tokens_in INTEGER,
32
33
  tokens_out INTEGER,
33
34
  created_at INTEGER
34
35
  );
35
36
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
36
37
 
38
+ CREATE TABLE IF NOT EXISTS agents (
39
+ id TEXT PRIMARY KEY,
40
+ session_id TEXT NOT NULL,
41
+ parent_agent_id TEXT,
42
+ kind TEXT,
43
+ title TEXT,
44
+ status TEXT,
45
+ model TEXT,
46
+ summary TEXT,
47
+ created_at INTEGER,
48
+ updated_at INTEGER
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_agents_session ON agents(session_id, created_at);
51
+
52
+ CREATE TABLE IF NOT EXISTS session_events (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ session_id TEXT NOT NULL,
55
+ agent_id TEXT,
56
+ type TEXT NOT NULL,
57
+ data TEXT,
58
+ created_at INTEGER
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_session_events ON session_events(session_id, id);
61
+
37
62
  CREATE TABLE IF NOT EXISTS runs (
38
63
  id TEXT PRIMARY KEY,
39
64
  dataset TEXT,
@@ -107,6 +132,7 @@ export function openDatabase(file) {
107
132
  function migrate(db) {
108
133
  const columns = db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name);
109
134
  if (!columns.includes('mode')) db.exec('ALTER TABLE messages ADD COLUMN mode TEXT');
135
+ if (!columns.includes('agent_id')) db.exec('ALTER TABLE messages ADD COLUMN agent_id TEXT');
110
136
  }
111
137
 
112
138
  const now = () => Date.now();
@@ -261,8 +287,8 @@ export class Store {
261
287
  const id = randomUUID();
262
288
  this.db
263
289
  .prepare(
264
- `INSERT INTO messages (id, session_id, role, content, tool_calls, tool_call_id, name, mode, tokens_in, tokens_out, created_at)
265
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
290
+ `INSERT INTO messages (id, session_id, role, content, tool_calls, tool_call_id, name, mode, agent_id, tokens_in, tokens_out, created_at)
291
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
266
292
  )
267
293
  .run(
268
294
  id,
@@ -273,6 +299,7 @@ export class Store {
273
299
  message.tool_call_id ?? null,
274
300
  message.name ?? null,
275
301
  message.mode ?? null,
302
+ message.agent_id ?? null,
276
303
  message.tokens_in ?? null,
277
304
  message.tokens_out ?? null,
278
305
  now()
@@ -298,10 +325,74 @@ export class Store {
298
325
  if (row.tool_call_id) message.tool_call_id = row.tool_call_id;
299
326
  if (row.name) message.name = row.name;
300
327
  if (row.mode) message.mode = row.mode;
328
+ if (row.agent_id) message.agent_id = row.agent_id;
301
329
  return message;
302
330
  });
303
331
  }
304
332
 
333
+ // --- Agents (subagentes) ------------------------------------------------
334
+
335
+ createAgent({ sessionId, parentAgentId = null, kind = 'task', title, model } = {}) {
336
+ const id = randomUUID();
337
+ const ts = now();
338
+ this.db
339
+ .prepare(
340
+ `INSERT INTO agents (id, session_id, parent_agent_id, kind, title, status, model, created_at, updated_at)
341
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
342
+ )
343
+ .run(id, sessionId, parentAgentId, kind, title ?? null, 'running', model ?? null, ts, ts);
344
+ return id;
345
+ }
346
+
347
+ finishAgent(id, { status = 'done', summary } = {}) {
348
+ this.db
349
+ .prepare(`UPDATE agents SET status = ?, summary = ?, updated_at = ? WHERE id = ?`)
350
+ .run(status, summary ?? null, now(), id);
351
+ }
352
+
353
+ getAgent(id) {
354
+ const row = this.db.prepare(`SELECT * FROM agents WHERE id = ?`).get(id);
355
+ return row ? toAgent(row) : null;
356
+ }
357
+
358
+ listAgents(sessionId) {
359
+ return this.db
360
+ .prepare(`SELECT * FROM agents WHERE session_id = ? ORDER BY created_at ASC`)
361
+ .all(sessionId)
362
+ .map(toAgent);
363
+ }
364
+
365
+ // --- Session events (run log for reconnection) --------------------------
366
+
367
+ addSessionEvent(sessionId, { agentId = null, type, data } = {}) {
368
+ const info = this.db
369
+ .prepare(`INSERT INTO session_events (session_id, agent_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)`)
370
+ .run(sessionId, agentId, type, json(data), now());
371
+ return Number(info.lastInsertRowid);
372
+ }
373
+
374
+ updateSessionEvent(id, data) {
375
+ this.db.prepare(`UPDATE session_events SET data = ? WHERE id = ?`).run(json(data), id);
376
+ }
377
+
378
+ getSessionEvents(sessionId, { from = 0, limit = 5000 } = {}) {
379
+ return this.db
380
+ .prepare(`SELECT * FROM session_events WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT ?`)
381
+ .all(sessionId, from, limit)
382
+ .map((row) => ({ id: row.id, agentId: row.agent_id, type: row.type, data: parseJson(row.data), createdAt: row.created_at }));
383
+ }
384
+
385
+ lastSessionEventId(sessionId) {
386
+ const row = this.db
387
+ .prepare(`SELECT COALESCE(MAX(id), 0) AS id FROM session_events WHERE session_id = ?`)
388
+ .get(sessionId);
389
+ return row?.id ?? 0;
390
+ }
391
+
392
+ clearSessionEvents(sessionId) {
393
+ this.db.prepare(`DELETE FROM session_events WHERE session_id = ?`).run(sessionId);
394
+ }
395
+
305
396
  // --- Runs (evaluation) --------------------------------------------------
306
397
 
307
398
  createRun({ dataset, config } = {}) {
@@ -468,6 +559,21 @@ export class Store {
468
559
  }
469
560
  }
470
561
 
562
+ function toAgent(row) {
563
+ return {
564
+ id: row.id,
565
+ sessionId: row.session_id,
566
+ parentAgentId: row.parent_agent_id ?? null,
567
+ kind: row.kind ?? 'task',
568
+ title: row.title ?? '',
569
+ status: row.status ?? 'running',
570
+ model: row.model ?? null,
571
+ summary: row.summary ?? null,
572
+ createdAt: row.created_at,
573
+ updatedAt: row.updated_at,
574
+ };
575
+ }
576
+
471
577
  function toProvider(row) {
472
578
  return {
473
579
  id: row.id,
@@ -0,0 +1,83 @@
1
+ // Etiquetas legibles para tool-calls y previews de resultados. Las usa la
2
+ // consola (y cualquier canal) para mostrar la actividad del agente de forma
3
+ // compacta, colapsable y apta para pantallas chicas.
4
+
5
+ function text(value) {
6
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
7
+ }
8
+
9
+ function shorten(value, max = 80) {
10
+ const raw = text(value);
11
+ return raw.length > max ? `${raw.slice(0, max - 1)}…` : raw;
12
+ }
13
+
14
+ /**
15
+ * Una linea con la esencia de un tool-call: `read src/app.js`,
16
+ * `grep "foo" *.js`, `bash npm test`. Devuelve '' si no hay nada util.
17
+ */
18
+ export function summarizeToolCall(name, args = {}) {
19
+ const a = args && typeof args === 'object' ? args : {};
20
+ const tool = String(name ?? '');
21
+ const path = (value) => (value ? shorten(value, 60) : '');
22
+
23
+ switch (tool) {
24
+ case 'read': {
25
+ const base = path(a.path) || '.';
26
+ const offset = Number(a.offset) || 0;
27
+ const limit = Number(a.limit) || 0;
28
+ if (offset && limit) return `${base}:${offset}-${offset + limit - 1}`;
29
+ if (offset) return `${base}:${offset}+`;
30
+ return base;
31
+ }
32
+ case 'write':
33
+ case 'edit':
34
+ return path(a.path) || 'archivo';
35
+ case 'list':
36
+ return path(a.path) || '.';
37
+ case 'glob':
38
+ return [a.pattern ? `"${shorten(a.pattern, 50)}"` : '', a.path ? `en ${path(a.path)}` : ''].filter(Boolean).join(' ');
39
+ case 'grep':
40
+ return [
41
+ a.pattern ? `"${shorten(a.pattern, 50)}"` : '',
42
+ a.include ? `${shorten(a.include, 30)}` : '',
43
+ a.path ? `en ${path(a.path)}` : '',
44
+ ]
45
+ .filter(Boolean)
46
+ .join(' ');
47
+ case 'bash': {
48
+ const line = text(a.command).split('\n')[0];
49
+ return shorten(line, 90);
50
+ }
51
+ case 'fetch_url':
52
+ return shorten(a.url, 90);
53
+ case 'git_diff':
54
+ return [a.ref ? `vs ${shorten(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? path(a.path) : '']
55
+ .filter(Boolean)
56
+ .join(' ');
57
+ case 'git_log':
58
+ return a.limit ? `${a.limit} commits` : '';
59
+ case 'git_commit':
60
+ return shorten(a.message, 70);
61
+ case 'task':
62
+ case 'spawn_agent':
63
+ return shorten(a.description || a.prompt, 80);
64
+ default:
65
+ return '';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Recorta un resultado para mostrarlo como preview. Devuelve el texto y si
71
+ * quedo truncado (para ofrecer "ver completo" bajo demanda).
72
+ */
73
+ export function previewToolResult(_name, result, { maxLines = 24, maxChars = 4000 } = {}) {
74
+ const value = typeof result === 'string' ? result : JSON.stringify(result ?? '', null, 2) ?? '';
75
+ const lines = value.split('\n');
76
+ let preview = lines.slice(0, maxLines).join('\n');
77
+ let truncated = lines.length > maxLines;
78
+ if (preview.length > maxChars) {
79
+ preview = preview.slice(0, maxChars);
80
+ truncated = true;
81
+ }
82
+ return { preview, truncated, totalLines: lines.length };
83
+ }
@@ -0,0 +1,34 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { summarizeToolCall, previewToolResult } from '../src/index.js';
4
+
5
+ test('summarizeToolCall describe las tools de exploracion', () => {
6
+ assert.equal(summarizeToolCall('read', { path: 'src/app.js' }), 'src/app.js');
7
+ assert.equal(summarizeToolCall('read', { path: 'src/app.js', offset: 10, limit: 5 }), 'src/app.js:10-14');
8
+ assert.equal(summarizeToolCall('list', {}), '.');
9
+ assert.equal(summarizeToolCall('glob', { pattern: 'src/**/*.js' }), '"src/**/*.js"');
10
+ assert.equal(summarizeToolCall('grep', { pattern: 'foo', include: '*.js' }), '"foo" *.js');
11
+ });
12
+
13
+ test('summarizeToolCall describe acciones y comandos', () => {
14
+ assert.equal(summarizeToolCall('bash', { command: 'npm test\n--watch' }), 'npm test --watch');
15
+ assert.equal(summarizeToolCall('write', { path: 'a/b.txt' }), 'a/b.txt');
16
+ assert.equal(summarizeToolCall('git_commit', { message: 'feat: algo' }), 'feat: algo');
17
+ });
18
+
19
+ test('summarizeToolCall tolera args invalidos y recorta', () => {
20
+ assert.equal(summarizeToolCall('read', null), '.');
21
+ assert.equal(summarizeToolCall('desconocida', { a: 1 }), '');
22
+ assert.ok(summarizeToolCall('bash', { command: 'x'.repeat(500) }).length <= 90);
23
+ });
24
+
25
+ test('previewToolResult recorta por lineas y marca truncado', () => {
26
+ const short = previewToolResult('read', 'uno\ndos\ntres');
27
+ assert.equal(short.preview, 'uno\ndos\ntres');
28
+ assert.equal(short.truncated, false);
29
+ assert.equal(short.totalLines, 3);
30
+
31
+ const long = previewToolResult('read', Array.from({ length: 100 }, (_, i) => `linea ${i}`).join('\n'));
32
+ assert.equal(long.truncated, true);
33
+ assert.equal(long.preview.split('\n').length, 24);
34
+ });