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.
- package/README.md +35 -4
- package/package.json +3 -2
- package/src/__tests__/e2e/blueprint.test.ts +63 -0
- package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
- package/src/__tests__/e2e/helpers.ts +286 -0
- package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
- package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
- package/src/__tests__/e2e/setup.ts +156 -0
- package/src/__tests__/e2e/single-task.test.ts +170 -0
- package/src/app/api/command-palette/recent/route.ts +41 -18
- package/src/app/api/context/batch/route.ts +44 -0
- package/src/app/api/permissions/presets/route.ts +80 -0
- package/src/app/api/playbook/status/route.ts +15 -0
- package/src/app/api/profiles/route.ts +23 -20
- package/src/app/api/settings/pricing/route.ts +15 -0
- package/src/app/costs/page.tsx +53 -43
- package/src/app/playbook/[slug]/page.tsx +76 -0
- package/src/app/playbook/page.tsx +54 -0
- package/src/app/profiles/page.tsx +7 -4
- package/src/app/settings/page.tsx +2 -2
- package/src/components/costs/cost-dashboard.tsx +226 -320
- package/src/components/dashboard/activity-feed.tsx +6 -2
- package/src/components/notifications/batch-proposal-review.tsx +150 -0
- package/src/components/notifications/notification-item.tsx +6 -3
- package/src/components/notifications/pending-approval-host.tsx +57 -11
- package/src/components/playbook/adoption-heatmap.tsx +69 -0
- package/src/components/playbook/journey-card.tsx +110 -0
- package/src/components/playbook/playbook-action-button.tsx +22 -0
- package/src/components/playbook/playbook-browser.tsx +143 -0
- package/src/components/playbook/playbook-card.tsx +102 -0
- package/src/components/playbook/playbook-detail-view.tsx +223 -0
- package/src/components/playbook/playbook-homepage.tsx +142 -0
- package/src/components/playbook/playbook-toc.tsx +90 -0
- package/src/components/playbook/playbook-updated-badge.tsx +23 -0
- package/src/components/playbook/related-docs.tsx +30 -0
- package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
- package/src/components/profiles/context-proposal-review.tsx +7 -3
- package/src/components/profiles/learned-context-panel.tsx +116 -8
- package/src/components/profiles/profile-detail-view.tsx +6 -3
- package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
- package/src/components/settings/api-key-form.tsx +5 -43
- package/src/components/settings/auth-config-section.tsx +10 -6
- package/src/components/settings/auth-status-badge.tsx +8 -0
- package/src/components/settings/budget-guardrails-section.tsx +403 -620
- package/src/components/settings/connection-test-control.tsx +63 -0
- package/src/components/settings/permissions-section.tsx +85 -75
- package/src/components/settings/permissions-sections.tsx +24 -0
- package/src/components/settings/presets-section.tsx +159 -0
- package/src/components/settings/pricing-registry-panel.tsx +164 -0
- package/src/components/shared/app-sidebar.tsx +2 -0
- package/src/components/shared/command-palette.tsx +30 -0
- package/src/components/shared/light-markdown.tsx +134 -0
- package/src/components/workflows/loop-status-view.tsx +8 -4
- package/src/components/workflows/workflow-status-view.tsx +16 -9
- package/src/lib/agents/learned-context.ts +27 -15
- package/src/lib/agents/learning-session.ts +234 -0
- package/src/lib/agents/pattern-extractor.ts +19 -0
- package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
- package/src/lib/agents/profiles/sort.ts +7 -0
- package/src/lib/constants/settings.ts +1 -0
- package/src/lib/db/schema.ts +3 -0
- package/src/lib/docs/adoption.ts +105 -0
- package/src/lib/docs/journey-tracker.ts +21 -0
- package/src/lib/docs/reader.ts +102 -0
- package/src/lib/docs/types.ts +54 -0
- package/src/lib/docs/usage-stage.ts +60 -0
- package/src/lib/notifications/actionable.ts +18 -10
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
- package/src/lib/settings/budget-guardrails.ts +213 -85
- package/src/lib/settings/permission-presets.ts +150 -0
- package/src/lib/settings/runtime-setup.ts +71 -0
- package/src/lib/usage/__tests__/ledger.test.ts +2 -2
- package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
- package/src/lib/usage/ledger.ts +1 -1
- package/src/lib/usage/pricing-registry.ts +570 -0
- package/src/lib/usage/pricing.ts +15 -95
- package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
- package/src/lib/utils/learned-context-history.ts +150 -0
- package/src/lib/validators/__tests__/settings.test.ts +23 -16
- package/src/lib/validators/settings.ts +3 -9
- 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
|
-
<
|
|
237
|
-
{
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
<
|
|
367
|
-
{step.state.result.slice(0,
|
|
368
|
-
|
|
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
|
-
<
|
|
413
|
-
{synthesisStep.state.result.slice(0,
|
|
414
|
-
|
|
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
|
-
<
|
|
452
|
-
{step.state.result.slice(0,
|
|
453
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
+
});
|
|
@@ -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";
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -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(),
|