@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.
Files changed (91) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +2 -2
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  17. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +51 -35
  23. package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js.map +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +2 -2
  25. package/.next/standalone/.next/server/app/index.html +2 -2
  26. package/.next/standalone/.next/server/app/index.rsc +3 -3
  27. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/page.js +515 -104
  33. package/.next/standalone/.next/server/app/page.js.map +1 -1
  34. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/setup.html +1 -1
  37. package/.next/standalone/.next/server/app/setup.rsc +2 -2
  38. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/setup.segments/_head.segment.rsc +1 -1
  40. package/.next/standalone/.next/server/app/setup.segments/_index.segment.rsc +2 -2
  41. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
  42. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +1 -1
  43. package/.next/standalone/.next/server/app/setup.segments/setup.segment.rsc +1 -1
  44. package/.next/standalone/.next/server/chunks/1683.js +26 -16
  45. package/.next/standalone/.next/server/chunks/1683.js.map +1 -1
  46. package/.next/standalone/.next/server/chunks/{317.js → 5432.js} +11100 -10858
  47. package/.next/standalone/.next/server/chunks/5432.js.map +1 -0
  48. package/.next/standalone/.next/server/chunks/7885.js +606 -353
  49. package/.next/standalone/.next/server/chunks/7885.js.map +1 -1
  50. package/.next/standalone/.next/server/chunks/8135.js +59 -16
  51. package/.next/standalone/.next/server/chunks/8135.js.map +1 -1
  52. package/.next/standalone/.next/server/chunks/9032.js +3 -3
  53. package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
  54. package/.next/standalone/.next/server/instrumentation.js +3 -3
  55. package/.next/standalone/.next/server/instrumentation.js.map +1 -1
  56. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  57. package/.next/standalone/.next/server/pages/404.html +2 -2
  58. package/.next/standalone/.next/server/pages/500.html +1 -1
  59. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/static/chunks/app/{page-a20902703c0a4f10.js → page-9fb006074fb13526.js} +582 -171
  61. package/.next/standalone/.next/static/chunks/app/page-9fb006074fb13526.js.map +1 -0
  62. package/.next/standalone/.next/static/css/5507dbe1cdc6c599.css +5 -0
  63. package/.next/standalone/.next/static/css/5507dbe1cdc6c599.css.map +1 -0
  64. package/.next/standalone/package.json +1 -1
  65. package/CHANGELOG.md +11 -0
  66. package/README.md +83 -1
  67. package/api/types.ts +7 -0
  68. package/app/api/v1/agents/[id]/compact/route.ts +2 -40
  69. package/components/bridges/BridgeEditor.tsx +8 -0
  70. package/components/chat/MessageBubble.tsx +3 -36
  71. package/components/models/ModelEditor.tsx +141 -0
  72. package/components/scheduled-tasks/ScheduledTasksPanel.tsx +5 -0
  73. package/components/scheduled-tasks/WatchersSection.tsx +5 -0
  74. package/lib/agents/context-budget.test.ts +128 -0
  75. package/lib/agents/context-budget.ts +128 -0
  76. package/lib/agents/conversation-summary.test.ts +68 -0
  77. package/lib/agents/conversation-summary.ts +51 -0
  78. package/lib/agents/run-thread.ts +112 -2
  79. package/lib/bridges/dispatcher.test.ts +134 -0
  80. package/lib/bridges/dispatcher.ts +34 -16
  81. package/lib/bridges/message-role.test.ts +83 -0
  82. package/lib/bridges/message-role.ts +46 -0
  83. package/lib/triggers/handlers/watcher.test.ts +23 -4
  84. package/lib/triggers/handlers/watcher.ts +56 -8
  85. package/package.json +1 -1
  86. package/.next/standalone/.next/server/chunks/317.js.map +0 -1
  87. package/.next/standalone/.next/static/chunks/app/page-a20902703c0a4f10.js.map +0 -1
  88. package/.next/standalone/.next/static/css/cc66c456aba91258.css +0 -5
  89. package/.next/standalone/.next/static/css/cc66c456aba91258.css.map +0 -1
  90. /package/.next/standalone/.next/static/{IauO0rNZkUVPX834k-SBa → AbCOWpaxP4v4lUSeFWWYz}/_buildManifest.js +0 -0
  91. /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
- ## Quick start
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 type { ContentPart } from "@/lib/tools/types";
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
- const { stream } = await provider.chat(cfg.model_id, summaryMessages, providerParams);
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
- interface BridgeContext {
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: BridgeContext }) {
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&apos;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
+ });