@danieltmn/tamnora 0.19.0 → 0.20.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 +38 -0
- package/README.md +6 -3
- package/harness/packages/cli/src/commands/tui.js +2 -2
- package/harness/packages/cli/src/tui/app.js +2 -1
- package/harness/packages/core/src/agent/compact.js +12 -2
- package/harness/packages/core/src/agent/modes.js +29 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +30 -17
- package/harness/packages/core/src/providers/stream.js +81 -7
- package/harness/packages/core/src/tools/registry.js +21 -1
- package/harness/packages/core/test/compact.test.js +4 -2
- package/harness/packages/core/test/providers.test.js +29 -0
- package/harness/packages/core/test/stream.test.js +46 -0
- package/harness/packages/core/test/tools.test.js +30 -0
- package/harness/packages/server/public/app.js +376 -47
- package/harness/packages/server/public/index.html +17 -3
- package/harness/packages/server/public/styles.css +25 -0
- package/harness/packages/server/src/lib/advisor.js +14 -13
- package/harness/packages/server/src/lib/compact.js +26 -1
- package/harness/packages/server/src/lib/scheduler.js +1 -0
- package/harness/packages/server/src/lib/sessionView.js +1 -0
- package/harness/packages/server/src/lib/tasks.js +11 -1
- package/harness/packages/server/src/routes/chat.js +20 -26
- package/harness/packages/server/src/routes/sessions.js +20 -11
- package/harness/packages/server/test/advisor.test.js +17 -13
- package/harness/packages/server/test/server.test.js +154 -51
- package/package.json +1 -1
|
@@ -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.
|
|
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.20.1</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>
|
|
@@ -184,6 +184,11 @@
|
|
|
184
184
|
<span>Full access</span>
|
|
185
185
|
<svg class="ctrl-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
|
186
186
|
</button>
|
|
187
|
+
<button type="button" class="ctrl-menu-item" data-kind="voice" role="menuitemradio" aria-checked="false" title="conversación por voz: solo notas .md y .json">
|
|
188
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19v3" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><path d="M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Z" /></svg>
|
|
189
|
+
<span>Voz (.md / .json)</span>
|
|
190
|
+
<svg class="ctrl-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
|
191
|
+
</button>
|
|
187
192
|
</div>
|
|
188
193
|
</div>
|
|
189
194
|
</div>
|
|
@@ -200,6 +205,15 @@
|
|
|
200
205
|
</svg>
|
|
201
206
|
<span class="mic-count" hidden></span>
|
|
202
207
|
</button>
|
|
208
|
+
<button type="button" id="hands-free-btn" class="add-btn" title="manos libres: conversación por voz" aria-label="manos libres" aria-pressed="false">
|
|
209
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
210
|
+
<path d="M4.9 19.1a10 10 0 0 1 0-14.2" />
|
|
211
|
+
<path d="M7.8 16.2a6 6 0 0 1 0-8.4" />
|
|
212
|
+
<circle cx="12" cy="12" r="2" />
|
|
213
|
+
<path d="M16.2 7.8a6 6 0 0 1 0 8.4" />
|
|
214
|
+
<path d="M19.1 4.9a10 10 0 0 1 0 14.2" />
|
|
215
|
+
</svg>
|
|
216
|
+
</button>
|
|
203
217
|
<input id="image-input" type="file" accept="image/*" multiple hidden />
|
|
204
218
|
<textarea id="chat-input" rows="1" placeholder="escribí un mensaje…" title="Enter envía · Shift+Enter salto" spellcheck="false"></textarea>
|
|
205
219
|
<button type="button" id="model-pill" class="ctrl-pill model-pill" title="proveedor y modelo activos">
|
|
@@ -758,8 +772,8 @@
|
|
|
758
772
|
<label class="check" title="avisar al terminar">
|
|
759
773
|
<input id="voz-ann-end" type="checkbox" /> el cierre
|
|
760
774
|
</label>
|
|
761
|
-
<label class="check" title="leer los consejos del asesor">
|
|
762
|
-
<input id="voz-ann-advice" type="checkbox" /> los consejos del asesor
|
|
775
|
+
<label class="check" title="leer los consejos y el resumen del asesor">
|
|
776
|
+
<input id="voz-ann-advice" type="checkbox" /> los consejos/resumen del asesor
|
|
763
777
|
</label>
|
|
764
778
|
<label class="check" title="leer un resumen breve de lo que hizo el agente">
|
|
765
779
|
<input id="voz-ann-summary" type="checkbox" /> Explicar resumidamente qué hizo
|
|
@@ -1092,6 +1092,9 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
|
|
|
1092
1092
|
.add-btn.recording { color: #2eb872; border-color: currentColor; animation: live-pulse 1s ease-in-out infinite; }
|
|
1093
1093
|
.add-btn.busy { opacity: 0.55; }
|
|
1094
1094
|
.add-btn.mic-counting > svg { visibility: hidden; }
|
|
1095
|
+
/* Manos libres: encendido (verde fijo) y escuchando (pulso). */
|
|
1096
|
+
.add-btn.hands-free-active { color: #2eb872; border-color: currentColor; }
|
|
1097
|
+
.add-btn.hands-free-active.listening { animation: live-pulse 1s ease-in-out infinite; }
|
|
1095
1098
|
.mic-count {
|
|
1096
1099
|
position: absolute;
|
|
1097
1100
|
inset: 0;
|
|
@@ -2074,5 +2077,27 @@ dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: bl
|
|
|
2074
2077
|
.advisor-summary .advisor-glyph,
|
|
2075
2078
|
.advisor-summary .advisor-badge { color: var(--muted); }
|
|
2076
2079
|
|
|
2080
|
+
/* compact marker ---------------------------------------------------------- */
|
|
2081
|
+
.compact-card { margin: 6px 0; }
|
|
2082
|
+
.compact-rule {
|
|
2083
|
+
display: flex;
|
|
2084
|
+
align-items: center;
|
|
2085
|
+
gap: 10px;
|
|
2086
|
+
font: 600 10px var(--mono);
|
|
2087
|
+
letter-spacing: 0.12em;
|
|
2088
|
+
text-transform: uppercase;
|
|
2089
|
+
color: var(--muted);
|
|
2090
|
+
}
|
|
2091
|
+
.compact-rule::before,
|
|
2092
|
+
.compact-rule::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
|
2093
|
+
.compact-text {
|
|
2094
|
+
margin-top: 10px;
|
|
2095
|
+
padding-left: 12px;
|
|
2096
|
+
border-left: 2px dashed var(--border);
|
|
2097
|
+
word-break: break-word;
|
|
2098
|
+
color: var(--text);
|
|
2099
|
+
line-height: 1.5;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2077
2102
|
.brand .ver.ver-action { cursor: pointer; }
|
|
2078
2103
|
.brand .ver.ver-action:hover { color: var(--accent); }
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// el agente hizo (pedido, respuesta, herramientas y diffs), lo interpreta con
|
|
3
3
|
// una llamada corta al modelo y decide si vale la pena decir algo. NO lee la
|
|
4
4
|
// respuesta del agente: solo agrega alertas de codigo, explicaciones o consejos.
|
|
5
|
-
// Opcionalmente (options.summary)
|
|
5
|
+
// Opcionalmente (options.summary) arranca el mensaje con un resumen breve: un
|
|
6
|
+
// SOLO texto continuo (resumen + alerta/consejo), como hablaria Jarvis.
|
|
6
7
|
//
|
|
7
8
|
// La salida es JSON estricto para poder decidir el silencio: si el modelo no
|
|
8
9
|
// tiene nada que aportar, no habla ni deja tarjeta.
|
|
@@ -21,7 +22,7 @@ export const DEFAULT_ADVISOR = {
|
|
|
21
22
|
model: '',
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
const KINDS = new Set(['code', 'explain', 'advice']);
|
|
25
|
+
const KINDS = new Set(['code', 'explain', 'advice', 'summary']);
|
|
25
26
|
const MAX_PROMPT_CHARS = 8000;
|
|
26
27
|
const MAX_DIFF_CHARS = 2500;
|
|
27
28
|
|
|
@@ -118,23 +119,24 @@ export function buildAdvisorPrompt({ userInput = '', answer = '', toolSummary =
|
|
|
118
119
|
'Recibis lo que el agente hizo en el ultimo turno: el pedido del usuario, su respuesta\n' +
|
|
119
120
|
'final, las herramientas que uso y los diffs de los archivos que toco.\n\n' +
|
|
120
121
|
(wantsAdvice
|
|
121
|
-
? 'Tu trabajo NO es
|
|
122
|
-
'
|
|
122
|
+
? 'Tu trabajo NO es repetir la respuesta del agente: el usuario ya la esta viendo.\n' +
|
|
123
|
+
'Solo aportas algo que el agente no dijo. Enfocate en:\n' +
|
|
123
124
|
`${focusLines(options)}\n` +
|
|
124
125
|
'Si no hay nada que valga la pena decir, pon "speak" en false.\n\n'
|
|
125
126
|
: '') +
|
|
126
127
|
(wantsSummary
|
|
127
|
-
? '
|
|
128
|
-
'frases, que se pidio y que se hizo, sin copiar la respuesta
|
|
129
|
-
'
|
|
128
|
+
? 'El usuario quiere UN SOLO mensaje continuo: arrancá con un resumen muy breve de\n' +
|
|
129
|
+
'lo que se hizo (una o dos frases, que se pidio y que se hizo, sin copiar la respuesta)\n' +
|
|
130
|
+
'y, si tenes un consejo o una alerta, continua con eso en el mismo texto.\n' +
|
|
131
|
+
'Si no hay nada que alertar, limita el mensaje al resumen y usa "kind": "summary".\n\n'
|
|
130
132
|
: '') +
|
|
131
133
|
'Hablale al usuario en segunda persona, breve (' +
|
|
132
134
|
`${maxWords} palabras como maximo), en espanol, sin markdown ni listas. Si no hay nada\n` +
|
|
133
135
|
'que valga la pena decir, no hables.\n' +
|
|
134
136
|
(options.notes ? `\nInstrucciones del usuario: ${options.notes}\n` : '') +
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
`\nResponde SOLO con JSON: {"speak": true|false, "kind": "${
|
|
138
|
+
wantsSummary ? 'code|explain|advice|summary' : 'code|explain|advice'
|
|
139
|
+
}", "text": "..."}`;
|
|
138
140
|
|
|
139
141
|
const parts = [`PEDIDO DEL USUARIO:\n${userInput || '(sin texto)'}`];
|
|
140
142
|
parts.push(`RESPUESTA FINAL DEL AGENTE:\n${answer || '(sin respuesta)'}`);
|
|
@@ -188,9 +190,8 @@ export async function consultAdvisor({
|
|
|
188
190
|
if (error || !value || typeof value !== 'object') return null;
|
|
189
191
|
const kind = KINDS.has(value.kind) ? value.kind : 'advice';
|
|
190
192
|
const text = truncateWords(value.text, maxWords);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return { speak: true, kind, text, summary };
|
|
193
|
+
if (value.speak !== true || !text) return { speak: false, kind, text: '' };
|
|
194
|
+
return { speak: true, kind, text };
|
|
194
195
|
} catch {
|
|
195
196
|
return null;
|
|
196
197
|
}
|
|
@@ -48,7 +48,7 @@ async function summarize(ctx, session, messages, instruction, previous) {
|
|
|
48
48
|
|
|
49
49
|
/** Resumen no destructivo de toda la sesión (no toca el historial). */
|
|
50
50
|
export async function summarizeSession(ctx, session, instruction) {
|
|
51
|
-
const messages = ctx.store.getMessages(session.id);
|
|
51
|
+
const messages = liveHistory(ctx.store.getMessages(session.id));
|
|
52
52
|
if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
|
|
53
53
|
const resolved = providerFor(ctx, session);
|
|
54
54
|
if (resolved.error) return resolved;
|
|
@@ -59,6 +59,31 @@ export async function summarizeSession(ctx, session, instruction) {
|
|
|
59
59
|
return { summary };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Compactación manual (/compact): resume el historial vigente y manda al modelo
|
|
64
|
+
* `meta.summary` + lo posterior al corte (`meta.summaryCursor`).
|
|
65
|
+
*
|
|
66
|
+
* A diferencia de la auto-compactación, deja una **marca visible** (un mensaje
|
|
67
|
+
* `name:'compact'`) en el punto del corte, para que la consola siga mostrando el
|
|
68
|
+
* historial completo en pantalla sin perderlo. Ese mensaje no entra al contexto
|
|
69
|
+
* del modelo (`liveHistory` lo excluye; el resumen ya viaja como system).
|
|
70
|
+
*/
|
|
71
|
+
export async function compactSession(ctx, session) {
|
|
72
|
+
const messages = ctx.store.getMessages(session.id);
|
|
73
|
+
const live = liveHistory(messages, session.meta?.summaryCursor ?? null);
|
|
74
|
+
if (!live.length) return { error: 'La sesión no tiene mensajes para compactar.', status: 400 };
|
|
75
|
+
const resolved = providerFor(ctx, session);
|
|
76
|
+
if (resolved.error) return resolved;
|
|
77
|
+
ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, messages: live.length });
|
|
78
|
+
const summary = await summarize(ctx, session, live, COMPACT_INSTRUCTION, session.meta?.summary);
|
|
79
|
+
if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
|
|
80
|
+
const cursor = live.at(-1)?.id ?? null;
|
|
81
|
+
ctx.store.addMessage(session.id, { role: 'assistant', name: 'compact', mode: 'compact', content: summary });
|
|
82
|
+
ctx.store.touchSession(session.id, { meta: { summary, summaryCursor: cursor } });
|
|
83
|
+
ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, summary });
|
|
84
|
+
return { summary, cursor };
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
/**
|
|
63
88
|
* Auto-compactación: si el historial vigente supera el umbral del contexto,
|
|
64
89
|
* resume lo viejo y lo reemplaza por `meta.summary` + `meta.summaryCursor`.
|
|
@@ -69,7 +69,17 @@ export function createSubagentTool(ctx, options) {
|
|
|
69
69
|
Array.isArray(args.tools) && args.tools.length
|
|
70
70
|
? args.tools.filter((tool) => !exclude.includes(tool))
|
|
71
71
|
: mode.tools ?? undefined;
|
|
72
|
-
const local = createTools({
|
|
72
|
+
const local = createTools({
|
|
73
|
+
root: cwd,
|
|
74
|
+
enabled,
|
|
75
|
+
permissions,
|
|
76
|
+
writeExtensions: mode.writeExtensions,
|
|
77
|
+
yes: false,
|
|
78
|
+
mcp,
|
|
79
|
+
mcpMode,
|
|
80
|
+
mcpPinned,
|
|
81
|
+
signal,
|
|
82
|
+
});
|
|
73
83
|
const basePrompt = await ctx.systemPromptFor(cwd);
|
|
74
84
|
const messages = [
|
|
75
85
|
{ role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
|
|
@@ -140,23 +140,30 @@ export function registerChat(route, ctx) {
|
|
|
140
140
|
);
|
|
141
141
|
if (!input && !images.length) return sendError(res, 400, 'Falta "input".');
|
|
142
142
|
|
|
143
|
-
const providerId = body.provider || ctx.effectiveProviderId();
|
|
144
|
-
const model = body.model || ctx.effectiveModel();
|
|
145
|
-
if (!model) return sendError(res, 400, 'Falta "model" (en el cuerpo, en Ajustes o en tamnora.json).');
|
|
146
|
-
|
|
147
143
|
let sessionId = body.sessionId;
|
|
148
144
|
let history = [];
|
|
149
145
|
let sessionCwd;
|
|
146
|
+
let session = null;
|
|
150
147
|
if (sessionId) {
|
|
151
|
-
|
|
152
|
-
if (!
|
|
148
|
+
session = ctx.store.getSession(sessionId);
|
|
149
|
+
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
153
150
|
history = ctx.store.getMessages(sessionId);
|
|
154
151
|
try {
|
|
155
|
-
sessionCwd = resolveExistingDir(
|
|
152
|
+
sessionCwd = resolveExistingDir(session.cwd || ctx.cwd);
|
|
156
153
|
} catch {
|
|
157
154
|
sessionCwd = ctx.cwd;
|
|
158
155
|
}
|
|
159
|
-
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Cada sesion tiene su propio "tipo de agente": el proveedor/modelo/modo
|
|
159
|
+
// guardados en la sesion mandan sobre el default global. El cuerpo solo los
|
|
160
|
+
// pisa si vienen explicitos.
|
|
161
|
+
const providerId = body.provider || session?.provider || ctx.effectiveProviderId();
|
|
162
|
+
const model = body.model || session?.model || ctx.effectiveModel();
|
|
163
|
+
if (!model) return sendError(res, 400, 'Falta "model" (en el cuerpo, en Ajustes o en tamnora.json).');
|
|
164
|
+
const mode = resolveMode(body.mode ?? session?.meta?.mode ?? ctx.store.getSetting('activeMode'));
|
|
165
|
+
|
|
166
|
+
if (!sessionId) {
|
|
160
167
|
try {
|
|
161
168
|
sessionCwd = workspaceForRequest({ req, raw: body.cwd, fallback: ctx.cwd });
|
|
162
169
|
} catch (error) {
|
|
@@ -167,6 +174,7 @@ export function registerChat(route, ctx) {
|
|
|
167
174
|
provider: providerId,
|
|
168
175
|
model,
|
|
169
176
|
cwd: sessionCwd,
|
|
177
|
+
meta: { mode: mode.id },
|
|
170
178
|
});
|
|
171
179
|
}
|
|
172
180
|
|
|
@@ -183,7 +191,6 @@ export function registerChat(route, ctx) {
|
|
|
183
191
|
await ctx.runs.waitForIdle(sessionId);
|
|
184
192
|
}
|
|
185
193
|
|
|
186
|
-
const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
|
|
187
194
|
const sse = openSse(req, res);
|
|
188
195
|
ctx.runs.start(sessionId);
|
|
189
196
|
const unsubscribe = ctx.runs.subscribe(sessionId, (event) => sse.event(event.type, event.data, event.id));
|
|
@@ -236,6 +243,7 @@ export function registerChat(route, ctx) {
|
|
|
236
243
|
root: sessionCwd,
|
|
237
244
|
enabled,
|
|
238
245
|
permissions: ctx.config.permissions,
|
|
246
|
+
writeExtensions: mode.writeExtensions,
|
|
239
247
|
yes: autoApprove,
|
|
240
248
|
onAsk,
|
|
241
249
|
onQuestion,
|
|
@@ -374,7 +382,7 @@ export function registerChat(route, ctx) {
|
|
|
374
382
|
const current = ctx.store.getSession(sessionId);
|
|
375
383
|
const title =
|
|
376
384
|
current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
|
|
377
|
-
ctx.store.touchSession(sessionId, { provider: providerId, model, title });
|
|
385
|
+
ctx.store.touchSession(sessionId, { provider: providerId, model, title, meta: { mode: mode.id } });
|
|
378
386
|
const toolCalls = newMessages.filter((message) => message.role === 'tool').length;
|
|
379
387
|
const durationMs = Date.now() - startedAt;
|
|
380
388
|
if (result.stopReason === 'aborted')
|
|
@@ -421,6 +429,8 @@ export function registerChat(route, ctx) {
|
|
|
421
429
|
messages: newMessages,
|
|
422
430
|
options: advisorOptions,
|
|
423
431
|
});
|
|
432
|
+
// Un solo mensaje (estilo Jarvis): el asesor arranca con el resumen y
|
|
433
|
+
// sigue con la alerta/consejo en el mismo texto. Una sola locucion.
|
|
424
434
|
if (advice?.speak) {
|
|
425
435
|
ctx.store.addMessage(sessionId, {
|
|
426
436
|
role: 'assistant',
|
|
@@ -431,22 +441,6 @@ export function registerChat(route, ctx) {
|
|
|
431
441
|
ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
|
|
432
442
|
ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.text, kind: advice.kind });
|
|
433
443
|
}
|
|
434
|
-
// El resumen de "que hizo" va como una tarjeta/aviso aparte, para que
|
|
435
|
-
// la voz pueda leerlo siempre (independiente del consejo).
|
|
436
|
-
if (advice?.summary) {
|
|
437
|
-
ctx.store.addMessage(sessionId, {
|
|
438
|
-
role: 'assistant',
|
|
439
|
-
name: 'advisor',
|
|
440
|
-
content: advice.summary,
|
|
441
|
-
mode: 'summary',
|
|
442
|
-
});
|
|
443
|
-
ctx.runs.emit(sessionId, 'advice', {
|
|
444
|
-
text: advice.summary,
|
|
445
|
-
kind: 'summary',
|
|
446
|
-
say: advice.summary,
|
|
447
|
-
});
|
|
448
|
-
ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.summary, kind: 'summary' });
|
|
449
|
-
}
|
|
450
444
|
}
|
|
451
445
|
} catch (error) {
|
|
452
446
|
partials.flush();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
2
|
+
import { resolveMode } from '../lib/core.js';
|
|
2
3
|
import { workspaceForRequest } from '../lib/workspace.js';
|
|
3
4
|
import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
4
|
-
import { summarizeSession,
|
|
5
|
+
import { summarizeSession, compactSession, SUMMARY_INSTRUCTION } from '../lib/compact.js';
|
|
5
6
|
import { rewindSession, checkpointTurns } from '../lib/checkpoints.js';
|
|
6
7
|
|
|
7
8
|
export function registerSessions(route, ctx) {
|
|
@@ -45,13 +46,15 @@ export function registerSessions(route, ctx) {
|
|
|
45
46
|
} catch (error) {
|
|
46
47
|
return sendError(res, 400, error.message);
|
|
47
48
|
}
|
|
48
|
-
const meta =
|
|
49
|
+
const meta = {};
|
|
50
|
+
if (body.goal) meta.goal = String(body.goal);
|
|
51
|
+
if (body.mode) meta.mode = resolveMode(String(body.mode)).id;
|
|
49
52
|
const id = ctx.store.createSession({
|
|
50
53
|
title: String(body.title || 'Nueva sesión').slice(0, 80),
|
|
51
|
-
provider: body.provider || ctx.
|
|
52
|
-
model: body.model || ctx.
|
|
54
|
+
provider: body.provider || ctx.effectiveProviderId(),
|
|
55
|
+
model: body.model || ctx.effectiveModel(),
|
|
53
56
|
cwd,
|
|
54
|
-
meta,
|
|
57
|
+
meta: Object.keys(meta).length ? meta : undefined,
|
|
55
58
|
});
|
|
56
59
|
sendJson(res, 201, { session: sessionView(ctx.store.getSession(id)) });
|
|
57
60
|
});
|
|
@@ -95,7 +98,14 @@ export function registerSessions(route, ctx) {
|
|
|
95
98
|
const body = await readJson(req);
|
|
96
99
|
const patch = {};
|
|
97
100
|
if ('title' in body) patch.title = String(body.title || '').slice(0, 80) || session.title;
|
|
98
|
-
|
|
101
|
+
// El "tipo de agente" de la sesion: cada sesion puede correr con su propio
|
|
102
|
+
// proveedor/modelo/modo sin tocar el default global.
|
|
103
|
+
if (body.provider) patch.provider = String(body.provider);
|
|
104
|
+
if (body.model) patch.model = String(body.model);
|
|
105
|
+
const meta = {};
|
|
106
|
+
if ('goal' in body) meta.goal = body.goal ? String(body.goal) : undefined;
|
|
107
|
+
if ('mode' in body) meta.mode = resolveMode(String(body.mode)).id;
|
|
108
|
+
if (Object.keys(meta).length) patch.meta = meta;
|
|
99
109
|
ctx.store.touchSession(params.id, patch);
|
|
100
110
|
sendJson(res, 200, { session: sessionView(ctx.store.getSession(params.id)) });
|
|
101
111
|
});
|
|
@@ -136,13 +146,12 @@ export function registerSessions(route, ctx) {
|
|
|
136
146
|
sendJson(res, 200, { ok: true, ...result });
|
|
137
147
|
});
|
|
138
148
|
|
|
139
|
-
route('POST', '/api/sessions/:id/compact', async ({ res, params }) => {
|
|
149
|
+
route('POST', '/api/sessions/:id/compact', async ({ res, params }) => {
|
|
150
|
+
const session = ctx.store.getSession(params.id);
|
|
140
151
|
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
141
|
-
const out = await
|
|
152
|
+
const out = await compactSession(ctx, session);
|
|
142
153
|
if (out.error) return sendError(res, out.status, out.error);
|
|
143
|
-
|
|
144
|
-
ctx.store.touchSession(params.id, { meta: { summary: out.summary, summaryCursor: null } });
|
|
145
|
-
sendJson(res, 200, { summary: out.summary, messages: 0 });
|
|
154
|
+
sendJson(res, 200, { summary: out.summary, cursor: out.cursor });
|
|
146
155
|
});
|
|
147
156
|
|
|
148
157
|
route('POST', '/api/sessions/:id/summary', async ({ res, params }) => {
|
|
@@ -72,15 +72,17 @@ test('buildAdvisorPrompt respeta los toggles y las notas', () => {
|
|
|
72
72
|
assert.match(user, /src\/a\.js/);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
test('buildAdvisorPrompt pide el resumen cuando options.summary', () => {
|
|
75
|
+
test('buildAdvisorPrompt pide un solo mensaje con el resumen cuando options.summary', () => {
|
|
76
76
|
const { system } = buildAdvisorPrompt({
|
|
77
77
|
userInput: 'hola',
|
|
78
78
|
answer: 'listo',
|
|
79
79
|
options: { summary: true, code: false, explain: false, advice: false, maxWords: 20 },
|
|
80
80
|
});
|
|
81
|
-
assert.match(system, /
|
|
82
|
-
assert.match(system, /
|
|
83
|
-
assert.
|
|
81
|
+
assert.match(system, /UN SOLO mensaje/);
|
|
82
|
+
assert.match(system, /resumen muy breve/);
|
|
83
|
+
assert.match(system, /code\|explain\|advice\|summary/);
|
|
84
|
+
assert.doesNotMatch(system, /"summary":/);
|
|
85
|
+
assert.doesNotMatch(system, /Tu trabajo NO es repetir/);
|
|
84
86
|
});
|
|
85
87
|
|
|
86
88
|
const fakeProvider = (content) => ({ streamChat: async () => ({ content }) });
|
|
@@ -98,24 +100,26 @@ test('consultAdvisor interpreta speak:true', async () => {
|
|
|
98
100
|
assert.match(advice.text, /token/);
|
|
99
101
|
});
|
|
100
102
|
|
|
101
|
-
test('consultAdvisor devuelve
|
|
103
|
+
test('consultAdvisor devuelve un resumen solo como kind summary', async () => {
|
|
102
104
|
const advice = await consultAdvisor({
|
|
103
|
-
provider: fakeProvider('{"speak":
|
|
105
|
+
provider: fakeProvider('{"speak":true,"kind":"summary","text":"Revisaste el token y lo pasaste a env."}'),
|
|
104
106
|
config: {},
|
|
105
107
|
model: 'x',
|
|
106
108
|
messages: editMessages(),
|
|
107
109
|
options: { summary: true, code: false, explain: false, advice: false, maxWords: 40 },
|
|
108
110
|
});
|
|
109
|
-
assert.equal(advice.speak,
|
|
110
|
-
assert.
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
assert.equal(advice.speak, true);
|
|
112
|
+
assert.equal(advice.kind, 'summary');
|
|
113
|
+
assert.match(advice.text, /Revisaste el token/);
|
|
114
|
+
const off = await consultAdvisor({
|
|
115
|
+
provider: fakeProvider('{"speak":false,"text":"algo"}'),
|
|
113
116
|
config: {},
|
|
114
117
|
model: 'x',
|
|
115
118
|
messages: [],
|
|
116
119
|
options: { summary: false, code: true, explain: true, advice: true },
|
|
117
120
|
});
|
|
118
|
-
assert.equal(
|
|
121
|
+
assert.equal(off.speak, false);
|
|
122
|
+
assert.equal(off.text, '');
|
|
119
123
|
});
|
|
120
124
|
|
|
121
125
|
test('consultAdvisor respeta speak:false, JSON invalido y errores', async () => {
|
|
@@ -208,7 +212,7 @@ test('el asesor emite el evento advice al cerrar el turno', async () => {
|
|
|
208
212
|
return { content: text, reasoning: '', toolCalls: [] };
|
|
209
213
|
}
|
|
210
214
|
return {
|
|
211
|
-
content: '{"speak":true,"kind":"code","text":"Cuidado con el token."
|
|
215
|
+
content: '{"speak":true,"kind":"code","text":"Revisaste la sesión. Cuidado con el token."}',
|
|
212
216
|
reasoning: '',
|
|
213
217
|
toolCalls: [],
|
|
214
218
|
};
|
|
@@ -237,8 +241,8 @@ test('el asesor emite el evento advice al cerrar el turno', async () => {
|
|
|
237
241
|
assert.equal(calls, 2);
|
|
238
242
|
assert.match(body, /event: advice/);
|
|
239
243
|
assert.match(body, /Cuidado con el token/);
|
|
240
|
-
assert.match(body, /"kind":"summary"/);
|
|
241
244
|
assert.match(body, /Revisaste la sesión/);
|
|
245
|
+
assert.equal((body.match(/event: advice/g) ?? []).length, 1);
|
|
242
246
|
} finally {
|
|
243
247
|
await instance.close();
|
|
244
248
|
}
|