@agentprojectcontext/apx 1.34.0 → 1.36.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.
Files changed (75) hide show
  1. package/package.json +1 -1
  2. package/skills/apx/SKILL.md +1 -1
  3. package/src/core/agent/build-agent-system.js +134 -58
  4. package/src/core/agent/channels/voice-context.js +4 -4
  5. package/src/core/agent/prompt-builder.js +176 -123
  6. package/src/core/agent/prompts/channels/code.md +12 -10
  7. package/src/core/agent/prompts/channels/desktop.md +5 -32
  8. package/src/core/agent/prompts/channels/telegram.md +4 -15
  9. package/src/core/agent/prompts/channels/web_code.md +11 -11
  10. package/src/core/agent/prompts/core/agent-base.md +24 -0
  11. package/src/core/agent/prompts/core/project-agent.md +11 -0
  12. package/src/core/agent/prompts/core/super-agent.md +21 -0
  13. package/src/core/agent/prompts/discipline/action.md +10 -0
  14. package/src/core/agent/prompts/discipline/single-segment.md +6 -0
  15. package/src/core/agent/prompts/discipline/two-segment.md +11 -0
  16. package/src/core/agent/self-memory.js +43 -1
  17. package/src/core/agent/skills/index-store.js +307 -0
  18. package/src/core/agent/skills/index.js +15 -1
  19. package/src/core/agent/skills/inspector.js +317 -0
  20. package/src/core/agent/super-agent.js +7 -1
  21. package/src/core/agent/tools/handlers/_git.js +50 -0
  22. package/src/core/agent/tools/handlers/git-diff.js +44 -0
  23. package/src/core/agent/tools/handlers/git-log.js +38 -0
  24. package/src/core/agent/tools/handlers/git-show.js +34 -0
  25. package/src/core/agent/tools/handlers/git-status.js +61 -0
  26. package/src/core/agent/tools/names.js +31 -0
  27. package/src/core/agent/tools/registry.js +36 -5
  28. package/src/core/config/index.js +21 -0
  29. package/src/core/runtime-skills/apx/SKILL.md +27 -39
  30. package/src/core/runtime-skills/apx-agency-agents/SKILL.md +40 -56
  31. package/src/core/runtime-skills/apx-agent/SKILL.md +27 -30
  32. package/src/core/runtime-skills/apx-mcp/SKILL.md +31 -36
  33. package/src/core/runtime-skills/apx-mcp-builder/SKILL.md +37 -51
  34. package/src/core/runtime-skills/apx-project/SKILL.md +20 -29
  35. package/src/core/runtime-skills/apx-routine/SKILL.md +34 -47
  36. package/src/core/runtime-skills/apx-runtime/SKILL.md +32 -50
  37. package/src/core/runtime-skills/apx-sessions/SKILL.md +96 -145
  38. package/src/core/runtime-skills/apx-skill-builder/SKILL.md +53 -77
  39. package/src/core/runtime-skills/apx-task/SKILL.md +18 -21
  40. package/src/core/runtime-skills/apx-telegram/SKILL.md +43 -54
  41. package/src/core/runtime-skills/apx-voice/SKILL.md +36 -56
  42. package/src/core/stores/conversations.js +27 -2
  43. package/src/host/daemon/api/exec.js +2 -0
  44. package/src/host/daemon/api/skills.js +140 -6
  45. package/src/host/daemon/api/super-agent.js +56 -1
  46. package/src/host/daemon/index.js +17 -0
  47. package/src/interfaces/cli/branding.js +53 -0
  48. package/src/interfaces/cli/commands/skills.js +254 -0
  49. package/src/interfaces/cli/index.js +84 -2
  50. package/src/interfaces/web/dist/assets/index-Cm0KyPoZ.css +1 -0
  51. package/src/interfaces/web/dist/assets/index-DJKA763h.js +628 -0
  52. package/src/interfaces/web/dist/assets/index-DJKA763h.js.map +1 -0
  53. package/src/interfaces/web/dist/index.html +2 -2
  54. package/src/interfaces/web/src/App.tsx +0 -1
  55. package/src/interfaces/web/src/components/chat/ChatList.tsx +412 -0
  56. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +21 -1
  57. package/src/interfaces/web/src/components/settings/AppearancePanel.tsx +1 -1
  58. package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +69 -1
  59. package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +222 -0
  60. package/src/interfaces/web/src/hooks/useChat.ts +54 -2
  61. package/src/interfaces/web/src/i18n/en.ts +12 -1
  62. package/src/interfaces/web/src/i18n/es.ts +12 -1
  63. package/src/interfaces/web/src/lib/api/agents.ts +1 -1
  64. package/src/interfaces/web/src/lib/api/skills.ts +70 -0
  65. package/src/interfaces/web/src/screens/ProjectScreen.tsx +3 -5
  66. package/src/interfaces/web/src/screens/SettingsScreen.tsx +12 -6
  67. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +1 -1
  68. package/src/interfaces/web/src/screens/project/ChatTab.tsx +120 -87
  69. package/src/interfaces/web/src/types/daemon.ts +10 -0
  70. package/src/core/agent/prompts/action-discipline.md +0 -24
  71. package/src/core/agent/prompts/super-agent-base.md +0 -42
  72. package/src/interfaces/web/dist/assets/index-DdmSRtsz.css +0 -1
  73. package/src/interfaces/web/dist/assets/index-M4FspaCH.js +0 -613
  74. package/src/interfaces/web/dist/assets/index-M4FspaCH.js.map +0 -1
  75. package/src/interfaces/web/src/screens/project/ThreadsTab.tsx +0 -100
