@circuitwall/jarela 0.9.3 → 0.10.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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +2 -2
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +72 -5
- package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/route.js +2 -2
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +2 -2
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +136 -26
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/tools/route.js +2 -2
- package/.next/standalone/.next/server/app/index.html +2 -2
- package/.next/standalone/.next/server/app/index.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page.js +266 -40
- package/.next/standalone/.next/server/app/page.js.map +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup.html +1 -1
- package/.next/standalone/.next/server/app/setup.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.segments/setup.segment.rsc +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +2 -2
- package/.next/standalone/.next/server/chunks/210.js +1 -1
- package/.next/standalone/.next/server/chunks/2151.js +60 -2
- package/.next/standalone/.next/server/chunks/2151.js.map +1 -1
- package/.next/standalone/.next/server/chunks/614.js +336 -93
- package/.next/standalone/.next/server/chunks/614.js.map +1 -1
- package/.next/standalone/.next/server/chunks/6765.js +35 -0
- package/.next/standalone/.next/server/chunks/6765.js.map +1 -1
- package/.next/standalone/.next/server/chunks/8697.js +15246 -15002
- package/.next/standalone/.next/server/chunks/8697.js.map +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/{3741-344e2bfc5028b9c8.js → 3741-2d64471ff763b8fa.js} +36 -1
- package/.next/standalone/.next/static/chunks/3741-2d64471ff763b8fa.js.map +1 -0
- package/.next/standalone/.next/static/chunks/app/{page-c77ab600642bbfc2.js → page-318743bf47fac345.js} +267 -41
- package/.next/standalone/.next/static/chunks/app/page-318743bf47fac345.js.map +1 -0
- package/.next/standalone/.next/static/css/b6b85b0f13bc0e98.css +5 -0
- package/.next/standalone/.next/static/css/b6b85b0f13bc0e98.css.map +1 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +48 -0
- package/README.md +2 -0
- package/api/client.ts +37 -1
- package/api/types.ts +18 -0
- package/app/api/v1/threads/[thread_id]/run/route.ts +69 -22
- package/components/agents/AgentEditor.tsx +7 -4
- package/components/chat/MessageBubble.tsx +108 -1
- package/components/dashboard/DashboardPanel.tsx +79 -21
- package/hooks/useSSE.ts +22 -9
- package/lib/agents/prepare/system-prompt.ts +30 -0
- package/lib/agents/run-registry.test.ts +94 -0
- package/lib/agents/run-registry.ts +60 -1
- package/lib/stores/dashboard-metrics.test.ts +33 -0
- package/lib/stores/dashboard-metrics.ts +93 -1
- package/lib/tools/exec.ts +9 -5
- package/lib/tools/files.ts +6 -0
- package/lib/tools/safety.test.ts +95 -0
- package/lib/tools/safety.ts +147 -0
- package/package.json +1 -1
- package/.next/standalone/.next/static/chunks/3741-344e2bfc5028b9c8.js.map +0 -1
- package/.next/standalone/.next/static/chunks/app/page-c77ab600642bbfc2.js.map +0 -1
- package/.next/standalone/.next/static/css/53f85613a5500253.css +0 -5
- package/.next/standalone/.next/static/css/53f85613a5500253.css.map +0 -1
- /package/.next/standalone/.next/static/{6uLoytvvEtLKIblEB53e0 → 8qTBpUDFnSMYwe3Zc0bGV}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{6uLoytvvEtLKIblEB53e0 → 8qTBpUDFnSMYwe3Zc0bGV}/_ssgManifest.js +0 -0
|
@@ -88,28 +88,60 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|
|
88
88
|
// Drive the agent to completion regardless of client connection. Events
|
|
89
89
|
// go to the registry; the GET subscriber (and any reattaching clients)
|
|
90
90
|
// receive them via subscribe().
|
|
91
|
+
//
|
|
92
|
+
// CRITICAL: finishRun() MUST run no matter what — if it doesn't, the
|
|
93
|
+
// run is pinned as "running" forever, every subsequent POST returns
|
|
94
|
+
// 409 run_in_flight, and the user sees a silently-dead chat. The TTL
|
|
95
|
+
// eviction is scheduled inside finishRun(), so a leaked entry never
|
|
96
|
+
// self-heals. Wrap the whole body in try/finally.
|
|
91
97
|
void (async () => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
let terminal: "done" | "error" = "error";
|
|
99
|
+
let assistantContent = "";
|
|
100
|
+
try {
|
|
101
|
+
const collected = await collectStream(prepared.stream as AsyncIterable<StreamChunk>, {
|
|
102
|
+
onChunk: (chunk) => broadcast(active, chunk),
|
|
103
|
+
});
|
|
104
|
+
assistantContent = collected.assistantContent;
|
|
105
|
+
terminal = collected.terminal;
|
|
106
|
+
// If the stream threw mid-iteration, collectStream returns terminal="error"
|
|
107
|
+
// but no `error` chunk was broadcast — surface one to subscribers.
|
|
108
|
+
if (collected.terminal === "error" && collected.errorMessage) {
|
|
109
|
+
broadcast(active, {
|
|
110
|
+
type: "error",
|
|
111
|
+
data: { message: collected.errorMessage, code: "stream_error" },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
persistAssistantMessage(thread_id, collected.assistantContent, collected.usedTools, collected.toolEvents, null, collected.usage ?? null, prepared.context_snapshot ?? null);
|
|
116
|
+
} catch (persistErr) {
|
|
117
|
+
// Persistence failure must not strand the run — surface and continue
|
|
118
|
+
// to finishRun in the finally block.
|
|
119
|
+
terminal = "error";
|
|
120
|
+
broadcast(active, {
|
|
121
|
+
type: "error",
|
|
122
|
+
data: { message: `persist failed: ${(persistErr as Error).message}`, code: "persist_error" },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// Unhandled throw from collectStream / the underlying stream. Without
|
|
127
|
+
// this catch, finishRun would never run and the registry entry would
|
|
128
|
+
// stick as "running" indefinitely (see CRITICAL note above).
|
|
129
|
+
terminal = "error";
|
|
98
130
|
broadcast(active, {
|
|
99
131
|
type: "error",
|
|
100
|
-
data: { message:
|
|
132
|
+
data: { message: (err as Error).message ?? String(err), code: "run_crashed" },
|
|
133
|
+
});
|
|
134
|
+
} finally {
|
|
135
|
+
finishRun(active, terminal);
|
|
136
|
+
publishNotification({
|
|
137
|
+
type: "run_completed",
|
|
138
|
+
thread_id,
|
|
139
|
+
agent_id: thread?.agent_id ?? null,
|
|
140
|
+
status: terminal,
|
|
141
|
+
preview: assistantContent.replace(/\s+/g, " ").trim().slice(0, 120),
|
|
142
|
+
ts: Date.now(),
|
|
101
143
|
});
|
|
102
144
|
}
|
|
103
|
-
persistAssistantMessage(thread_id, collected.assistantContent, collected.usedTools, collected.toolEvents, null, collected.usage ?? null, prepared.context_snapshot ?? null);
|
|
104
|
-
finishRun(active, collected.terminal);
|
|
105
|
-
publishNotification({
|
|
106
|
-
type: "run_completed",
|
|
107
|
-
thread_id,
|
|
108
|
-
agent_id: thread?.agent_id ?? null,
|
|
109
|
-
status: collected.terminal,
|
|
110
|
-
preview: collected.assistantContent.replace(/\s+/g, " ").trim().slice(0, 120),
|
|
111
|
-
ts: Date.now(),
|
|
112
|
-
});
|
|
113
145
|
})();
|
|
114
146
|
|
|
115
147
|
return new Response(
|
|
@@ -124,8 +156,14 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|
|
124
156
|
// `EventSource` — never `fetch().body.getReader()`, which is unreliable on
|
|
125
157
|
// iOS Safari for long-lived streaming responses.
|
|
126
158
|
//
|
|
127
|
-
//
|
|
128
|
-
// existed, or it finished + TTL-evicted before the GET
|
|
159
|
+
// Emits a single synthetic `done` event and closes when there's no run to
|
|
160
|
+
// attach to (run never existed, or it finished + TTL-evicted before the GET
|
|
161
|
+
// arrived). We deliberately do NOT return 404 here: browsers map a 404 SSE
|
|
162
|
+
// response onto `EventSource.onerror` with no terminal event, which trips
|
|
163
|
+
// EventSource's auto-reconnect logic and leaves the client in a "stream
|
|
164
|
+
// open but silent" state — UIs gating on a `done` event hang forever
|
|
165
|
+
// showing the Stop/Reconnecting affordances. A 200 with `data: {"type":
|
|
166
|
+
// "done"}\n\n` makes the iterator drain cleanly on every transport.
|
|
129
167
|
//
|
|
130
168
|
// `show_tools` / `show_thinking` query params let the caller suppress
|
|
131
169
|
// chunk types it doesn't want to render. Defaults: both on. The full
|
|
@@ -133,14 +171,23 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|
|
133
171
|
// agent run config are run-wide settings, not per-subscriber filters).
|
|
134
172
|
export async function GET(req: NextRequest, { params }: Params) {
|
|
135
173
|
const { thread_id } = await params;
|
|
136
|
-
const run = getRun(thread_id);
|
|
137
|
-
if (!run) return new Response(null, { status: 404 });
|
|
138
|
-
|
|
139
174
|
const showTools = req.nextUrl.searchParams.get("show_tools") !== "false";
|
|
140
175
|
const showThinking = req.nextUrl.searchParams.get("show_thinking") !== "false";
|
|
141
176
|
const stream_options: StreamOptions = {
|
|
142
177
|
filters: { include_tools: showTools, include_thinking: showThinking },
|
|
143
178
|
};
|
|
179
|
+
|
|
180
|
+
const run = getRun(thread_id);
|
|
181
|
+
if (!run) {
|
|
182
|
+
const stream = new ReadableStream({
|
|
183
|
+
start(controller) {
|
|
184
|
+
controller.enqueue(sse({ type: "done" }));
|
|
185
|
+
controller.close();
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
return sseResponse(stream);
|
|
189
|
+
}
|
|
190
|
+
|
|
144
191
|
return attachStream(thread_id, stream_options);
|
|
145
192
|
}
|
|
146
193
|
|
|
@@ -807,7 +807,9 @@ function ToolGroupBlock({
|
|
|
807
807
|
const selectedInGroup = allTools.filter((t) => selected.includes(t.name)).length;
|
|
808
808
|
const allOn = selectedInGroup === allTools.length;
|
|
809
809
|
const someOn = selectedInGroup > 0 && !allOn;
|
|
810
|
-
|
|
810
|
+
// Collapsed by default to keep the editor compact on small / PWA viewports;
|
|
811
|
+
// the header still surfaces the selected/total count.
|
|
812
|
+
const [open, setOpen] = useState(false);
|
|
811
813
|
const headerRef = useRef<HTMLInputElement>(null);
|
|
812
814
|
|
|
813
815
|
useEffect(() => {
|
|
@@ -844,8 +846,8 @@ function ToolGroupBlock({
|
|
|
844
846
|
|
|
845
847
|
// Collapsible per-category block with a tri-state header checkbox. The
|
|
846
848
|
// header toggle flips the entire category on/off; individual tool checkboxes
|
|
847
|
-
// stay available for fine-grained control. Collapsed
|
|
848
|
-
//
|
|
849
|
+
// stay available for fine-grained control. Collapsed by default to keep the
|
|
850
|
+
// editor short on mobile / PWA viewports.
|
|
849
851
|
function ToolCategoryBlock({
|
|
850
852
|
category,
|
|
851
853
|
tools,
|
|
@@ -866,7 +868,8 @@ function ToolCategoryBlock({
|
|
|
866
868
|
const selectedInCat = tools.filter((t) => selected.includes(t.name)).length;
|
|
867
869
|
const allOn = selectedInCat === tools.length;
|
|
868
870
|
const someOn = selectedInCat > 0 && !allOn;
|
|
869
|
-
|
|
871
|
+
// Collapsed by default — see ToolGroupBlock comment.
|
|
872
|
+
const [open, setOpen] = useState(false);
|
|
870
873
|
const headerRef = useRef<HTMLInputElement>(null);
|
|
871
874
|
|
|
872
875
|
// Render the tri-state indeterminate dash via the DOM property (React
|
|
@@ -7,7 +7,7 @@ import rehypeRaw from "rehype-raw";
|
|
|
7
7
|
import rehypeHighlight from "rehype-highlight";
|
|
8
8
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
9
9
|
import "highlight.js/styles/github-dark.css";
|
|
10
|
-
import { Bot, Check, ChevronRight, Copy, Link as LinkIcon, Link2, Loader2, MessageCircle, Paperclip, Pause, Play, User, Users, X } from "lucide-react";
|
|
10
|
+
import { Bot, Check, ChevronRight, Clock, Copy, Eye, EyeOff, Link as LinkIcon, Link2, Loader2, MessageCircle, Paperclip, Pause, Play, User, Users, X } from "lucide-react";
|
|
11
11
|
import type { AgentConfig, Message, UserProfile } from "@/api/types";
|
|
12
12
|
import type { ContentPart } from "@/api/types";
|
|
13
13
|
import { ToolList } from "@/components/chat/ToolList";
|
|
@@ -409,6 +409,110 @@ function UserAvatar({ profile }: { profile?: UserProfile | null }) {
|
|
|
409
409
|
// side changes, update both.
|
|
410
410
|
const parseBridgeContext = parseBridgePrompt;
|
|
411
411
|
|
|
412
|
+
const SILENT_TRIGGER_RE = /\n+\[SILENT_TRIGGER\][^\n]*(?:\n(?!\n).*)*$/;
|
|
413
|
+
|
|
414
|
+
interface ScheduledTaskCardData {
|
|
415
|
+
kind: "scheduled_task";
|
|
416
|
+
prompt: string;
|
|
417
|
+
silent: boolean;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
interface WatcherCardData {
|
|
421
|
+
kind: "watcher";
|
|
422
|
+
label: string;
|
|
423
|
+
tool: string;
|
|
424
|
+
args: string;
|
|
425
|
+
diff: string;
|
|
426
|
+
directive: string;
|
|
427
|
+
silent: boolean;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
type TriggerCardData = ScheduledTaskCardData | WatcherCardData;
|
|
431
|
+
|
|
432
|
+
// Strip the `[SILENT_TRIGGER] …` envelope that runTriggerAgent appends
|
|
433
|
+
// in silent mode so the card can render the user's original prompt and
|
|
434
|
+
// surface a separate "Silent" pill instead of leaking framework prose.
|
|
435
|
+
function stripSilentEnvelope(text: string): { text: string; silent: boolean } {
|
|
436
|
+
const m = SILENT_TRIGGER_RE.exec(text);
|
|
437
|
+
if (!m) return { text, silent: false };
|
|
438
|
+
return { text: text.slice(0, m.index).trimEnd(), silent: true };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function parseTriggerMessage(category: string | null | undefined, raw: string): TriggerCardData | null {
|
|
442
|
+
if (category === "scheduled_task") {
|
|
443
|
+
const { text, silent } = stripSilentEnvelope(raw);
|
|
444
|
+
return { kind: "scheduled_task", prompt: text, silent };
|
|
445
|
+
}
|
|
446
|
+
if (category === "watcher") {
|
|
447
|
+
const { text, silent } = stripSilentEnvelope(raw);
|
|
448
|
+
const headerRe = /^Watcher\s+"([^"]*)"\s+detected a change\.\s*\n+Tool:\s*([^\n]+)\s*\nArgs:\s*([\s\S]*?)\n+---\s*Diff[^\n]*---\s*\n([\s\S]*?)\n\n([\s\S]*)$/;
|
|
449
|
+
const m = headerRe.exec(text);
|
|
450
|
+
if (!m) return null;
|
|
451
|
+
return {
|
|
452
|
+
kind: "watcher",
|
|
453
|
+
label: m[1],
|
|
454
|
+
tool: m[2].trim(),
|
|
455
|
+
args: m[3].trim(),
|
|
456
|
+
diff: m[4].trim(),
|
|
457
|
+
directive: m[5].trim(),
|
|
458
|
+
silent,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Compact header card for trigger-originated user messages (scheduled
|
|
465
|
+
// tasks + watchers, ADR-0027/ADR-0032). Surfaces the trigger type with
|
|
466
|
+
// an icon + label so the operator can tell at a glance that the prompt
|
|
467
|
+
// came from automation and not from them. Watchers additionally expose
|
|
468
|
+
// the diff context in a collapsed section to keep large diffs out of
|
|
469
|
+
// the main bubble height.
|
|
470
|
+
function TriggerMessageCard({ data }: { data: TriggerCardData }) {
|
|
471
|
+
const [diffOpen, setDiffOpen] = useState(false);
|
|
472
|
+
const Icon = data.kind === "scheduled_task" ? Clock : Eye;
|
|
473
|
+
const label = data.kind === "scheduled_task" ? "Scheduled task" : `Watcher: ${data.label || "(unnamed)"}`;
|
|
474
|
+
return (
|
|
475
|
+
<div className="flex flex-col gap-1.5 min-w-0">
|
|
476
|
+
<div className="flex items-center gap-1.5 text-[11px] text-white/85 min-w-0">
|
|
477
|
+
<Icon size={11} className="shrink-0" />
|
|
478
|
+
<span className="font-medium truncate">{label}</span>
|
|
479
|
+
{data.kind === "watcher" && (
|
|
480
|
+
<span className="px-1.5 py-0.5 rounded-full bg-white/15 text-[9.5px] uppercase tracking-wide shrink-0">
|
|
481
|
+
{data.tool}
|
|
482
|
+
</span>
|
|
483
|
+
)}
|
|
484
|
+
{data.silent && (
|
|
485
|
+
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-white/15 text-[9.5px] uppercase tracking-wide shrink-0" title="Silent trigger: reply only if material">
|
|
486
|
+
<EyeOff size={9} />
|
|
487
|
+
silent
|
|
488
|
+
</span>
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
{data.kind === "watcher" && (
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
onClick={() => setDiffOpen((v) => !v)}
|
|
495
|
+
className="flex items-center gap-1 text-left text-[11px] text-white/75 hover:text-white/95"
|
|
496
|
+
aria-expanded={diffOpen}
|
|
497
|
+
title={diffOpen ? "Hide diff" : "Show diff"}
|
|
498
|
+
>
|
|
499
|
+
<ChevronRight size={11} className={`shrink-0 transition-transform ${diffOpen ? "rotate-90" : ""}`} />
|
|
500
|
+
<span>Change context</span>
|
|
501
|
+
</button>
|
|
502
|
+
)}
|
|
503
|
+
{data.kind === "watcher" && diffOpen && (
|
|
504
|
+
<div className="ml-4 flex flex-col gap-1">
|
|
505
|
+
<pre className="m-0 px-2 py-1.5 rounded bg-black/25 text-[11px] leading-snug whitespace-pre-wrap break-words text-white/90 max-h-72 overflow-auto">{data.args}</pre>
|
|
506
|
+
<pre className="m-0 px-2 py-1.5 rounded bg-black/25 text-[11px] leading-snug whitespace-pre-wrap break-words text-white/90 max-h-96 overflow-auto">{data.diff}</pre>
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
<p className="whitespace-pre-wrap text-[13.5px] leading-relaxed">
|
|
510
|
+
{data.kind === "scheduled_task" ? data.prompt : data.directive}
|
|
511
|
+
</p>
|
|
512
|
+
</div>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
412
516
|
// Compact header card for inbound bridge messages. Shows sender + chat
|
|
413
517
|
// context as a single line of metadata above the actual message text, so a
|
|
414
518
|
// WhatsApp DM looks like "Alice • DM\n<text>" and a group message looks
|
|
@@ -1099,6 +1203,9 @@ export const MessageBubble = memo(function MessageBubble({ message, agentConfig,
|
|
|
1099
1203
|
{typeof parsed === "string" ? (
|
|
1100
1204
|
isUser ? (
|
|
1101
1205
|
(() => {
|
|
1206
|
+
const category = "category" in message ? message.category : null;
|
|
1207
|
+
const trigger = parseTriggerMessage(category, parsed);
|
|
1208
|
+
if (trigger) return <TriggerMessageCard data={trigger} />;
|
|
1102
1209
|
const bridge = parseBridgeContext(parsed);
|
|
1103
1210
|
if (bridge) return <BridgeMessageCard ctx={bridge} />;
|
|
1104
1211
|
const ctx = parseCapturedContext(parsed);
|
|
@@ -388,18 +388,9 @@ export function DashboardPanel() {
|
|
|
388
388
|
: "Currency defaults to USD because no location-based currency is available."}
|
|
389
389
|
</p>
|
|
390
390
|
|
|
391
|
-
<section className="rounded-2xl border border-[var(--border)] bg-[var(--bg-secondary)]/90 p-4 shadow-sm">
|
|
392
|
-
<h3 className="text-sm font-medium text-[var(--text-primary)] mb-3">Token usage over time</h3>
|
|
393
|
-
<InteractiveTokenChart
|
|
394
|
-
series={series}
|
|
395
|
-
selectedDay={selectedDay}
|
|
396
|
-
onSelectDay={(day) => setSelectedDay((prev) => (prev === day ? null : day))}
|
|
397
|
-
/>
|
|
398
|
-
</section>
|
|
399
|
-
|
|
400
391
|
<section className="rounded-2xl border border-[var(--border)] bg-[var(--bg-secondary)]/90 p-4 shadow-sm">
|
|
401
392
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
402
|
-
<h3 className="text-sm font-medium text-[var(--text-primary)]">
|
|
393
|
+
<h3 className="text-sm font-medium text-[var(--text-primary)]">Tokens & cost over time</h3>
|
|
403
394
|
{selectedDay ? (
|
|
404
395
|
<button
|
|
405
396
|
type="button"
|
|
@@ -414,12 +405,19 @@ export function DashboardPanel() {
|
|
|
414
405
|
<span className="text-[11px] text-[var(--text-secondary)]">Click a day to narrow the breakdown</span>
|
|
415
406
|
)}
|
|
416
407
|
</div>
|
|
417
|
-
<
|
|
408
|
+
<InteractiveTokenChart
|
|
418
409
|
series={series}
|
|
419
|
-
currencyInfo={currencyInfo}
|
|
420
410
|
selectedDay={selectedDay}
|
|
421
411
|
onSelectDay={(day) => setSelectedDay((prev) => (prev === day ? null : day))}
|
|
422
412
|
/>
|
|
413
|
+
<div className="mt-3 border-t border-[var(--border)]/60 pt-3">
|
|
414
|
+
<InteractiveCostChart
|
|
415
|
+
series={series}
|
|
416
|
+
currencyInfo={currencyInfo}
|
|
417
|
+
selectedDay={selectedDay}
|
|
418
|
+
onSelectDay={(day) => setSelectedDay((prev) => (prev === day ? null : day))}
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
423
421
|
</section>
|
|
424
422
|
|
|
425
423
|
<div className="grid md:grid-cols-2 gap-4">
|
|
@@ -670,6 +668,14 @@ function InsightChip({ label, value, hint }: { label: string; value: string; hin
|
|
|
670
668
|
);
|
|
671
669
|
}
|
|
672
670
|
|
|
671
|
+
// Per-tier breakdown colors shared by the stacked token chart legend.
|
|
672
|
+
const TIER_COLORS: Record<"hot" | "warm" | "facts" | "overhead", string> = {
|
|
673
|
+
hot: "#22d3ee",
|
|
674
|
+
warm: "#f59e0b",
|
|
675
|
+
facts: "#a78bfa",
|
|
676
|
+
overhead: "#94a3b8",
|
|
677
|
+
};
|
|
678
|
+
|
|
673
679
|
type DonutSlice = {
|
|
674
680
|
id: string;
|
|
675
681
|
label: string;
|
|
@@ -1033,6 +1039,15 @@ function InteractiveTokenChart({
|
|
|
1033
1039
|
const totalHeight = hasData && maxTotal > 0 ? Math.max(4, Math.round((total / maxTotal) * 150)) : 0;
|
|
1034
1040
|
const inputHeight = hasData ? Math.round((point.input_tokens_est / total) * totalHeight) : 0;
|
|
1035
1041
|
const outputHeight = hasData ? Math.max(0, totalHeight - inputHeight) : 0;
|
|
1042
|
+
// Subdivide the input portion by measured tier breakdown when the
|
|
1043
|
+
// day has at least one snapshotted assistant turn. Legacy days
|
|
1044
|
+
// (measured_input_tokens === 0) fall back to a solid violet block.
|
|
1045
|
+
const tier = point.tier_tokens;
|
|
1046
|
+
const tierTotal = tier?.measured_input_tokens ?? 0;
|
|
1047
|
+
const hotPx = tierTotal > 0 ? Math.round((tier.hot_tokens / tierTotal) * inputHeight) : 0;
|
|
1048
|
+
const warmPx = tierTotal > 0 ? Math.round((tier.warm_tokens / tierTotal) * inputHeight) : 0;
|
|
1049
|
+
const factsPx = tierTotal > 0 ? Math.round((tier.facts_tokens / tierTotal) * inputHeight) : 0;
|
|
1050
|
+
const overheadPx = tierTotal > 0 ? Math.max(0, inputHeight - hotPx - warmPx - factsPx) : 0;
|
|
1036
1051
|
const isActive = idx === hovered;
|
|
1037
1052
|
const isSelected = selectedDay === point.day;
|
|
1038
1053
|
const clickable = !!onSelectDay && hasData;
|
|
@@ -1070,13 +1085,50 @@ function InteractiveTokenChart({
|
|
|
1070
1085
|
transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18}ms`,
|
|
1071
1086
|
}}
|
|
1072
1087
|
/>
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1088
|
+
{tierTotal > 0 ? (
|
|
1089
|
+
<>
|
|
1090
|
+
<div
|
|
1091
|
+
className="w-full"
|
|
1092
|
+
style={{
|
|
1093
|
+
background: TIER_COLORS.hot,
|
|
1094
|
+
height: barsReady ? `${hotPx}px` : "0px",
|
|
1095
|
+
transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 30}ms`,
|
|
1096
|
+
}}
|
|
1097
|
+
/>
|
|
1098
|
+
<div
|
|
1099
|
+
className="w-full"
|
|
1100
|
+
style={{
|
|
1101
|
+
background: TIER_COLORS.warm,
|
|
1102
|
+
height: barsReady ? `${warmPx}px` : "0px",
|
|
1103
|
+
transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 42}ms`,
|
|
1104
|
+
}}
|
|
1105
|
+
/>
|
|
1106
|
+
<div
|
|
1107
|
+
className="w-full"
|
|
1108
|
+
style={{
|
|
1109
|
+
background: TIER_COLORS.facts,
|
|
1110
|
+
height: barsReady ? `${factsPx}px` : "0px",
|
|
1111
|
+
transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 54}ms`,
|
|
1112
|
+
}}
|
|
1113
|
+
/>
|
|
1114
|
+
<div
|
|
1115
|
+
className="w-full"
|
|
1116
|
+
style={{
|
|
1117
|
+
background: TIER_COLORS.overhead,
|
|
1118
|
+
height: barsReady ? `${overheadPx}px` : "0px",
|
|
1119
|
+
transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 66}ms`,
|
|
1120
|
+
}}
|
|
1121
|
+
/>
|
|
1122
|
+
</>
|
|
1123
|
+
) : (
|
|
1124
|
+
<div
|
|
1125
|
+
className="w-full bg-gradient-to-t from-indigo-500 to-violet-400"
|
|
1126
|
+
style={{
|
|
1127
|
+
height: barsReady ? `${inputHeight}px` : "0px",
|
|
1128
|
+
transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 30}ms`,
|
|
1129
|
+
}}
|
|
1130
|
+
/>
|
|
1131
|
+
)}
|
|
1080
1132
|
</>
|
|
1081
1133
|
) : null}
|
|
1082
1134
|
</div>
|
|
@@ -1089,11 +1141,17 @@ function InteractiveTokenChart({
|
|
|
1089
1141
|
</div>
|
|
1090
1142
|
</div>
|
|
1091
1143
|
<div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-[var(--text-secondary)]">
|
|
1092
|
-
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-
|
|
1144
|
+
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: TIER_COLORS.hot }} />hot</span>
|
|
1145
|
+
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: TIER_COLORS.warm }} />warm</span>
|
|
1146
|
+
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: TIER_COLORS.facts }} />facts</span>
|
|
1147
|
+
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: TIER_COLORS.overhead }} />overhead</span>
|
|
1148
|
+
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-400" />input (est)</span>
|
|
1093
1149
|
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400" />output</span>
|
|
1094
1150
|
{active && (
|
|
1095
1151
|
<span className="ml-auto text-[var(--text-primary)]">
|
|
1096
|
-
|
|
1152
|
+
{active.tier_tokens.measured_input_tokens > 0
|
|
1153
|
+
? `hot ${formatInt(active.tier_tokens.hot_tokens)} · warm ${formatInt(active.tier_tokens.warm_tokens)} · facts ${formatInt(active.tier_tokens.facts_tokens)} · overhead ${formatInt(active.tier_tokens.overhead_tokens)} · out ${formatInt(active.output_tokens_est)}`
|
|
1154
|
+
: `in ${formatInt(active.input_tokens_est)} · out ${formatInt(active.output_tokens_est)}`}
|
|
1097
1155
|
</span>
|
|
1098
1156
|
)}
|
|
1099
1157
|
</div>
|
package/hooks/useSSE.ts
CHANGED
|
@@ -126,11 +126,21 @@ export function useSSE(onDone?: () => void) {
|
|
|
126
126
|
if ((err as Error).name !== "AbortError") {
|
|
127
127
|
setError(String(err));
|
|
128
128
|
}
|
|
129
|
+
return { accepted: false };
|
|
130
|
+
} finally {
|
|
131
|
+
// Always release the gate when the stream ends — defends against the
|
|
132
|
+
// consume() loop returning without ever observing a terminal `done`
|
|
133
|
+
// event (e.g. the EventSource closed cleanly with zero events). If
|
|
134
|
+
// we relied solely on the `done` branch inside consume(), the chat
|
|
135
|
+
// would stay locked behind the Stop button forever.
|
|
136
|
+
//
|
|
137
|
+
// We intentionally do NOT clear streamingContent / thinkingContent
|
|
138
|
+
// here — the consumer (ChatView) swaps them for the persisted
|
|
139
|
+
// assistant bubble once the refetch lands; clearing now would yank
|
|
140
|
+
// the text out from under the user. The next start()/attach() resets
|
|
141
|
+
// them.
|
|
129
142
|
setStreaming(false);
|
|
130
|
-
setStreamingContent("");
|
|
131
|
-
setThinkingContent("");
|
|
132
143
|
closeActivity();
|
|
133
|
-
return { accepted: false };
|
|
134
144
|
}
|
|
135
145
|
}, [consume, openActivity, closeActivity]);
|
|
136
146
|
|
|
@@ -179,15 +189,18 @@ export function useSSE(onDone?: () => void) {
|
|
|
179
189
|
try {
|
|
180
190
|
await consume(subscribeRun(threadId, ctrl.signal));
|
|
181
191
|
} catch (err) {
|
|
182
|
-
// Attach failures are non-fatal — clear the gate and let the consumer
|
|
183
|
-
// drain anything queued during session load. The common case is
|
|
184
|
-
// "no run to attach to" (server returns 404, EventSource fails to
|
|
185
|
-
// open) — completely normal when navigating into an idle session.
|
|
186
|
-
setStreaming(false);
|
|
187
|
-
closeActivity();
|
|
188
192
|
if ((err as Error).name !== "AbortError") {
|
|
189
193
|
onDone?.();
|
|
190
194
|
}
|
|
195
|
+
} finally {
|
|
196
|
+
// Always release the optimistic gate when the stream ends — whether
|
|
197
|
+
// via terminal `done`/`error` event, a thrown failure, or a clean
|
|
198
|
+
// EventSource close with no events (idle thread, server returned 404
|
|
199
|
+
// so the iterator exited without yielding). Without this, navigating
|
|
200
|
+
// into an idle thread leaves streaming=true forever: the Stop button
|
|
201
|
+
// hangs in the composer and the "Reconnecting…" badge never clears.
|
|
202
|
+
setStreaming(false);
|
|
203
|
+
closeActivity();
|
|
191
204
|
}
|
|
192
205
|
}, [consume, onDone, openActivity, closeActivity]);
|
|
193
206
|
|
|
@@ -10,6 +10,7 @@ import os from "node:os";
|
|
|
10
10
|
import type { AgentConfigRow } from "@/lib/stores/agent-configs";
|
|
11
11
|
import { getUserProfile } from "@/lib/stores/user-profile";
|
|
12
12
|
import { listIntegrations } from "@/lib/stores/integrations";
|
|
13
|
+
import { listEnabledDocumentSources, getDocumentSourceStats } from "@/lib/stores/document-sources";
|
|
13
14
|
import { buildAdaptivePersonaContext } from "@/lib/agents/adaptive-persona";
|
|
14
15
|
import { resolveHarness } from "@/lib/agents/harness/resolve";
|
|
15
16
|
import {
|
|
@@ -49,6 +50,7 @@ export function buildSystemPrompt(ctx: SystemPromptContext): string {
|
|
|
49
50
|
adaptivePersonaCtx,
|
|
50
51
|
buildUserContext(),
|
|
51
52
|
buildIntegrationsContext(),
|
|
53
|
+
buildDocumentsContext(),
|
|
52
54
|
harnessParts.capabilities,
|
|
53
55
|
harnessParts.plan_first,
|
|
54
56
|
harnessParts.presentation,
|
|
@@ -203,3 +205,31 @@ function buildDelegatesContext(lines: string[]): string {
|
|
|
203
205
|
...lines,
|
|
204
206
|
].join("\n");
|
|
205
207
|
}
|
|
208
|
+
|
|
209
|
+
// Surface indexed Documents so the model knows the RAG corpus exists.
|
|
210
|
+
// Without this nudge agents almost never call `documents_search` — they have
|
|
211
|
+
// no signal that any local content is searchable. Gated on actually-indexed
|
|
212
|
+
// chunks (not just configured sources) so an empty/erroring source doesn't
|
|
213
|
+
// produce false advertising.
|
|
214
|
+
function buildDocumentsContext(): string {
|
|
215
|
+
const sources = listEnabledDocumentSources();
|
|
216
|
+
if (sources.length === 0) return "";
|
|
217
|
+
|
|
218
|
+
let totalChunks = 0;
|
|
219
|
+
const lines: string[] = [];
|
|
220
|
+
for (const s of sources) {
|
|
221
|
+
const stats = getDocumentSourceStats(s.id);
|
|
222
|
+
if (stats.chunk_count === 0) continue;
|
|
223
|
+
totalChunks += stats.chunk_count;
|
|
224
|
+
const label = s.label ?? s.path;
|
|
225
|
+
lines.push(`- ${label} (${s.kind}, ${stats.chunk_count} chunks)`);
|
|
226
|
+
}
|
|
227
|
+
if (totalChunks === 0) return "";
|
|
228
|
+
|
|
229
|
+
return [
|
|
230
|
+
"--- Indexed documents ---",
|
|
231
|
+
`The user has ${totalChunks} indexed chunks across ${lines.length} document source(s) available to you:`,
|
|
232
|
+
...lines,
|
|
233
|
+
"Call `documents_search` whenever the user asks about local files, notes, project docs, or any content that sounds like it lives in one of these sources. Prefer it over guessing from training data. Use `documents_list_sources` to enumerate, and pass `source_id` to scope a search.",
|
|
234
|
+
].join("\n");
|
|
235
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { startRun, finishRun, getRun, broadcast } from "./run-registry";
|
|
3
|
+
import type { StreamChunk } from "./base";
|
|
4
|
+
|
|
5
|
+
const delta = (s: string): StreamChunk => ({ type: "text_delta", data: { delta: s } } as StreamChunk);
|
|
6
|
+
|
|
7
|
+
describe("run-registry watchdog", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("force-evicts a leaked 'running' entry after JARELA_RUN_MAX_MS", () => {
|
|
16
|
+
const prev = process.env.JARELA_RUN_MAX_MS;
|
|
17
|
+
process.env.JARELA_RUN_MAX_MS = "60000";
|
|
18
|
+
try {
|
|
19
|
+
const tid = `t-leak-${Date.now()}`;
|
|
20
|
+
const run = startRun(tid, null);
|
|
21
|
+
expect(getRun(tid)).toBe(run);
|
|
22
|
+
expect(run.status).toBe("running");
|
|
23
|
+
|
|
24
|
+
// Driver never calls finishRun(). Without the watchdog the entry
|
|
25
|
+
// would sit as "running" forever, 409'ing every subsequent submit.
|
|
26
|
+
vi.advanceTimersByTime(60_000 + 10);
|
|
27
|
+
|
|
28
|
+
expect(run.status).toBe("error");
|
|
29
|
+
// Still in the map briefly (TTL-evicted 5 min after finishRun),
|
|
30
|
+
// but no longer blocks new runs because status is no longer "running".
|
|
31
|
+
const stillThere = getRun(tid);
|
|
32
|
+
expect(stillThere === null || stillThere.status === "error").toBe(true);
|
|
33
|
+
|
|
34
|
+
// A fresh startRun for the same thread must now succeed.
|
|
35
|
+
expect(() => startRun(tid, null)).not.toThrow();
|
|
36
|
+
} finally {
|
|
37
|
+
if (prev === undefined) delete process.env.JARELA_RUN_MAX_MS;
|
|
38
|
+
else process.env.JARELA_RUN_MAX_MS = prev;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not clobber a run that finishes normally before the watchdog", () => {
|
|
43
|
+
const prev = process.env.JARELA_RUN_MAX_MS;
|
|
44
|
+
process.env.JARELA_RUN_MAX_MS = "60000";
|
|
45
|
+
try {
|
|
46
|
+
const tid = `t-ok-${Date.now()}`;
|
|
47
|
+
const run = startRun(tid, null);
|
|
48
|
+
finishRun(run, "done");
|
|
49
|
+
expect(run.status).toBe("done");
|
|
50
|
+
vi.advanceTimersByTime(60_000 + 10);
|
|
51
|
+
// Watchdog must not have flipped a "done" run to "error".
|
|
52
|
+
expect(run.status).toBe("done");
|
|
53
|
+
} finally {
|
|
54
|
+
if (prev === undefined) delete process.env.JARELA_RUN_MAX_MS;
|
|
55
|
+
else process.env.JARELA_RUN_MAX_MS = prev;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("force-finishes a stalled run after JARELA_RUN_IDLE_MS of no progress", () => {
|
|
60
|
+
const prev = process.env.JARELA_RUN_IDLE_MS;
|
|
61
|
+
process.env.JARELA_RUN_IDLE_MS = "5000";
|
|
62
|
+
try {
|
|
63
|
+
const tid = `t-idle-${Date.now()}`;
|
|
64
|
+
const run = startRun(tid, null);
|
|
65
|
+
vi.advanceTimersByTime(5_000 + 10);
|
|
66
|
+
expect(run.status).toBe("error");
|
|
67
|
+
} finally {
|
|
68
|
+
if (prev === undefined) delete process.env.JARELA_RUN_IDLE_MS;
|
|
69
|
+
else process.env.JARELA_RUN_IDLE_MS = prev;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not fire idle watchdog while broadcast keeps streaming", () => {
|
|
74
|
+
const prev = process.env.JARELA_RUN_IDLE_MS;
|
|
75
|
+
process.env.JARELA_RUN_IDLE_MS = "5000";
|
|
76
|
+
try {
|
|
77
|
+
const tid = `t-stream-${Date.now()}`;
|
|
78
|
+
const run = startRun(tid, null);
|
|
79
|
+
// Stream a chunk every 2s for 12s — total elapsed > idleMs but
|
|
80
|
+
// never idle for >5s in a row.
|
|
81
|
+
for (let i = 0; i < 6; i++) {
|
|
82
|
+
vi.advanceTimersByTime(2_000);
|
|
83
|
+
broadcast(run, delta("x"));
|
|
84
|
+
}
|
|
85
|
+
expect(run.status).toBe("running");
|
|
86
|
+
// Now go quiet and confirm it fires.
|
|
87
|
+
vi.advanceTimersByTime(5_000 + 10);
|
|
88
|
+
expect(run.status).toBe("error");
|
|
89
|
+
} finally {
|
|
90
|
+
if (prev === undefined) delete process.env.JARELA_RUN_IDLE_MS;
|
|
91
|
+
else process.env.JARELA_RUN_IDLE_MS = prev;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|