stagent 0.1.11 → 0.1.12

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 (81) hide show
  1. package/README.md +35 -4
  2. package/package.json +3 -2
  3. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  4. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  5. package/src/__tests__/e2e/helpers.ts +286 -0
  6. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  7. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  8. package/src/__tests__/e2e/setup.ts +156 -0
  9. package/src/__tests__/e2e/single-task.test.ts +170 -0
  10. package/src/app/api/command-palette/recent/route.ts +41 -18
  11. package/src/app/api/context/batch/route.ts +44 -0
  12. package/src/app/api/permissions/presets/route.ts +80 -0
  13. package/src/app/api/playbook/status/route.ts +15 -0
  14. package/src/app/api/profiles/route.ts +23 -20
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/playbook/[slug]/page.tsx +76 -0
  18. package/src/app/playbook/page.tsx +54 -0
  19. package/src/app/profiles/page.tsx +7 -4
  20. package/src/app/settings/page.tsx +2 -2
  21. package/src/components/costs/cost-dashboard.tsx +226 -320
  22. package/src/components/dashboard/activity-feed.tsx +6 -2
  23. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  24. package/src/components/notifications/notification-item.tsx +6 -3
  25. package/src/components/notifications/pending-approval-host.tsx +57 -11
  26. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  27. package/src/components/playbook/journey-card.tsx +110 -0
  28. package/src/components/playbook/playbook-action-button.tsx +22 -0
  29. package/src/components/playbook/playbook-browser.tsx +143 -0
  30. package/src/components/playbook/playbook-card.tsx +102 -0
  31. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  32. package/src/components/playbook/playbook-homepage.tsx +142 -0
  33. package/src/components/playbook/playbook-toc.tsx +90 -0
  34. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  35. package/src/components/playbook/related-docs.tsx +30 -0
  36. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  37. package/src/components/profiles/context-proposal-review.tsx +7 -3
  38. package/src/components/profiles/learned-context-panel.tsx +116 -8
  39. package/src/components/profiles/profile-detail-view.tsx +6 -3
  40. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  41. package/src/components/settings/api-key-form.tsx +5 -43
  42. package/src/components/settings/auth-config-section.tsx +10 -6
  43. package/src/components/settings/auth-status-badge.tsx +8 -0
  44. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  45. package/src/components/settings/connection-test-control.tsx +63 -0
  46. package/src/components/settings/permissions-section.tsx +85 -75
  47. package/src/components/settings/permissions-sections.tsx +24 -0
  48. package/src/components/settings/presets-section.tsx +159 -0
  49. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  50. package/src/components/shared/app-sidebar.tsx +2 -0
  51. package/src/components/shared/command-palette.tsx +30 -0
  52. package/src/components/shared/light-markdown.tsx +134 -0
  53. package/src/components/workflows/loop-status-view.tsx +8 -4
  54. package/src/components/workflows/workflow-status-view.tsx +16 -9
  55. package/src/lib/agents/learned-context.ts +27 -15
  56. package/src/lib/agents/learning-session.ts +234 -0
  57. package/src/lib/agents/pattern-extractor.ts +19 -0
  58. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  59. package/src/lib/agents/profiles/sort.ts +7 -0
  60. package/src/lib/constants/settings.ts +1 -0
  61. package/src/lib/db/schema.ts +3 -0
  62. package/src/lib/docs/adoption.ts +105 -0
  63. package/src/lib/docs/journey-tracker.ts +21 -0
  64. package/src/lib/docs/reader.ts +102 -0
  65. package/src/lib/docs/types.ts +54 -0
  66. package/src/lib/docs/usage-stage.ts +60 -0
  67. package/src/lib/notifications/actionable.ts +18 -10
  68. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  69. package/src/lib/settings/budget-guardrails.ts +213 -85
  70. package/src/lib/settings/permission-presets.ts +150 -0
  71. package/src/lib/settings/runtime-setup.ts +71 -0
  72. package/src/lib/usage/__tests__/ledger.test.ts +2 -2
  73. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  74. package/src/lib/usage/ledger.ts +1 -1
  75. package/src/lib/usage/pricing-registry.ts +570 -0
  76. package/src/lib/usage/pricing.ts +15 -95
  77. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  78. package/src/lib/utils/learned-context-history.ts +150 -0
  79. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  80. package/src/lib/validators/settings.ts +3 -9
  81. package/src/lib/workflows/engine.ts +18 -0