@@ -18,8 +18,8 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-M4FspaCH.js"></script>
22
- <link rel="stylesheet" crossorigin href="/assets/index-DdmSRtsz.css">
21
+ <script type="module" crossorigin src="/assets/index-DJKA763h.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/assets/index-Cm0KyPoZ.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
25
25
  <div id="root"></div>
@@ -179,7 +179,6 @@ function settingsLabel(key?: string) {
179
179
  function projectLabel(key?: string) {
180
180
  switch (key) {
181
181
  case "chat": return t("project.nav.chat");
182
- case "threads": return t("project.nav.threads");
183
182
  case "telegram": return t("project.nav.telegram");
184
183
  case "agents": return t("project.nav.agents");
185
184
  case "routines": return t("project.nav.routines");
@@ -0,0 +1,412 @@
1
+ import { useEffect, useMemo, useState, type ElementType } from "react";
2
+ import useSWR from "swr";
3
+ import clsx from "clsx";
4
+ import {
5
+ Bot,
6
+ ChevronDown,
7
+ ChevronRight,
8
+ Clock,
9
+ FolderOpen,
10
+ MessageSquare,
11
+ Mic,
12
+ Monitor,
13
+ Plus,
14
+ Send,
15
+ Sparkles,
16
+ Timer,
17
+ User,
18
+ } from "lucide-react";
19
+ import { Conversations } from "../../lib/api";
20
+ import { Input, Loading } from "../ui";
21
+ import { UiSelect } from "../UiSelect";
22
+ import { t } from "../../i18n";
23
+ import type { AgentEntry, ConversationListEntry } from "../../types/daemon";
24
+
25
+ // Channel taxonomy — same channels the daemon writes ("web", "voice",
26
+ // "desktop", "telegram", …) folded into 8 sidebar groups. Each group has an
27
+ // icon + a fixed display order so the sidebar is stable across reloads.
28
+ export type ChannelGroupKey =
29
+ | "live"
30
+ | "telegram"
31
+ | "voice"
32
+ | "desktop"
33
+ | "web"
34
+ | "a2a"
35
+ | "schedule"
36
+ | "other";
37
+
38
+ interface GroupMeta {
39
+ label: string;
40
+ icon: ElementType;
41
+ order: number;
42
+ }
43
+
44
+ const GROUP_META: Record<ChannelGroupKey, GroupMeta> = {
45
+ live: { label: "Live", icon: Sparkles, order: 0 },
46
+ telegram: { label: "Telegram", icon: Send, order: 1 },
47
+ voice: { label: "Voice", icon: Mic, order: 2 },
48
+ desktop: { label: "Desktop", icon: Monitor, order: 3 },
49
+ web: { label: "Web", icon: MessageSquare, order: 4 },
50
+ a2a: { label: "Agent ↔ Agent", icon: Bot, order: 5 },
51
+ schedule: { label: "Schedule", icon: Timer, order: 6 },
52
+ other: { label: "Other", icon: FolderOpen, order: 7 },
53
+ };
54
+
55
+ function channelGroup(channel?: string): ChannelGroupKey {
56
+ if (!channel) return "web";
57
+ const c = channel.toLowerCase();
58
+ if (c === "telegram") return "telegram";
59
+ if (c === "voice" || c === "overlay") return "voice";
60
+ if (c === "desktop") return "desktop";
61
+ if (c === "web" || c === "sidebar" || c === "web-sidebar") return "web";
62
+ if (c === "a2a" || c.startsWith("agent")) return "a2a";
63
+ if (c === "schedule" || c === "cron" || c === "routine") return "schedule";
64
+ return "other";
65
+ }
66
+
67
+ // Composite key identifying a sidebar selection: either a "live" agent session
68
+ // (no conversation file yet) or a persisted conversation tied to an agent.
69
+ export type ChatKey =
70
+ | { kind: "live"; agentSlug: string }
71
+ | { kind: "conv"; agentSlug: string; convId: string };
72
+
73
+ export function chatKeyToString(k: ChatKey): string {
74
+ return k.kind === "live" ? `live:${k.agentSlug}` : `conv:${k.agentSlug}:${k.convId}`;
75
+ }
76
+
77
+ interface Props {
78
+ pid: string;
79
+ agents: AgentEntry[];
80
+ /** Virtual super-agent slug used by the dropdown (kept in sync with ChatTab). */
81
+ superAgentSlug: string;
82
+ superAgentLabel: string;
83
+ selected: ChatKey;
84
+ onSelect: (key: ChatKey) => void;
85
+ onNewChat: () => void;
86
+ }
87
+
88
+ // Per-agent SWR fetcher. Lives in a child so adding/removing agents doesn't
89
+ // violate the rules of hooks in the parent.
90
+ function AgentConvFetcher({
91
+ pid,
92
+ slug,
93
+ onLoaded,
94
+ }: {
95
+ pid: string;
96
+ slug: string;
97
+ onLoaded: (slug: string, data: ConversationListEntry[] | undefined) => void;
98
+ }) {
99
+ const { data } = useSWR(
100
+ `/projects/${pid}/agents/${slug}/conversations`,
101
+ () => Conversations.list(pid, slug),
102
+ { revalidateOnFocus: false },
103
+ );
104
+ // Bubble fetched data up to the parent on every change.
105
+ useEffect(() => {
106
+ onLoaded(slug, data);
107
+ }, [slug, data]); // eslint-disable-line react-hooks/exhaustive-deps
108
+ return null;
109
+ }
110
+
111
+ export function ChatList({
112
+ pid,
113
+ agents,
114
+ superAgentSlug,
115
+ superAgentLabel,
116
+ selected,
117
+ onSelect,
118
+ onNewChat,
119
+ }: Props) {
120
+ const [query, setQuery] = useState("");
121
+ const [agentFilter, setAgentFilter] = useState("");
122
+ const [collapsed, setCollapsed] = useState<Partial<Record<ChannelGroupKey, boolean>>>({});
123
+ const [byAgent, setByAgent] = useState<Record<string, ConversationListEntry[]>>({});
124
+
125
+ const handleLoaded = (slug: string, data: ConversationListEntry[] | undefined) => {
126
+ if (!data) return;
127
+ setByAgent((prev) => {
128
+ const cur = prev[slug];
129
+ if (cur && cur.length === data.length && cur === data) return prev;
130
+ return { ...prev, [slug]: data };
131
+ });
132
+ };
133
+
134
+ const allConvs = useMemo<ConversationListEntry[]>(() => {
135
+ const out: ConversationListEntry[] = [];
136
+ for (const a of agents) {
137
+ for (const c of byAgent[a.slug] || []) {
138
+ out.push({ ...c, agent_slug: c.agent_slug || a.slug });
139
+ }
140
+ }
141
+ return out;
142
+ }, [agents, byAgent]);
143
+
144
+ const filteredConvs = useMemo(() => {
145
+ const q = query.trim().toLowerCase();
146
+ return allConvs.filter((c) => {
147
+ if (agentFilter && c.agent_slug !== agentFilter) return false;
148
+ if (q) {
149
+ const hay = `${c.title || ""} ${c.id} ${c.agent_slug}`.toLowerCase();
150
+ if (!hay.includes(q)) return false;
151
+ }
152
+ return true;
153
+ });
154
+ }, [allConvs, query, agentFilter]);
155
+
156
+ // Group: live entries (one per applicable agent) + stored conversations by channel.
157
+ const groups = useMemo(() => {
158
+ const byKey = new Map<ChannelGroupKey, ConversationListEntry[]>();
159
+ for (const c of filteredConvs) {
160
+ const key = channelGroup(c.channel);
161
+ const bucket = byKey.get(key);
162
+ if (bucket) bucket.push(c);
163
+ else byKey.set(key, [c]);
164
+ }
165
+ return Array.from(byKey.entries())
166
+ .map(([key, items]) => ({
167
+ key,
168
+ items: items.sort(
169
+ (a, b) => new Date(b.started_at || 0).getTime() - new Date(a.started_at || 0).getTime(),
170
+ ),
171
+ }))
172
+ .sort((a, b) => GROUP_META[a.key].order - GROUP_META[b.key].order);
173
+ }, [filteredConvs]);
174
+
175
+ // "Live" agents shown at the top: super-agent + project agents matching the
176
+ // current agent filter (so "filter by foo" hides the others everywhere).
177
+ const liveAgents = useMemo(() => {
178
+ if (agentFilter === superAgentSlug) {
179
+ return [{ slug: superAgentSlug, label: superAgentLabel }];
180
+ }
181
+ const out: { slug: string; label: string }[] = [];
182
+ if (!agentFilter) out.push({ slug: superAgentSlug, label: superAgentLabel });
183
+ for (const a of agents) {
184
+ if (agentFilter && a.slug !== agentFilter) continue;
185
+ out.push({ slug: a.slug, label: a.slug });
186
+ }
187
+ return out;
188
+ }, [agents, agentFilter, superAgentSlug, superAgentLabel]);
189
+
190
+ const agentOptions = useMemo(
191
+ () => [
192
+ { value: "", label: t("project.chat.list.all_agents") },
193
+ { value: superAgentSlug, label: superAgentLabel },
194
+ ...agents.map((a) => ({ value: a.slug, label: a.slug })),
195
+ ],
196
+ [agents, superAgentSlug, superAgentLabel],
197
+ );
198
+
199
+ const totalCount = allConvs.length + liveAgents.length;
200
+ const anyLoaded = Object.keys(byAgent).length > 0 || agents.length === 0;
201
+
202
+ return (
203
+ <aside className="flex h-full w-72 shrink-0 flex-col border-r border-border bg-card/30">
204
+ {/* Hidden per-agent fetchers — one SWR per agent, but with a stable
205
+ component per slug so rules-of-hooks are respected. */}
206
+ {agents.map((a) => (
207
+ <AgentConvFetcher key={a.slug} pid={pid} slug={a.slug} onLoaded={handleLoaded} />
208
+ ))}
209
+
210
+ <header className="flex h-[57px] shrink-0 items-center justify-between gap-2 border-b border-border px-3">
211
+ <div className="min-w-0">
212
+ <p className="truncate text-sm font-semibold">{t("project.chat.list.title")}</p>
213
+ <p className="text-[10px] text-muted-fg">
214
+ {t("project.chat.list.count", { n: totalCount })}
215
+ </p>
216
+ </div>
217
+ <button
218
+ type="button"
219
+ onClick={onNewChat}
220
+ className="inline-flex items-center gap-1 rounded-md border border-border bg-accent/60 px-2 py-1 text-[11px] font-medium hover:bg-accent"
221
+ >
222
+ <Plus className="size-3" /> {t("project.chat.list.new")}
223
+ </button>
224
+ </header>
225
+
226
+ <div className="space-y-2 border-b border-border p-2">
227
+ <Input
228
+ value={query}
229
+ onChange={(e) => setQuery(e.target.value)}
230
+ placeholder={t("project.chat.list.search")}
231
+ />
232
+ <UiSelect value={agentFilter} onChange={setAgentFilter} options={agentOptions} />
233
+ </div>
234
+
235
+ <div className="flex-1 space-y-3 overflow-y-auto p-2">
236
+ {!anyLoaded && (
237
+ <div className="px-2 py-1">
238
+ <Loading />
239
+ </div>
240
+ )}
241
+
242
+ {/* Live group — synthetic, always first. */}
243
+ {liveAgents.length > 0 && (
244
+ <ChannelGroup
245
+ keyName="live"
246
+ count={liveAgents.length}
247
+ collapsed={!!collapsed.live}
248
+ onToggle={() => setCollapsed((p) => ({ ...p, live: !p.live }))}
249
+ >
250
+ {liveAgents.map((a) => {
251
+ const isRoby = a.slug === superAgentSlug;
252
+ const active =
253
+ selected.kind === "live" && selected.agentSlug === a.slug;
254
+ return (
255
+ <ChatListItem
256
+ key={`live-${a.slug}`}
257
+ title={isRoby ? a.label : t("project.chat.list.live_with", { slug: a.slug })}
258
+ subtitle={t("project.chat.list.live_subtitle")}
259
+ badge={isRoby ? "super" : a.slug}
260
+ selected={active}
261
+ onClick={() => onSelect({ kind: "live", agentSlug: a.slug })}
262
+ />
263
+ );
264
+ })}
265
+ </ChannelGroup>
266
+ )}
267
+
268
+ {/* Stored conversations, grouped by channel. */}
269
+ {groups.map((g) => (
270
+ <ChannelGroup
271
+ key={g.key}
272
+ keyName={g.key}
273
+ count={g.items.length}
274
+ collapsed={!!collapsed[g.key]}
275
+ onToggle={() => setCollapsed((p) => ({ ...p, [g.key]: !p[g.key] }))}
276
+ >
277
+ {g.items.map((c) => {
278
+ const active =
279
+ selected.kind === "conv" &&
280
+ selected.agentSlug === c.agent_slug &&
281
+ selected.convId === c.id;
282
+ return (
283
+ <ChatListItem
284
+ key={`${c.agent_slug}-${c.id}`}
285
+ title={c.title || c.id}
286
+ subtitle={[c.agent_slug, `${c.messages ?? 0} msg`]
287
+ .filter(Boolean)
288
+ .join(" · ")}
289
+ badge={c.agent_slug}
290
+ timeAgo={c.started_at}
291
+ selected={active}
292
+ onClick={() =>
293
+ onSelect({ kind: "conv", agentSlug: c.agent_slug, convId: c.id })
294
+ }
295
+ />
296
+ );
297
+ })}
298
+ </ChannelGroup>
299
+ ))}
300
+
301
+ {anyLoaded && allConvs.length === 0 && (
302
+ <p className="px-3 py-6 text-center text-xs text-muted-fg">
303
+ {t("project.chat.list.empty")}
304
+ </p>
305
+ )}
306
+ </div>
307
+ </aside>
308
+ );
309
+ }
310
+
311
+ function ChannelGroup({
312
+ keyName,
313
+ count,
314
+ collapsed,
315
+ onToggle,
316
+ children,
317
+ }: {
318
+ keyName: ChannelGroupKey;
319
+ count: number;
320
+ collapsed: boolean;
321
+ onToggle: () => void;
322
+ children: React.ReactNode;
323
+ }) {
324
+ const meta = GROUP_META[keyName];
325
+ const Icon = meta.icon;
326
+ return (
327
+ <section className="space-y-1">
328
+ <button
329
+ type="button"
330
+ onClick={onToggle}
331
+ className="flex w-full items-center justify-between rounded-md px-2 py-1 text-muted-fg hover:bg-accent/30"
332
+ >
333
+ <span className="inline-flex items-center gap-1.5">
334
+ {collapsed ? (
335
+ <ChevronRight className="size-3" />
336
+ ) : (
337
+ <ChevronDown className="size-3" />
338
+ )}
339
+ <Icon className="size-3" />
340
+ <span className="text-[10px] font-semibold uppercase tracking-wider">
341
+ {meta.label}
342
+ </span>
343
+ </span>
344
+ <span className="text-[10px]">{count}</span>
345
+ </button>
346
+ {!collapsed && <div className="space-y-0.5">{children}</div>}
347
+ </section>
348
+ );
349
+ }
350
+
351
+ function ChatListItem({
352
+ title,
353
+ subtitle,
354
+ badge,
355
+ timeAgo,
356
+ selected,
357
+ onClick,
358
+ }: {
359
+ title: string;
360
+ subtitle?: string;
361
+ badge?: string;
362
+ timeAgo?: string;
363
+ selected?: boolean;
364
+ onClick: () => void;
365
+ }) {
366
+ return (
367
+ <button
368
+ type="button"
369
+ onClick={onClick}
370
+ className={clsx(
371
+ "w-full rounded-md border px-2.5 py-2 text-left transition-colors",
372
+ selected
373
+ ? "border-primary/50 bg-primary/10"
374
+ : "border-transparent hover:border-border hover:bg-accent/40",
375
+ )}
376
+ >
377
+ <div className="flex items-start justify-between gap-2">
378
+ <p className={clsx("truncate text-sm", selected ? "font-semibold" : "font-medium")}>
379
+ {title}
380
+ </p>
381
+ {timeAgo && (
382
+ <span className="inline-flex shrink-0 items-center gap-0.5 text-[10px] text-muted-fg">
383
+ <Clock className="size-2.5" />
384
+ {formatTimeAgo(timeAgo)}
385
+ </span>
386
+ )}
387
+ </div>
388
+ <div className="mt-0.5 flex items-center justify-between gap-2 text-[10px] text-muted-fg">
389
+ <span className="truncate">{subtitle}</span>
390
+ {badge && (
391
+ <span className="inline-flex shrink-0 items-center gap-1 rounded bg-accent/50 px-1.5 py-0.5">
392
+ <User className="size-2.5" />
393
+ {badge}
394
+ </span>
395
+ )}
396
+ </div>
397
+ </button>
398
+ );
399
+ }
400
+
401
+ function formatTimeAgo(iso?: string): string {
402
+ if (!iso) return "";
403
+ const ts = new Date(iso).getTime();
404
+ if (!Number.isFinite(ts)) return "";
405
+ const diff = Date.now() - ts;
406
+ const m = Math.floor(diff / 60000);
407
+ if (m < 1) return "now";
408
+ if (m < 60) return `${m}m`;
409
+ const h = Math.floor(m / 60);
410
+ if (h < 24) return `${h}h`;
411
+ return `${Math.floor(h / 24)}d`;
412
+ }
@@ -1,4 +1,4 @@
1
- import { Bot, Copy, User, Info } from "lucide-react";
1
+ import { Bot, Copy, User, Info, Sparkles } from "lucide-react";
2
2
  import { cn } from "../../lib/cn";
3
3
  import { ToolCall } from "./ToolCall";
4
4
  import { AskQuestionsCard } from "./AskQuestionsCard";
@@ -49,6 +49,26 @@ export function MessageBubble({ msg, isLast, isAskAnswer, onCopy }: Props) {
49
49
  </div>
50
50
  )}
51
51
 
52
+ {/* Skill Inspector: which skills the per-turn RAG injected for this turn. */}
53
+ {!mine && msg.inspector && (msg.inspector.loaded?.length || msg.inspector.hinted?.length) ? (
54
+ <div
55
+ className="flex flex-wrap items-center gap-1 text-[10px] text-sky-400/90"
56
+ title={`Skill Inspector (${msg.inspector.embedder || "RAG"}) eligió estas skills para este turno`}
57
+ >
58
+ <Sparkles size={10} />
59
+ {msg.inspector.loaded?.map((s) => (
60
+ <span key={`l-${s}`} className="rounded bg-sky-500/15 px-1 py-0.5 font-mono">
61
+ {s}
62
+ </span>
63
+ ))}
64
+ {msg.inspector.hinted?.map((s) => (
65
+ <span key={`h-${s}`} className="rounded border border-sky-500/30 px-1 py-0.5 font-mono opacity-70">
66
+ {s}?
67
+ </span>
68
+ ))}
69
+ </div>
70
+ ) : null}
71
+
52
72
  {/* Ordered parts: interleaved assistant text + tool calls. */}
53
73
  {msg.parts.map((part, i) =>
54
74
  part.kind === "tool" ? (
@@ -30,7 +30,7 @@ export function AppearancePanel() {
30
30
  };
31
31
 
32
32
  return (
33
- <div className="space-y-6">
33
+ <div className="grid gap-6 xl:grid-cols-2 xl:items-start">
34
34
  <Section title={t("settings.appearance")}>
35
35
  <div className="flex items-center gap-2">
36
36
  <Button variant={theme === "light" ? "primary" : "secondary"} onClick={() => set("light")}>{t("settings.light_mode")}</Button>
@@ -36,6 +36,10 @@ interface MemoryCfg {
36
36
  openai?: { api_key?: string; model?: string; base_url?: string };
37
37
  gemini?: { api_key?: string; model?: string };
38
38
  };
39
+ compact_threshold?: number;
40
+ keep_recent?: number;
41
+ compact_model?: string;
42
+ compact_fallback_model?: string;
39
43
  }
40
44
 
41
45
  const isMarker = (v: string) => v.startsWith("***");
@@ -97,7 +101,7 @@ export function MemoryPanel() {
97
101
  };
98
102
 
99
103
  return (
100
- <div className="space-y-6">
104
+ <div className="grid gap-6 xl:grid-cols-2 xl:items-start">
101
105
  <Section
102
106
  title={t("memory_panel.embeddings_title")}
103
107
  description="Modelo que vectoriza el historial de todos los canales para la memoria relevante. Igual que TTS/STT: elegí proveedor y modelo. 'Automático' prueba local primero y cae al offline si no hay nada."
@@ -222,6 +226,70 @@ export function MemoryPanel() {
222
226
  />
223
227
  </Field>
224
228
  </Section>
229
+
230
+ <Section
231
+ title={t("memory_panel.compaction_title")}
232
+ description="Cuando un chat supera el umbral de turnos, los más viejos se resumen con un LLM liviano (local) y se guardan como [RESUMEN COMPACTADO], manteniendo el contexto acotado. Corre fuera del hot-path: el turno actual usa el resumen que ya exista."
233
+ >
234
+ <div className="grid gap-3 sm:grid-cols-2">
235
+ <Field label="Umbral de compactación" hint="Compactar una vez que el chat supera estos turnos (por defecto 60).">
236
+ <Input
237
+ type="number"
238
+ min={1}
239
+ defaultValue={mem.compact_threshold ?? 60}
240
+ placeholder="60"
241
+ disabled={busy}
242
+ onBlur={(ev) => {
243
+ const n = parseInt(ev.target.value, 10);
244
+ if (Number.isFinite(n) && n > 0 && n !== mem.compact_threshold) {
245
+ apply({ "memory.compact_threshold": n });
246
+ }
247
+ }}
248
+ className="max-w-[10rem]"
249
+ />
250
+ </Field>
251
+ <Field label="Turnos recientes a preservar" hint="Turnos verbatim que NUNCA se compactan (por defecto 40). Debe ser menor al umbral.">
252
+ <Input
253
+ type="number"
254
+ min={1}
255
+ defaultValue={mem.keep_recent ?? 40}
256
+ placeholder="40"
257
+ disabled={busy}
258
+ onBlur={(ev) => {
259
+ const n = parseInt(ev.target.value, 10);
260
+ if (Number.isFinite(n) && n > 0 && n !== mem.keep_recent) {
261
+ apply({ "memory.keep_recent": n });
262
+ }
263
+ }}
264
+ className="max-w-[10rem]"
265
+ />
266
+ </Field>
267
+ </div>
268
+ <Field label="Modelo de compactación" hint="LLM liviano para resumir. Ideal uno local (Ollama) para no gastar. Formato proveedor:modelo.">
269
+ <Input
270
+ defaultValue={mem.compact_model || "ollama:gemma4:31b-cloud"}
271
+ placeholder="ollama:gemma4:31b-cloud"
272
+ disabled={busy}
273
+ onBlur={(ev) => {
274
+ const v = ev.target.value.trim();
275
+ if (v && v !== mem.compact_model) apply({ "memory.compact_model": v });
276
+ }}
277
+ className="max-w-md"
278
+ />
279
+ </Field>
280
+ <Field label="Modelo de fallback" hint="Se usa si el de compactación falla. Vacío cae al modelo del super-agente.">
281
+ <Input
282
+ defaultValue={mem.compact_fallback_model || ""}
283
+ placeholder="(vacío → modelo del super-agente)"
284
+ disabled={busy}
285
+ onBlur={(ev) => {
286
+ const v = ev.target.value.trim();
287
+ if (v !== (mem.compact_fallback_model || "")) apply({ "memory.compact_fallback_model": v });
288
+ }}
289
+ className="max-w-md"
290
+ />
291
+ </Field>
292
+ </Section>
225
293
  </div>
226
294
  );
227
295
  }