@circuitwall/jarela 0.7.2 → 0.7.3
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/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/agents/[id]/compact/route.js +51 -35
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/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 +515 -104
- 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/chunks/1683.js +26 -16
- package/.next/standalone/.next/server/chunks/1683.js.map +1 -1
- package/.next/standalone/.next/server/chunks/{317.js → 5432.js} +11100 -10858
- package/.next/standalone/.next/server/chunks/5432.js.map +1 -0
- package/.next/standalone/.next/server/chunks/7885.js +606 -353
- package/.next/standalone/.next/server/chunks/7885.js.map +1 -1
- package/.next/standalone/.next/server/chunks/8135.js +59 -16
- package/.next/standalone/.next/server/chunks/8135.js.map +1 -1
- package/.next/standalone/.next/server/chunks/9032.js +3 -3
- package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
- package/.next/standalone/.next/server/instrumentation.js +3 -3
- package/.next/standalone/.next/server/instrumentation.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/app/{page-a20902703c0a4f10.js → page-9fb006074fb13526.js} +582 -171
- package/.next/standalone/.next/static/chunks/app/page-9fb006074fb13526.js.map +1 -0
- package/.next/standalone/.next/static/css/5507dbe1cdc6c599.css +5 -0
- package/.next/standalone/.next/static/css/5507dbe1cdc6c599.css.map +1 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +83 -1
- package/api/types.ts +7 -0
- package/app/api/v1/agents/[id]/compact/route.ts +2 -40
- package/components/bridges/BridgeEditor.tsx +8 -0
- package/components/chat/MessageBubble.tsx +3 -36
- package/components/models/ModelEditor.tsx +141 -0
- package/components/scheduled-tasks/ScheduledTasksPanel.tsx +5 -0
- package/components/scheduled-tasks/WatchersSection.tsx +5 -0
- package/lib/agents/context-budget.test.ts +128 -0
- package/lib/agents/context-budget.ts +128 -0
- package/lib/agents/conversation-summary.test.ts +68 -0
- package/lib/agents/conversation-summary.ts +51 -0
- package/lib/agents/run-thread.ts +112 -2
- package/lib/bridges/dispatcher.test.ts +134 -0
- package/lib/bridges/dispatcher.ts +34 -16
- package/lib/bridges/message-role.test.ts +83 -0
- package/lib/bridges/message-role.ts +46 -0
- package/lib/triggers/handlers/watcher.test.ts +23 -4
- package/lib/triggers/handlers/watcher.ts +56 -8
- package/package.json +1 -1
- package/.next/standalone/.next/server/chunks/317.js.map +0 -1
- package/.next/standalone/.next/static/chunks/app/page-a20902703c0a4f10.js.map +0 -1
- package/.next/standalone/.next/static/css/cc66c456aba91258.css +0 -5
- package/.next/standalone/.next/static/css/cc66c456aba91258.css.map +0 -1
- /package/.next/standalone/.next/static/{IauO0rNZkUVPX834k-SBa → AbCOWpaxP4v4lUSeFWWYz}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{IauO0rNZkUVPX834k-SBa → AbCOWpaxP4v4lUSeFWWYz}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
16
|
<a href="#quick-start">Quick start</a> ·
|
|
17
|
+
<a href="#configuration-guide-home--work">Config guide</a> ·
|
|
17
18
|
<a href="#supported-platforms">Platforms</a> ·
|
|
18
19
|
<a href="#features">Features</a> ·
|
|
19
20
|
<a href="#productivity-stacks-google--microsoft-at-parity">Google + Microsoft</a> ·
|
|
@@ -59,6 +60,87 @@
|
|
|
59
60
|
</video>
|
|
60
61
|
</p>
|
|
61
62
|
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
Get to a working local agent in under 10 minutes:
|
|
66
|
+
|
|
67
|
+
1. Install using one channel:
|
|
68
|
+
- npm: `npm install -g @circuitwall/jarela`
|
|
69
|
+
- Docker: `docker run -d -p 127.0.0.1:4312:4312 -v jarela-data:/data andrewgewu/jarela`
|
|
70
|
+
- portable archive: download from [Releases](https://github.com/CircuitWall/jarela/releases/latest)
|
|
71
|
+
2. Start Jarela:
|
|
72
|
+
- npm install: run `jarela`
|
|
73
|
+
- source checkout: run `npm run dev` (dev) or `npm run build && npm start` (prod)
|
|
74
|
+
3. Open the app:
|
|
75
|
+
- dev mode: `http://localhost:3000`
|
|
76
|
+
- installed/prod mode: `http://127.0.0.1:4312`
|
|
77
|
+
4. Complete first-run setup:
|
|
78
|
+
- add one provider key (Models/setup screen), then create your first agent
|
|
79
|
+
- enable tool categories that match your workflow (Mail, Calendar, Files, Web, etc.)
|
|
80
|
+
5. Optionally connect integrations from Connections:
|
|
81
|
+
- Gmail/Calendar, Outlook/Calendar, GitHub, Atlassian, MCP servers, WhatsApp bridge
|
|
82
|
+
|
|
83
|
+
For platform-specific install, service/autostart, and operations detail, jump to
|
|
84
|
+
[Installation and runtime details](#installation-and-runtime-details).
|
|
85
|
+
|
|
86
|
+
## Configuration guide (home + work)
|
|
87
|
+
|
|
88
|
+
This section gives opinionated starter configs and tool chains you can adapt.
|
|
89
|
+
Pattern that works well: one agent per lane (home, work), each with a narrow
|
|
90
|
+
tool policy and clear trigger source.
|
|
91
|
+
|
|
92
|
+
### Home setup
|
|
93
|
+
|
|
94
|
+
Recommended baseline:
|
|
95
|
+
|
|
96
|
+
- Agent name: `Home assistant`
|
|
97
|
+
- Model: low-cost chat model for routine tasks
|
|
98
|
+
- Tool categories: `Mail`, `Calendar`, `Web`, `Memory`, `Schedule`
|
|
99
|
+
- Bridges: WhatsApp route for family group in `silent_mode` for monitoring, and
|
|
100
|
+
one direct route with replies enabled for active planning
|
|
101
|
+
|
|
102
|
+
Popular goals and tool chains:
|
|
103
|
+
|
|
104
|
+
1. Daily family agenda brief
|
|
105
|
+
- Chain: `calendar_list_events` -> `gmail_search`/`outlook_search` -> `memory_write`
|
|
106
|
+
- Result: one morning summary with events + high-priority emails
|
|
107
|
+
2. Trip planning helper
|
|
108
|
+
- Chain: `web_search` -> `calendar_create_event` -> `gmail_create_draft`/`outlook_create_draft`
|
|
109
|
+
- Result: compares options, blocks time, drafts confirmation mail
|
|
110
|
+
3. Household reminders
|
|
111
|
+
- Chain: `schedule_task` -> `memory_read` -> `calendar_create_event`
|
|
112
|
+
- Result: recurring reminders with memory-backed context
|
|
113
|
+
|
|
114
|
+
### Work setup
|
|
115
|
+
|
|
116
|
+
Recommended baseline:
|
|
117
|
+
|
|
118
|
+
- Agent name: `Work operator`
|
|
119
|
+
- Model: stronger reasoning model for cross-system workflows
|
|
120
|
+
- Tool categories: `Work` (GitHub + Atlassian), `Mail`, `Calendar`, `Files`, `Documents`, `Schedule`
|
|
121
|
+
- Connections: GitHub PAT, Atlassian token, one mail/calendar stack (or both)
|
|
122
|
+
- Safety: keep destructive operations behind approvals and draft-first mail policy
|
|
123
|
+
|
|
124
|
+
Popular goals and tool chains:
|
|
125
|
+
|
|
126
|
+
1. Standup prep in 5 minutes
|
|
127
|
+
- Chain: `jira_search_issues` -> `github_list_pulls` -> `documents_search` -> `memory_write`
|
|
128
|
+
- Result: compact status grouped by in-progress, blocked, and review-ready
|
|
129
|
+
2. Incident follow-up workflow
|
|
130
|
+
- Chain: `github_get_issue`/`jira_get_issue` -> `file_write` (draft runbook notes) -> `outlook_create_draft`/`gmail_create_draft`
|
|
131
|
+
- Result: structured summary plus stakeholder update draft without auto-send
|
|
132
|
+
3. Weekly planning and meeting slots
|
|
133
|
+
- Chain: `calendar_list_events` -> `outlook_calendar_create_event`/`calendar_create_event` -> `jira_update_issue`
|
|
134
|
+
- Result: reserves focus blocks and updates ticket state in one pass
|
|
135
|
+
|
|
136
|
+
### Suggested operating pattern
|
|
137
|
+
|
|
138
|
+
1. Keep `Home assistant` and `Work operator` separate.
|
|
139
|
+
2. Use `silent_mode` on noisy bridge/group channels so the agent reports important events without replying publicly.
|
|
140
|
+
3. Prefer watcher/scheduled `script` reactions for deterministic automations; use `agent_prompt` when judgment is required.
|
|
141
|
+
4. Store recurring context in memory namespaces (`home/*`, `work/*`) so prompts stay compact.
|
|
142
|
+
5. Start with minimal tool categories, then expand only where needed.
|
|
143
|
+
|
|
62
144
|
## What is Jarela?
|
|
63
145
|
|
|
64
146
|
Jarela is a desktop-grade chat UI for **LangGraph** agents that runs as a
|
|
@@ -180,7 +262,7 @@ create an Outlook Calendar invite in the same turn.
|
|
|
180
262
|
required when the corporate roots are already in the system keychain
|
|
181
263
|
(ADR-0020).
|
|
182
264
|
|
|
183
|
-
##
|
|
265
|
+
## Installation and runtime details
|
|
184
266
|
|
|
185
267
|
Works on **Windows 10/11**, **macOS 12+**, and **Linux** (any modern glibc
|
|
186
268
|
distro). See [Supported platforms](#supported-platforms) for the per-OS
|
package/api/types.ts
CHANGED
|
@@ -120,6 +120,13 @@ export interface ModelConfig {
|
|
|
120
120
|
extra_headers?: Record<string, string>;
|
|
121
121
|
temperature?: number;
|
|
122
122
|
max_tokens?: number;
|
|
123
|
+
context_window_tokens?: number;
|
|
124
|
+
context_tier_proportions?: {
|
|
125
|
+
hot?: number;
|
|
126
|
+
warm?: number;
|
|
127
|
+
facts?: number;
|
|
128
|
+
};
|
|
129
|
+
context_tier_priority?: ["hot" | "warm" | "facts", "hot" | "warm" | "facts", "hot" | "warm" | "facts"];
|
|
123
130
|
};
|
|
124
131
|
is_default: boolean;
|
|
125
132
|
created_at: string;
|
|
@@ -5,35 +5,10 @@ import { getModelConfig, getDefaultModelConfig } from "@/lib/stores/model-config
|
|
|
5
5
|
import { getProvider } from "@/lib/providers";
|
|
6
6
|
import { putMemory } from "@/lib/stores/memory";
|
|
7
7
|
import type { ProviderParams } from "@/lib/providers/types";
|
|
8
|
-
import
|
|
8
|
+
import { summarizeTranscript, transcriptText } from "@/lib/agents/conversation-summary";
|
|
9
9
|
|
|
10
10
|
type Params = { params: Promise<{ id: string }> };
|
|
11
11
|
|
|
12
|
-
// Messages with attachments are stored as a JSON-stringified ContentPart[]
|
|
13
|
-
// (text + image/file parts whose `data` is base64). Feeding that raw into
|
|
14
|
-
// the summarizer dumps multi-MB base64 blobs into the prompt and blows the
|
|
15
|
-
// context window. For compaction we only need the textual narrative; replace
|
|
16
|
-
// image/file parts with short stubs so the summary still mentions them.
|
|
17
|
-
function transcriptText(raw: string): string {
|
|
18
|
-
if (!raw.startsWith("[")) return raw;
|
|
19
|
-
try {
|
|
20
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
21
|
-
if (!Array.isArray(parsed)) return raw;
|
|
22
|
-
return (parsed as ContentPart[])
|
|
23
|
-
.map((p) => {
|
|
24
|
-
if (p.type === "text") return p.text;
|
|
25
|
-
if (p.type === "image") return `[image attachment: ${p.media_type}]`;
|
|
26
|
-
if (p.type === "file") return `[file attachment: ${p.name} (${p.media_type})]`;
|
|
27
|
-
return "";
|
|
28
|
-
})
|
|
29
|
-
.filter(Boolean)
|
|
30
|
-
.join(" ")
|
|
31
|
-
.trim();
|
|
32
|
-
} catch {
|
|
33
|
-
return raw;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
12
|
export async function POST(_req: Request, { params }: Params) {
|
|
38
13
|
const { id } = await params;
|
|
39
14
|
const agent = getAgentConfig(id);
|
|
@@ -76,26 +51,13 @@ export async function POST(_req: Request, { params }: Params) {
|
|
|
76
51
|
const messageCount = rows.length;
|
|
77
52
|
|
|
78
53
|
const provider = getProvider(cfg.provider);
|
|
79
|
-
const summaryMessages = [
|
|
80
|
-
{
|
|
81
|
-
role: "system" as const,
|
|
82
|
-
content: "You are a concise summarizer. Summarize the conversation below in 3-7 bullet points, capturing key facts, decisions, and context that would be useful to remember later.",
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
role: "user" as const,
|
|
86
|
-
content: `Conversation to summarize:\n\n${transcript}`,
|
|
87
|
-
},
|
|
88
|
-
];
|
|
89
54
|
|
|
90
55
|
// Summarize FIRST. Only clear messages once we have a summary safely
|
|
91
56
|
// persisted to memory — otherwise a model failure would lose history with
|
|
92
57
|
// no recovery path.
|
|
93
58
|
let summary = "";
|
|
94
59
|
try {
|
|
95
|
-
|
|
96
|
-
for await (const chunk of stream) {
|
|
97
|
-
summary += chunk;
|
|
98
|
-
}
|
|
60
|
+
summary = await summarizeTranscript(provider, cfg.model_id, providerParams, transcript);
|
|
99
61
|
} catch (err) {
|
|
100
62
|
return NextResponse.json(
|
|
101
63
|
{ error: `Summarization failed: ${String(err)}`, code: "summarize_failed" },
|
|
@@ -556,6 +556,14 @@ function RouteTable({ bridge_id }: { bridge_id: string }) {
|
|
|
556
556
|
<p className="text-[11px] text-fg-faint py-2">No routes. Inbound messages will be ignored unless you add a catch-all route.</p>
|
|
557
557
|
)}
|
|
558
558
|
|
|
559
|
+
{routes.length > 0 && (
|
|
560
|
+
<p className="text-[10px] text-fg-faint pb-1">
|
|
561
|
+
Route settings: pick which agent receives this chat, choose whether replies are
|
|
562
|
+
sent back to chat (Silent mode off) or kept user-side only (Silent mode on), and
|
|
563
|
+
select who should trigger outbound replies when not silent.
|
|
564
|
+
</p>
|
|
565
|
+
)}
|
|
566
|
+
|
|
559
567
|
{routes.map((r) => {
|
|
560
568
|
const a = agents.find((x) => x.id === r.agent_id) ?? null;
|
|
561
569
|
return (
|
|
@@ -14,6 +14,7 @@ import { ToolList } from "@/components/chat/ToolList";
|
|
|
14
14
|
import { useAppContext } from "@/contexts/AppContext";
|
|
15
15
|
import { parseHref } from "@/lib/ui/navigate";
|
|
16
16
|
import { pushToast } from "@/lib/ui/toasts";
|
|
17
|
+
import { parseBridgePrompt, type BridgePromptContext } from "@/lib/bridges/message-role";
|
|
17
18
|
|
|
18
19
|
interface ExtractedRef {
|
|
19
20
|
title: string;
|
|
@@ -376,48 +377,14 @@ function UserAvatar({ profile }: { profile?: UserProfile | null }) {
|
|
|
376
377
|
// metadata as a compact header card instead of dumping six bracketed `[key:value]`
|
|
377
378
|
// lines at the top of every bubble. Format is fixed by dispatcher; if either
|
|
378
379
|
// side changes, update both.
|
|
379
|
-
|
|
380
|
-
bridgeId: string;
|
|
381
|
-
chatJid: string;
|
|
382
|
-
chatName: string;
|
|
383
|
-
isGroup: boolean;
|
|
384
|
-
senderJid: string;
|
|
385
|
-
senderName: string;
|
|
386
|
-
body: string;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function parseBridgeContext(raw: string): BridgeContext | null {
|
|
390
|
-
if (!raw.startsWith("[bridge:")) return null;
|
|
391
|
-
// Walk the contiguous `[key:value]` prefix line-by-line; stop at the first
|
|
392
|
-
// blank line (dispatcher always separates the header from the body with one).
|
|
393
|
-
const headers: Record<string, string> = {};
|
|
394
|
-
const lines = raw.split("\n");
|
|
395
|
-
let i = 0;
|
|
396
|
-
for (; i < lines.length; i++) {
|
|
397
|
-
const line = lines[i];
|
|
398
|
-
if (line === "") { i++; break; }
|
|
399
|
-
const m = /^\[([a-z_]+):([\s\S]*)\]$/.exec(line);
|
|
400
|
-
if (!m) return null;
|
|
401
|
-
headers[m[1]] = m[2];
|
|
402
|
-
}
|
|
403
|
-
if (!headers.bridge || !headers.chat_jid || !headers.chat_type) return null;
|
|
404
|
-
return {
|
|
405
|
-
bridgeId: headers.bridge,
|
|
406
|
-
chatJid: headers.chat_jid,
|
|
407
|
-
chatName: headers.chat_name || headers.chat_jid,
|
|
408
|
-
isGroup: headers.chat_type === "group",
|
|
409
|
-
senderJid: headers.sender_jid || headers.chat_jid,
|
|
410
|
-
senderName: headers.sender_name || headers.sender_jid || "Unknown",
|
|
411
|
-
body: lines.slice(i).join("\n").trimEnd(),
|
|
412
|
-
};
|
|
413
|
-
}
|
|
380
|
+
const parseBridgeContext = parseBridgePrompt;
|
|
414
381
|
|
|
415
382
|
// Compact header card for inbound bridge messages. Shows sender + chat
|
|
416
383
|
// context as a single line of metadata above the actual message text, so a
|
|
417
384
|
// WhatsApp DM looks like "Alice • DM\n<text>" and a group message looks
|
|
418
385
|
// like "Alice in Family Chat • Group\n<text>". Always on the user-bubble
|
|
419
386
|
// (accent) side because bridge messages are persisted with role=user.
|
|
420
|
-
function BridgeMessageCard({ ctx }: { ctx:
|
|
387
|
+
function BridgeMessageCard({ ctx }: { ctx: BridgePromptContext }) {
|
|
421
388
|
const showChat = ctx.isGroup && ctx.chatName && ctx.chatName !== ctx.senderName;
|
|
422
389
|
return (
|
|
423
390
|
<div className="flex flex-col gap-1.5 min-w-0">
|
|
@@ -9,6 +9,29 @@ import { CapBadges } from "./CapBadges";
|
|
|
9
9
|
const FALLBACK_PROVIDERS = ["anthropic", "openai", "github-copilot", "deepseek", "gemini", "langchain"];
|
|
10
10
|
|
|
11
11
|
const CATALOG_PROVIDERS = new Set<string>(["openai", "github-copilot", "anthropic", "gemini", "deepseek"]);
|
|
12
|
+
const DEFAULT_CONTEXT_WINDOW = 8192;
|
|
13
|
+
const DEFAULT_TIER_PROPORTIONS = { hot: 60, warm: 25, facts: 15 };
|
|
14
|
+
|
|
15
|
+
type Tier = "hot" | "warm" | "facts";
|
|
16
|
+
|
|
17
|
+
function sanitizeTierPriority(
|
|
18
|
+
value: ModelConfig["params"]["context_tier_priority"] | undefined,
|
|
19
|
+
): [Tier, Tier, Tier] {
|
|
20
|
+
if (!Array.isArray(value) || value.length !== 3) return ["hot", "warm", "facts"];
|
|
21
|
+
const filtered = value.filter((v): v is Tier => v === "hot" || v === "warm" || v === "facts");
|
|
22
|
+
if (filtered.length !== 3 || new Set(filtered).size !== 3) return ["hot", "warm", "facts"];
|
|
23
|
+
return [filtered[0], filtered[1], filtered[2]];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toNumberOrEmpty(v: string): number | undefined {
|
|
27
|
+
if (!v.trim()) return undefined;
|
|
28
|
+
const n = Number(v);
|
|
29
|
+
return Number.isFinite(n) ? n : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fmtInt(n: number): string {
|
|
33
|
+
return n.toLocaleString();
|
|
34
|
+
}
|
|
12
35
|
|
|
13
36
|
interface Props {
|
|
14
37
|
model?: ModelConfig;
|
|
@@ -35,6 +58,11 @@ export function ModelEditor({ model, onSave, onClose }: Props) {
|
|
|
35
58
|
);
|
|
36
59
|
const [temperature, setTemperature] = useState(String(model?.params.temperature ?? ""));
|
|
37
60
|
const [maxTokens, setMaxTokens] = useState(String(model?.params.max_tokens ?? ""));
|
|
61
|
+
const [contextWindowTokens, setContextWindowTokens] = useState(String(model?.params.context_window_tokens ?? ""));
|
|
62
|
+
const [hotRatio, setHotRatio] = useState(String(Math.round((model?.params.context_tier_proportions?.hot ?? (DEFAULT_TIER_PROPORTIONS.hot / 100)) * 100)));
|
|
63
|
+
const [warmRatio, setWarmRatio] = useState(String(Math.round((model?.params.context_tier_proportions?.warm ?? (DEFAULT_TIER_PROPORTIONS.warm / 100)) * 100)));
|
|
64
|
+
const [factsRatio, setFactsRatio] = useState(String(Math.round((model?.params.context_tier_proportions?.facts ?? (DEFAULT_TIER_PROPORTIONS.facts / 100)) * 100)));
|
|
65
|
+
const [tierPriority, setTierPriority] = useState<[Tier, Tier, Tier]>(sanitizeTierPriority(model?.params.context_tier_priority));
|
|
38
66
|
const [isDefault, setIsDefault] = useState(model?.is_default ?? false);
|
|
39
67
|
const [error, setError] = useState<string | null>(null);
|
|
40
68
|
const [saving, setSaving] = useState(false);
|
|
@@ -107,11 +135,40 @@ export function ModelEditor({ model, onSave, onClose }: Props) {
|
|
|
107
135
|
setSaving(true);
|
|
108
136
|
try {
|
|
109
137
|
const params: ModelConfig["params"] = {};
|
|
138
|
+
const parsedWindow = toNumberOrEmpty(contextWindowTokens);
|
|
139
|
+
const parsedHot = toNumberOrEmpty(hotRatio);
|
|
140
|
+
const parsedWarm = toNumberOrEmpty(warmRatio);
|
|
141
|
+
const parsedFacts = toNumberOrEmpty(factsRatio);
|
|
142
|
+
const tiers = [parsedHot ?? 0, parsedWarm ?? 0, parsedFacts ?? 0];
|
|
143
|
+
if (tiers.some((n) => n < 0)) {
|
|
144
|
+
setError("Tier proportions cannot be negative");
|
|
145
|
+
setSaving(false);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const tierSum = tiers[0] + tiers[1] + tiers[2];
|
|
149
|
+
if (tierSum <= 0) {
|
|
150
|
+
setError("Tier proportions must add up to more than 0");
|
|
151
|
+
setSaving(false);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (new Set(tierPriority).size !== 3) {
|
|
155
|
+
setError("Tier priority must list hot, warm, and facts exactly once");
|
|
156
|
+
setSaving(false);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
110
160
|
if (apiKey) params.api_key = apiKey;
|
|
111
161
|
if (baseUrl) params.base_url = baseUrl;
|
|
112
162
|
if (parsed_headers) params.extra_headers = parsed_headers;
|
|
113
163
|
if (temperature) params.temperature = Number(temperature);
|
|
114
164
|
if (maxTokens) params.max_tokens = Number(maxTokens);
|
|
165
|
+
if (parsedWindow && parsedWindow > 0) params.context_window_tokens = Math.floor(parsedWindow);
|
|
166
|
+
params.context_tier_proportions = {
|
|
167
|
+
hot: (parsedHot ?? 0) / tierSum,
|
|
168
|
+
warm: (parsedWarm ?? 0) / tierSum,
|
|
169
|
+
facts: (parsedFacts ?? 0) / tierSum,
|
|
170
|
+
};
|
|
171
|
+
params.context_tier_priority = tierPriority;
|
|
115
172
|
await onSave(name.trim(), { provider, model_id: modelId.trim(), params, is_default: isDefault });
|
|
116
173
|
onClose();
|
|
117
174
|
} catch (e) {
|
|
@@ -126,6 +183,29 @@ export function ModelEditor({ model, onSave, onClose }: Props) {
|
|
|
126
183
|
|
|
127
184
|
const showGitHub = provider === "github-copilot";
|
|
128
185
|
|
|
186
|
+
const contextWindow = Math.max(1, Math.floor(toNumberOrEmpty(contextWindowTokens) ?? DEFAULT_CONTEXT_WINDOW));
|
|
187
|
+
const outputReserve = Math.max(256, Math.min(contextWindow - 1, Math.floor(toNumberOrEmpty(maxTokens) ?? contextWindow * 0.2)));
|
|
188
|
+
const inputBudget = Math.max(0, contextWindow - outputReserve - Math.min(1200, contextWindow - outputReserve));
|
|
189
|
+
const hotP = Math.max(0, toNumberOrEmpty(hotRatio) ?? DEFAULT_TIER_PROPORTIONS.hot);
|
|
190
|
+
const warmP = Math.max(0, toNumberOrEmpty(warmRatio) ?? DEFAULT_TIER_PROPORTIONS.warm);
|
|
191
|
+
const factsP = Math.max(0, toNumberOrEmpty(factsRatio) ?? DEFAULT_TIER_PROPORTIONS.facts);
|
|
192
|
+
const totalP = hotP + warmP + factsP || 1;
|
|
193
|
+
const hotBudget = Math.floor(inputBudget * (hotP / totalP));
|
|
194
|
+
const warmBudget = Math.floor(inputBudget * (warmP / totalP));
|
|
195
|
+
const factsBudget = Math.max(0, inputBudget - hotBudget - warmBudget);
|
|
196
|
+
|
|
197
|
+
function updatePriority(index: 0 | 1 | 2, value: Tier) {
|
|
198
|
+
setTierPriority((prev) => {
|
|
199
|
+
const next: [Tier, Tier, Tier] = [...prev] as [Tier, Tier, Tier];
|
|
200
|
+
const existing = next.indexOf(value);
|
|
201
|
+
if (existing !== -1 && existing !== index) {
|
|
202
|
+
next[existing] = next[index];
|
|
203
|
+
}
|
|
204
|
+
next[index] = value;
|
|
205
|
+
return next;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
129
209
|
const filteredCatalog = catalog?.filter((m) =>
|
|
130
210
|
!catalogSearch || m.id.toLowerCase().includes(catalogSearch.toLowerCase())
|
|
131
211
|
) ?? [];
|
|
@@ -243,6 +323,67 @@ export function ModelEditor({ model, onSave, onClose }: Props) {
|
|
|
243
323
|
</label>
|
|
244
324
|
</div>
|
|
245
325
|
|
|
326
|
+
<label className="block">
|
|
327
|
+
<span className="text-xs text-fg-subtle mb-1 block">Context window tokens</span>
|
|
328
|
+
<input type="number" min="1" className="w-full bg-surface-3 text-fg text-sm rounded px-2 py-1.5 border border-border focus:outline-none focus:ring-1 focus:ring-accent"
|
|
329
|
+
value={contextWindowTokens} onChange={(e) => setContextWindowTokens(e.target.value)} placeholder="8192" />
|
|
330
|
+
</label>
|
|
331
|
+
|
|
332
|
+
<div className="rounded-lg border border-border bg-surface-3 p-3 space-y-2">
|
|
333
|
+
<p className="text-xs text-fg-subtle">Context tiers and resource usage</p>
|
|
334
|
+
<div className="grid grid-cols-3 gap-2">
|
|
335
|
+
<label className="block">
|
|
336
|
+
<span className="text-[11px] text-fg-faint mb-1 block">Hot %</span>
|
|
337
|
+
<input type="number" min="0" className="w-full bg-surface text-fg text-xs rounded px-2 py-1 border border-border focus:outline-none focus:ring-1 focus:ring-accent"
|
|
338
|
+
value={hotRatio} onChange={(e) => setHotRatio(e.target.value)} />
|
|
339
|
+
</label>
|
|
340
|
+
<label className="block">
|
|
341
|
+
<span className="text-[11px] text-fg-faint mb-1 block">Warm %</span>
|
|
342
|
+
<input type="number" min="0" className="w-full bg-surface text-fg text-xs rounded px-2 py-1 border border-border focus:outline-none focus:ring-1 focus:ring-accent"
|
|
343
|
+
value={warmRatio} onChange={(e) => setWarmRatio(e.target.value)} />
|
|
344
|
+
</label>
|
|
345
|
+
<label className="block">
|
|
346
|
+
<span className="text-[11px] text-fg-faint mb-1 block">Facts %</span>
|
|
347
|
+
<input type="number" min="0" className="w-full bg-surface text-fg text-xs rounded px-2 py-1 border border-border focus:outline-none focus:ring-1 focus:ring-accent"
|
|
348
|
+
value={factsRatio} onChange={(e) => setFactsRatio(e.target.value)} />
|
|
349
|
+
</label>
|
|
350
|
+
</div>
|
|
351
|
+
<div className="grid grid-cols-3 gap-2">
|
|
352
|
+
<label className="block">
|
|
353
|
+
<span className="text-[11px] text-fg-faint mb-1 block">Priority 1</span>
|
|
354
|
+
<select className="w-full bg-surface text-fg text-xs rounded px-2 py-1 border border-border focus:outline-none focus:ring-1 focus:ring-accent"
|
|
355
|
+
value={tierPriority[0]} onChange={(e) => updatePriority(0, e.target.value as Tier)}>
|
|
356
|
+
<option value="hot">hot</option>
|
|
357
|
+
<option value="warm">warm</option>
|
|
358
|
+
<option value="facts">facts</option>
|
|
359
|
+
</select>
|
|
360
|
+
</label>
|
|
361
|
+
<label className="block">
|
|
362
|
+
<span className="text-[11px] text-fg-faint mb-1 block">Priority 2</span>
|
|
363
|
+
<select className="w-full bg-surface text-fg text-xs rounded px-2 py-1 border border-border focus:outline-none focus:ring-1 focus:ring-accent"
|
|
364
|
+
value={tierPriority[1]} onChange={(e) => updatePriority(1, e.target.value as Tier)}>
|
|
365
|
+
<option value="hot">hot</option>
|
|
366
|
+
<option value="warm">warm</option>
|
|
367
|
+
<option value="facts">facts</option>
|
|
368
|
+
</select>
|
|
369
|
+
</label>
|
|
370
|
+
<label className="block">
|
|
371
|
+
<span className="text-[11px] text-fg-faint mb-1 block">Priority 3</span>
|
|
372
|
+
<select className="w-full bg-surface text-fg text-xs rounded px-2 py-1 border border-border focus:outline-none focus:ring-1 focus:ring-accent"
|
|
373
|
+
value={tierPriority[2]} onChange={(e) => updatePriority(2, e.target.value as Tier)}>
|
|
374
|
+
<option value="hot">hot</option>
|
|
375
|
+
<option value="warm">warm</option>
|
|
376
|
+
<option value="facts">facts</option>
|
|
377
|
+
</select>
|
|
378
|
+
</label>
|
|
379
|
+
</div>
|
|
380
|
+
<p className="text-[11px] text-fg-faint leading-relaxed">
|
|
381
|
+
Estimated per-turn allocation: window {fmtInt(contextWindow)} tokens, output reserve {fmtInt(outputReserve)}, input {fmtInt(inputBudget)}.
|
|
382
|
+
Hot gets about {fmtInt(hotBudget)}, warm {fmtInt(warmBudget)}, facts {fmtInt(factsBudget)} tokens.
|
|
383
|
+
Higher hot keeps recent messages; higher warm favors recap summaries; higher facts favors durable memory retrieval.
|
|
384
|
+
</p>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
246
387
|
<label className="block">
|
|
247
388
|
<span className="text-xs text-fg-subtle mb-1 block">Extra headers (JSON, optional)</span>
|
|
248
389
|
<textarea className="w-full bg-surface-3 text-fg text-sm rounded px-2 py-1.5 border border-border focus:outline-none focus:ring-1 focus:ring-accent font-mono h-20 resize-none"
|
|
@@ -330,6 +330,11 @@ function TaskReactionEditor({
|
|
|
330
330
|
Script
|
|
331
331
|
</KindPill>
|
|
332
332
|
</div>
|
|
333
|
+
<p className="text-[10px] text-fg-faint leading-snug">
|
|
334
|
+
Reaction mode controls what happens when this task fires:
|
|
335
|
+
<span className="text-fg-subtle"> Agent prompt</span> runs the task's agent prompt;
|
|
336
|
+
<span className="text-fg-subtle"> Script</span> runs a built-in reaction script with no LLM chat turn.
|
|
337
|
+
</p>
|
|
333
338
|
{task.reaction_kind === "script" && (
|
|
334
339
|
<ReactionScriptEditor
|
|
335
340
|
initialScript={task.reaction_script}
|
|
@@ -330,6 +330,11 @@ function ReactionEditor({
|
|
|
330
330
|
Script
|
|
331
331
|
</KindPill>
|
|
332
332
|
</div>
|
|
333
|
+
<p className="text-[10px] text-fg-faint leading-snug">
|
|
334
|
+
Choose how this watcher reacts on change:
|
|
335
|
+
<span className="text-fg-subtle"> Agent prompt</span> runs the assigned agent with diff context;
|
|
336
|
+
<span className="text-fg-subtle"> Script</span> runs a built-in automation without an LLM round-trip.
|
|
337
|
+
</p>
|
|
333
338
|
{watcher.reaction_kind === "script"
|
|
334
339
|
? (
|
|
335
340
|
<ReactionScriptEditor
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
computeContextBudget,
|
|
4
|
+
normalizeTierPriority,
|
|
5
|
+
normalizeTierProportions,
|
|
6
|
+
takeRecentMessagesWithinBudget,
|
|
7
|
+
estimateTokens,
|
|
8
|
+
} from "./context-budget";
|
|
9
|
+
import type { MessageRow } from "@/lib/stores/threads";
|
|
10
|
+
|
|
11
|
+
function msg(content: string): MessageRow {
|
|
12
|
+
return {
|
|
13
|
+
msg_id: "m",
|
|
14
|
+
thread_id: "t",
|
|
15
|
+
role: "user",
|
|
16
|
+
content,
|
|
17
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
18
|
+
tool_events: null,
|
|
19
|
+
category: null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("estimateTokens", () => {
|
|
24
|
+
it("uses a 4-char token heuristic with minimum 1 for non-empty text", () => {
|
|
25
|
+
expect(estimateTokens("a")).toBe(1);
|
|
26
|
+
expect(estimateTokens("abcd")).toBe(1);
|
|
27
|
+
expect(estimateTokens("abcde")).toBe(2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns 0 for empty/whitespace input", () => {
|
|
31
|
+
expect(estimateTokens("")).toBe(0);
|
|
32
|
+
expect(estimateTokens(" ")).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("normalizeTierPriority", () => {
|
|
37
|
+
it("accepts valid permutations", () => {
|
|
38
|
+
expect(normalizeTierPriority(["facts", "warm", "hot"]))
|
|
39
|
+
.toEqual(["facts", "warm", "hot"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls back to default for invalid values", () => {
|
|
43
|
+
expect(normalizeTierPriority(["hot", "hot", "warm"]))
|
|
44
|
+
.toEqual(["hot", "warm", "facts"]);
|
|
45
|
+
expect(normalizeTierPriority(["hot", "warm"]))
|
|
46
|
+
.toEqual(["hot", "warm", "facts"]);
|
|
47
|
+
expect(normalizeTierPriority("bad"))
|
|
48
|
+
.toEqual(["hot", "warm", "facts"]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("normalizeTierProportions", () => {
|
|
53
|
+
it("normalizes arbitrary positive numbers to sum to 1", () => {
|
|
54
|
+
const p = normalizeTierProportions({ hot: 2, warm: 1, facts: 1 });
|
|
55
|
+
expect(p.hot).toBeCloseTo(0.5, 6);
|
|
56
|
+
expect(p.warm).toBeCloseTo(0.25, 6);
|
|
57
|
+
expect(p.facts).toBeCloseTo(0.25, 6);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to defaults when values are non-positive", () => {
|
|
61
|
+
const p = normalizeTierProportions({ hot: 0, warm: -1, facts: 0 });
|
|
62
|
+
expect(p.hot).toBeCloseTo(0.6, 6);
|
|
63
|
+
expect(p.warm).toBeCloseTo(0.25, 6);
|
|
64
|
+
expect(p.facts).toBeCloseTo(0.15, 6);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("computeContextBudget", () => {
|
|
69
|
+
it("applies defaults when model params are absent", () => {
|
|
70
|
+
const b = computeContextBudget({});
|
|
71
|
+
expect(b.contextWindowTokens).toBe(8192);
|
|
72
|
+
expect(b.outputReserveTokens).toBe(1638);
|
|
73
|
+
expect(b.overheadTokens).toBe(1200);
|
|
74
|
+
expect(b.inputBudgetTokens).toBe(5354);
|
|
75
|
+
expect(b.tierPriority).toEqual(["hot", "warm", "facts"]);
|
|
76
|
+
expect(b.tierBudgets.hot + b.tierBudgets.warm + b.tierBudgets.facts).toBe(b.inputBudgetTokens);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("respects explicit max_tokens and context_window_tokens", () => {
|
|
80
|
+
const b = computeContextBudget({
|
|
81
|
+
context_window_tokens: 10000,
|
|
82
|
+
max_tokens: 500,
|
|
83
|
+
context_tier_proportions: { hot: 0.5, warm: 0.3, facts: 0.2 },
|
|
84
|
+
context_tier_priority: ["warm", "facts", "hot"],
|
|
85
|
+
});
|
|
86
|
+
expect(b.contextWindowTokens).toBe(10000);
|
|
87
|
+
expect(b.outputReserveTokens).toBe(500);
|
|
88
|
+
expect(b.tierPriority).toEqual(["warm", "facts", "hot"]);
|
|
89
|
+
expect(b.tierBudgets.hot + b.tierBudgets.warm + b.tierBudgets.facts).toBe(b.inputBudgetTokens);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("enforces minimum output reserve", () => {
|
|
93
|
+
const b = computeContextBudget({ context_window_tokens: 600, max_tokens: 1 });
|
|
94
|
+
expect(b.outputReserveTokens).toBe(256);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("takeRecentMessagesWithinBudget", () => {
|
|
99
|
+
it("returns most-recent-first slice bounded by token budget", () => {
|
|
100
|
+
const rows = [
|
|
101
|
+
msg("first message has some words"),
|
|
102
|
+
msg("second message has some words"),
|
|
103
|
+
msg("third message has some words"),
|
|
104
|
+
];
|
|
105
|
+
// One message is about 7 tokens with current heuristic.
|
|
106
|
+
const out = takeRecentMessagesWithinBudget(rows, 10);
|
|
107
|
+
expect(out).toHaveLength(1);
|
|
108
|
+
expect(out[0].content).toContain("third");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("always includes latest message when budget is tiny", () => {
|
|
112
|
+
const rows = [msg("older"), msg("latest message with many chars")];
|
|
113
|
+
const out = takeRecentMessagesWithinBudget(rows, 1);
|
|
114
|
+
expect(out).toHaveLength(1);
|
|
115
|
+
expect(out[0].content).toContain("latest");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("handles attachment-encoded content via transcriptText path", () => {
|
|
119
|
+
const withAttachment = JSON.stringify([
|
|
120
|
+
{ type: "text", text: "hello" },
|
|
121
|
+
{ type: "file", name: "doc.txt", media_type: "text/plain", data: "x" },
|
|
122
|
+
]);
|
|
123
|
+
const rows = [msg(withAttachment), msg("latest")];
|
|
124
|
+
const out = takeRecentMessagesWithinBudget(rows, 8);
|
|
125
|
+
expect(out.at(-1)?.content).toBe("latest");
|
|
126
|
+
expect(out.length).toBeGreaterThan(0);
|
|
127
|
+
});
|
|
128
|
+
});
|