@@ -0,0 +1,134 @@
1
+ import { type ReactNode } from "react";
2
+
3
+ interface LightMarkdownProps {
4
+ content: string;
5
+ className?: string;
6
+ maxHeight?: string;
7
+ lineClamp?: number;
8
+ textSize?: "xs" | "sm";
9
+ stripBracketTags?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Lightweight markdown renderer for short agent output.
14
+ * Handles: headers, bullet lists, paragraphs, **bold**, `code`.
15
+ * For full markdown (tables, code fences, GFM), use ReactMarkdown instead.
16
+ */
17
+ export function LightMarkdown({
18
+ content,
19
+ className = "",
20
+ maxHeight,
21
+ lineClamp,
22
+ textSize = "xs",
23
+ stripBracketTags = false,
24
+ }: LightMarkdownProps) {
25
+ const sizeClass = textSize === "sm" ? "text-sm" : "text-xs";
26
+
27
+ const blocks = content.split(/\n{2,}/);
28
+
29
+ const rendered = blocks.map((block, i) => {
30
+ let text = block.trim();
31
+ if (!text) return null;
32
+
33
+ if (stripBracketTags) {
34
+ text = text.replace(/\s*\[.*?\]\s*/g, " ").trim();
35
+ }
36
+
37
+ // Header: ### Title
38
+ const headerMatch = text.match(/^(#{1,4})\s+(.+)/);
39
+ if (headerMatch) {
40
+ return (
41
+ <p key={i} className="font-semibold text-foreground">
42
+ {formatInline(headerMatch[2])}
43
+ </p>
44
+ );
45
+ }
46
+
47
+ // Bullet list: lines starting with - or *
48
+ const lines = text.split("\n");
49
+ const isList = lines.every(
50
+ (l) => /^\s*[-*]\s+/.test(l) || l.trim() === ""
51
+ );
52
+ if (isList && lines.some((l) => /^\s*[-*]\s+/.test(l))) {
53
+ return (
54
+ <ul key={i} className="list-disc pl-4 space-y-0.5 text-muted-foreground">
55
+ {lines
56
+ .filter((l) => /^\s*[-*]\s+/.test(l))
57
+ .map((l, j) => (
58
+ <li key={j}>{formatInline(l.replace(/^\s*[-*]\s+/, ""))}</li>
59
+ ))}
60
+ </ul>
61
+ );
62
+ }
63
+
64
+ // Paragraph
65
+ return (
66
+ <p key={i} className="leading-relaxed text-muted-foreground">
67
+ {formatInline(text)}
68
+ </p>
69
+ );
70
+ });
71
+
72
+ // Overflow / clamp styles
73
+ let containerClass = `${sizeClass} space-y-2 ${className}`;
74
+ const style: React.CSSProperties = {};
75
+
76
+ if (lineClamp) {
77
+ // Approximate: each "line" ~1.25rem at text-xs, ~1.5rem at text-sm
78
+ const lineHeight = textSize === "sm" ? 1.5 : 1.25;
79
+ style.maxHeight = `${lineClamp * lineHeight}rem`;
80
+ style.overflow = "hidden";
81
+ } else if (maxHeight) {
82
+ containerClass += ` ${maxHeight} overflow-auto`;
83
+ }
84
+
85
+ return (
86
+ <div className={containerClass} style={style}>
87
+ {rendered}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ /** Format inline **bold** and `code` spans */
93
+ function formatInline(text: string): ReactNode {
94
+ // Split on **bold** and `code` patterns
95
+ const parts: ReactNode[] = [];
96
+ const regex = /(\*\*(.+?)\*\*|`([^`]+)`)/g;
97
+ let lastIndex = 0;
98
+ let match: RegExpExecArray | null;
99
+
100
+ while ((match = regex.exec(text)) !== null) {
101
+ // Text before the match
102
+ if (match.index > lastIndex) {
103
+ parts.push(text.slice(lastIndex, match.index));
104
+ }
105
+
106
+ if (match[2]) {
107
+ // **bold**
108
+ parts.push(
109
+ <strong key={match.index} className="font-semibold text-foreground">
110
+ {match[2]}
111
+ </strong>
112
+ );
113
+ } else if (match[3]) {
114
+ // `code`
115
+ parts.push(
116
+ <code
117
+ key={match.index}
118
+ className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
119
+ >
120
+ {match[3]}
121
+ </code>
122
+ );
123
+ }
124
+
125
+ lastIndex = match.index + match[0].length;
126
+ }
127
+
128
+ // Remaining text
129
+ if (lastIndex < text.length) {
130
+ parts.push(text.slice(lastIndex));
131
+ }
132
+
133
+ return parts.length === 1 ? parts[0] : parts;
134
+ }
@@ -3,6 +3,7 @@
3
3
  import { useState } from "react";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { Button } from "@/components/ui/button";
6
+ import { LightMarkdown } from "@/components/shared/light-markdown";
6
7
  import {
7
8
  CheckCircle,
8
9
  Circle,
@@ -233,10 +234,13 @@ export function LoopStatusView({
233
234
  <p className="text-xs text-destructive">{iter.error}</p>
234
235
  )}
235
236
  {iter.result && (
236
- <p className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-6">
237
- {iter.result.slice(0, 1000)}
238
- {iter.result.length > 1000 ? "..." : ""}
239
- </p>
237
+ <LightMarkdown
238
+ content={
239
+ iter.result.slice(0, 1000) +
240
+ (iter.result.length > 1000 ? "..." : "")
241
+ }
242
+ lineClamp={6}
243
+ />
240
244
  )}
241
245
  {iter.taskId && (
242
246
  <a
@@ -25,6 +25,7 @@ import { toast } from "sonner";
25
25
  import { workflowStatusVariant, patternLabels } from "@/lib/constants/status-colors";
26
26
  import { LoopStatusView } from "./loop-status-view";
27
27
  import { ConfirmDialog } from "@/components/shared/confirm-dialog";
28
+ import { LightMarkdown } from "@/components/shared/light-markdown";
28
29
  import { SwarmDashboard } from "./swarm-dashboard";
29
30
  import type { LoopState, LoopConfig, SwarmConfig } from "@/lib/workflows/types";
30
31
 
@@ -363,9 +364,11 @@ export function WorkflowStatusView({ workflowId }: WorkflowStatusViewProps) {
363
364
  </p>
364
365
  )}
365
366
  {step.state.result && step.state.status === "completed" && (
366
- <p className="mt-2 text-xs text-muted-foreground line-clamp-4">
367
- {step.state.result.slice(0, 260)}
368
- </p>
367
+ <LightMarkdown
368
+ content={step.state.result.slice(0, 500)}
369
+ lineClamp={4}
370
+ className="mt-2"
371
+ />
369
372
  )}
370
373
  </div>
371
374
  </div>
@@ -409,9 +412,11 @@ export function WorkflowStatusView({ workflowId }: WorkflowStatusViewProps) {
409
412
  )}
410
413
  {synthesisStep.state.result &&
411
414
  synthesisStep.state.status === "completed" && (
412
- <p className="mt-2 text-xs text-muted-foreground line-clamp-4">
413
- {synthesisStep.state.result.slice(0, 260)}
414
- </p>
415
+ <LightMarkdown
416
+ content={synthesisStep.state.result.slice(0, 500)}
417
+ lineClamp={4}
418
+ className="mt-2"
419
+ />
415
420
  )}
416
421
  </div>
417
422
  </div>
@@ -448,9 +453,11 @@ export function WorkflowStatusView({ workflowId }: WorkflowStatusViewProps) {
448
453
  </p>
449
454
  )}
450
455
  {step.state.result && step.state.status === "completed" && (
451
- <p className="text-xs text-muted-foreground mt-1 line-clamp-2">
452
- {step.state.result.slice(0, 200)}
453
- </p>
456
+ <LightMarkdown
457
+ content={step.state.result.slice(0, 500)}
458
+ lineClamp={2}
459
+ className="mt-1"
460
+ />
454
461
  )}
455
462
  </div>
456
463
  </div>
@@ -58,14 +58,24 @@ function getNextVersion(profileId: string): number {
58
58
  // Proposal flow
59
59
  // ---------------------------------------------------------------------------
60
60
 
61
- /** Insert a context proposal and create a notification for human review */
61
+ /**
62
+ * Insert a context proposal and optionally create a notification for human review.
63
+ *
64
+ * When `options.silent` is true, the proposal row is created but no notification
65
+ * is generated. This is used by the learning session system to buffer proposals
66
+ * during workflow execution — a batch notification is created when the session closes.
67
+ *
68
+ * Returns the learned_context row ID (not the notification ID) so callers can
69
+ * reference the proposal regardless of whether a notification was created.
70
+ */
62
71
  export async function proposeContextAddition(
63
72
  profileId: string,
64
73
  taskId: string,
65
- additions: string
74
+ additions: string,
75
+ options?: { silent?: boolean }
66
76
  ): Promise<string> {
67
77
  const version = getNextVersion(profileId);
68
- const notificationId = crypto.randomUUID();
78
+ const notificationId = options?.silent ? null : crypto.randomUUID();
69
79
  const rowId = crypto.randomUUID();
70
80
  const now = new Date();
71
81
 
@@ -83,19 +93,21 @@ export async function proposeContextAddition(
83
93
  createdAt: now,
84
94
  });
85
95
 
86
- // Create notification for human review
87
- await db.insert(notifications).values({
88
- id: notificationId,
89
- taskId,
90
- type: "context_proposal",
91
- title: `Context proposal for ${profileId}`,
92
- body: additions.slice(0, 500),
93
- toolName: profileId,
94
- toolInput: JSON.stringify({ profileId, additions, learnedContextId: rowId }),
95
- createdAt: now,
96
- });
96
+ // Create notification for human review (unless silent)
97
+ if (notificationId) {
98
+ await db.insert(notifications).values({
99
+ id: notificationId,
100
+ taskId,
101
+ type: "context_proposal",
102
+ title: `Context proposal for ${profileId}`,
103
+ body: additions.slice(0, 500),
104
+ toolName: profileId,
105
+ toolInput: JSON.stringify({ profileId, additions, learnedContextId: rowId }),
106
+ createdAt: now,
107
+ });
108
+ }
97
109
 
98
- return notificationId;
110
+ return rowId;
99
111
  }
100
112
 
101
113
  // ---------------------------------------------------------------------------
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Learning Session — buffers context proposals during workflow execution.
3
+ *
4
+ * When a workflow starts, a session is opened. All context proposals generated
5
+ * by tasks within that workflow are buffered instead of creating individual
6
+ * notifications. When the workflow completes (or fails), the session is closed
7
+ * and a single batch notification is created for all buffered proposals.
8
+ */
9
+
10
+ import { db } from "@/lib/db";
11
+ import { learnedContext, notifications, tasks } from "@/lib/db/schema";
12
+ import { eq, and } from "drizzle-orm";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // In-memory session registry
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Active learning sessions, keyed by workflowId.
20
+ * Each session accumulates proposal IDs (learnedContext row IDs) until closed.
21
+ */
22
+ const activeSessions = new Map<
23
+ string,
24
+ {
25
+ workflowId: string;
26
+ proposalIds: string[];
27
+ openedAt: Date;
28
+ }
29
+ >();
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Session lifecycle
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Open a learning session for a workflow. All proposals created during this
37
+ * session will be buffered instead of generating individual notifications.
38
+ */
39
+ export function openLearningSession(workflowId: string): void {
40
+ activeSessions.set(workflowId, {
41
+ workflowId,
42
+ proposalIds: [],
43
+ openedAt: new Date(),
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Check if a learning session is active for a workflow.
49
+ */
50
+ export function hasLearningSession(workflowId: string): boolean {
51
+ return activeSessions.has(workflowId);
52
+ }
53
+
54
+ /**
55
+ * Get the active session for a workflow (or null if none).
56
+ */
57
+ export function getLearningSession(workflowId: string) {
58
+ return activeSessions.get(workflowId) ?? null;
59
+ }
60
+
61
+ /**
62
+ * Buffer a proposal ID into the active session.
63
+ * Called by proposeContextAddition when it detects a workflow session.
64
+ */
65
+ export function bufferProposal(
66
+ workflowId: string,
67
+ proposalRowId: string
68
+ ): void {
69
+ const session = activeSessions.get(workflowId);
70
+ if (session) {
71
+ session.proposalIds.push(proposalRowId);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Close the learning session and create a single batch notification
77
+ * for all buffered proposals. Returns the batch notification ID,
78
+ * or null if there were no proposals.
79
+ */
80
+ export async function closeLearningSession(
81
+ workflowId: string
82
+ ): Promise<string | null> {
83
+ const session = activeSessions.get(workflowId);
84
+ activeSessions.delete(workflowId);
85
+
86
+ if (!session || session.proposalIds.length === 0) {
87
+ return null;
88
+ }
89
+
90
+ // Load all buffered proposal rows
91
+ const proposals = [];
92
+ for (const id of session.proposalIds) {
93
+ const [row] = db
94
+ .select()
95
+ .from(learnedContext)
96
+ .where(eq(learnedContext.id, id))
97
+ .all();
98
+ if (row) {
99
+ proposals.push(row);
100
+ }
101
+ }
102
+
103
+ if (proposals.length === 0) return null;
104
+
105
+ // Group by profile for the notification body
106
+ const byProfile = new Map<string, typeof proposals>();
107
+ for (const p of proposals) {
108
+ const group = byProfile.get(p.profileId) ?? [];
109
+ group.push(p);
110
+ byProfile.set(p.profileId, group);
111
+ }
112
+
113
+ // Build summary body
114
+ const bodyLines: string[] = [];
115
+ for (const [profileId, group] of byProfile) {
116
+ bodyLines.push(`**${profileId}** (${group.length} proposal${group.length > 1 ? "s" : ""}):`);
117
+ for (const p of group) {
118
+ const preview = (p.proposedAdditions ?? p.diff ?? "").slice(0, 150);
119
+ bodyLines.push(` - ${preview}${preview.length >= 150 ? "..." : ""}`);
120
+ }
121
+ }
122
+
123
+ const notificationId = crypto.randomUUID();
124
+
125
+ await db.insert(notifications).values({
126
+ id: notificationId,
127
+ taskId: null,
128
+ type: "context_proposal_batch",
129
+ title: `Workflow learning: ${proposals.length} context proposal${proposals.length > 1 ? "s" : ""}`,
130
+ body: bodyLines.join("\n").slice(0, 1000),
131
+ toolName: "workflow-context-batch",
132
+ toolInput: JSON.stringify({
133
+ workflowId,
134
+ proposalIds: session.proposalIds,
135
+ profileIds: [...byProfile.keys()],
136
+ }),
137
+ createdAt: new Date(),
138
+ });
139
+
140
+ return notificationId;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Task → Workflow mapping
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Resolve the workflowId for a given task. Returns null if the task is
149
+ * not part of a workflow or the task doesn't exist.
150
+ */
151
+ export function getTaskWorkflowId(taskId: string): string | null {
152
+ try {
153
+ const [row] = db
154
+ .select({ workflowId: tasks.workflowId })
155
+ .from(tasks)
156
+ .where(eq(tasks.id, taskId))
157
+ .all();
158
+
159
+ return row?.workflowId ?? null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Batch operations
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Approve all proposals in a batch by their IDs.
171
+ */
172
+ export async function batchApproveProposals(
173
+ proposalRowIds: string[]
174
+ ): Promise<number> {
175
+ // Import inline to avoid circular dependency
176
+ const { approveProposal } = await import("./learned-context");
177
+
178
+ let approved = 0;
179
+ for (const rowId of proposalRowIds) {
180
+ const [row] = db
181
+ .select()
182
+ .from(learnedContext)
183
+ .where(
184
+ and(
185
+ eq(learnedContext.id, rowId),
186
+ eq(learnedContext.changeType, "proposal")
187
+ )
188
+ )
189
+ .all();
190
+
191
+ if (row?.proposalNotificationId) {
192
+ try {
193
+ await approveProposal(row.proposalNotificationId);
194
+ approved++;
195
+ } catch {
196
+ // Skip if already approved/rejected
197
+ }
198
+ }
199
+ }
200
+ return approved;
201
+ }
202
+
203
+ /**
204
+ * Reject all proposals in a batch by their IDs.
205
+ */
206
+ export async function batchRejectProposals(
207
+ proposalRowIds: string[]
208
+ ): Promise<number> {
209
+ const { rejectProposal } = await import("./learned-context");
210
+
211
+ let rejected = 0;
212
+ for (const rowId of proposalRowIds) {
213
+ const [row] = db
214
+ .select()
215
+ .from(learnedContext)
216
+ .where(
217
+ and(
218
+ eq(learnedContext.id, rowId),
219
+ eq(learnedContext.changeType, "proposal")
220
+ )
221
+ )
222
+ .all();
223
+
224
+ if (row?.proposalNotificationId) {
225
+ try {
226
+ await rejectProposal(row.proposalNotificationId);
227
+ rejected++;
228
+ } catch {
229
+ // Skip if already approved/rejected
230
+ }
231
+ }
232
+ }
233
+ return rejected;
234
+ }
@@ -6,6 +6,11 @@ import {
6
6
  proposeContextAddition,
7
7
  } from "./learned-context";
8
8
  import { runMetaCompletion } from "./runtime/claude";
9
+ import {
10
+ getTaskWorkflowId,
11
+ hasLearningSession,
12
+ bufferProposal,
13
+ } from "./learning-session";
9
14
 
10
15
  export interface PatternEntry {
11
16
  title: string;
@@ -97,5 +102,19 @@ Extract ONLY genuinely useful patterns — things that would help this profile a
97
102
  )
98
103
  .join("\n\n");
99
104
 
105
+ // Check if this task is part of a workflow with an active learning session.
106
+ // If so, buffer the proposal instead of creating an individual notification.
107
+ const workflowId = getTaskWorkflowId(taskId);
108
+ if (workflowId && hasLearningSession(workflowId)) {
109
+ const rowId = await proposeContextAddition(
110
+ profileId,
111
+ taskId,
112
+ formattedAdditions,
113
+ { silent: true }
114
+ );
115
+ bufferProposal(workflowId, rowId);
116
+ return rowId;
117
+ }
118
+
100
119
  return proposeContextAddition(profileId, taskId, formattedAdditions);
101
120
  }
@@ -0,0 +1,42 @@
1
+ import type { AgentProfile } from "../types";
2
+
3
+ import { sortProfilesByName } from "../sort";
4
+
5
+ function makeProfile(id: string, name: string): AgentProfile {
6
+ return {
7
+ id,
8
+ name,
9
+ description: `${name} description`,
10
+ domain: "work",
11
+ tags: [],
12
+ systemPrompt: "",
13
+ skillMd: "",
14
+ allowedTools: [],
15
+ mcpServers: {},
16
+ canUseToolPolicy: false,
17
+ maxTurns: 20,
18
+ outputFormat: "text",
19
+ version: "1.0.0",
20
+ author: "test",
21
+ source: "test",
22
+ tests: [],
23
+ supportedRuntimes: ["claude-code"],
24
+ runtimeOverrides: {},
25
+ };
26
+ }
27
+
28
+ describe("sortProfilesByName", () => {
29
+ it("sorts profiles alphabetically so sweep remains discoverable", () => {
30
+ const sorted = sortProfilesByName([
31
+ makeProfile("sweep", "Sweep"),
32
+ makeProfile("general", "General"),
33
+ makeProfile("api-tester", "API Tester"),
34
+ ]);
35
+
36
+ expect(sorted.map((profile) => profile.id)).toEqual([
37
+ "api-tester",
38
+ "general",
39
+ "sweep",
40
+ ]);
41
+ });
42
+ });
@@ -0,0 +1,7 @@
1
+ export function sortProfilesByName<T extends { name: string }>(
2
+ profiles: T[]
3
+ ): T[] {
4
+ return [...profiles].sort((a, b) =>
5
+ a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
6
+ );
7
+ }
@@ -7,6 +7,7 @@ export const SETTINGS_KEYS = {
7
7
  PERMISSIONS_ALLOW: "permissions.allow",
8
8
  BUDGET_POLICY: "usage.budgetPolicy",
9
9
  BUDGET_WARNING_STATE: "usage.budgetWarningState",
10
+ PRICING_REGISTRY: "usage.pricingRegistry",
10
11
  } as const;
11
12
 
12
13
  export type AuthMethod = "api_key" | "oauth";
@@ -88,6 +88,7 @@ export const notifications = sqliteTable(
88
88
  "agent_message",
89
89
  "budget_alert",
90
90
  "context_proposal",
91
+ "context_proposal_batch",
91
92
  ],
92
93
  }).notNull(),
93
94
  title: text("title").notNull(),
@@ -223,6 +224,8 @@ export const usageLedger = sqliteTable(
223
224
  "scheduled_firing",
224
225
  "task_assist",
225
226
  "profile_test",
227
+ "pattern_extraction",
228
+ "context_summarization",
226
229
  ],
227
230
  }).notNull(),
228
231
  runtimeId: text("runtime_id").notNull(),