stagent 0.1.0 → 0.1.1

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 (64) hide show
  1. package/README.md +33 -30
  2. package/dist/cli.js +376 -49
  3. package/package.json +20 -21
  4. package/public/desktop-icon-512.png +0 -0
  5. package/public/icon-512.png +0 -0
  6. package/src/app/api/data/clear/route.ts +0 -7
  7. package/src/app/api/data/seed/route.ts +0 -7
  8. package/src/app/api/profiles/[id]/context/route.ts +109 -0
  9. package/src/components/dashboard/__tests__/accessibility.test.tsx +42 -0
  10. package/src/components/documents/__tests__/document-upload-dialog.test.tsx +46 -0
  11. package/src/components/notifications/__tests__/pending-approval-host.test.tsx +122 -0
  12. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +79 -0
  13. package/src/components/notifications/pending-approval-host.tsx +49 -25
  14. package/src/components/profiles/context-proposal-review.tsx +145 -0
  15. package/src/components/profiles/learned-context-panel.tsx +286 -0
  16. package/src/components/profiles/profile-detail-view.tsx +4 -0
  17. package/src/components/projects/__tests__/dialog-focus.test.tsx +87 -0
  18. package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +59 -0
  19. package/src/lib/__tests__/setup-verify.test.ts +28 -0
  20. package/src/lib/__tests__/utils.test.ts +29 -0
  21. package/src/lib/agents/__tests__/claude-agent.test.ts +946 -0
  22. package/src/lib/agents/__tests__/execution-manager.test.ts +63 -0
  23. package/src/lib/agents/__tests__/router.test.ts +61 -0
  24. package/src/lib/agents/claude-agent.ts +34 -5
  25. package/src/lib/agents/learned-context.ts +322 -0
  26. package/src/lib/agents/pattern-extractor.ts +150 -0
  27. package/src/lib/agents/profiles/__tests__/compatibility.test.ts +76 -0
  28. package/src/lib/agents/profiles/__tests__/registry.test.ts +177 -0
  29. package/src/lib/agents/profiles/builtins/sweep/SKILL.md +47 -0
  30. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +12 -0
  31. package/src/lib/agents/runtime/__tests__/catalog.test.ts +38 -0
  32. package/src/lib/agents/runtime/openai-codex.ts +1 -1
  33. package/src/lib/agents/sweep.ts +65 -0
  34. package/src/lib/constants/__tests__/task-status.test.ts +119 -0
  35. package/src/lib/data/seed-data/__tests__/profiles.test.ts +141 -0
  36. package/src/lib/db/__tests__/bootstrap.test.ts +56 -0
  37. package/src/lib/db/bootstrap.ts +301 -0
  38. package/src/lib/db/index.ts +2 -205
  39. package/src/lib/db/migrations/0004_add_documents.sql +2 -1
  40. package/src/lib/db/migrations/0005_add_document_preprocessing.sql +2 -0
  41. package/src/lib/db/migrations/0006_add_agent_profile.sql +1 -0
  42. package/src/lib/db/migrations/0007_add_usage_metering_ledger.sql +9 -2
  43. package/src/lib/db/migrations/meta/_journal.json +43 -1
  44. package/src/lib/db/schema.ts +34 -0
  45. package/src/lib/desktop/__tests__/sidecar-launch.test.ts +70 -0
  46. package/src/lib/desktop/sidecar-launch.ts +85 -0
  47. package/src/lib/documents/__tests__/context-builder.test.ts +57 -0
  48. package/src/lib/documents/__tests__/output-scanner.test.ts +141 -0
  49. package/src/lib/notifications/actionable.ts +21 -7
  50. package/src/lib/settings/__tests__/auth.test.ts +220 -0
  51. package/src/lib/settings/__tests__/budget-guardrails.test.ts +181 -0
  52. package/src/lib/tauri-bridge.ts +138 -0
  53. package/src/lib/usage/__tests__/ledger.test.ts +284 -0
  54. package/src/lib/utils/__tests__/crypto.test.ts +90 -0
  55. package/src/lib/validators/__tests__/profile.test.ts +119 -0
  56. package/src/lib/validators/__tests__/project.test.ts +82 -0
  57. package/src/lib/validators/__tests__/settings.test.ts +151 -0
  58. package/src/lib/validators/__tests__/task.test.ts +144 -0
  59. package/src/lib/workflows/__tests__/definition-validation.test.ts +164 -0
  60. package/src/lib/workflows/__tests__/engine.test.ts +114 -0
  61. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  62. package/src/lib/workflows/__tests__/parallel.test.ts +75 -0
  63. package/src/lib/workflows/__tests__/swarm.test.ts +97 -0
  64. package/src/test/setup.ts +10 -0
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ getExecution,
4
+ setExecution,
5
+ removeExecution,
6
+ getAllExecutions,
7
+ } from "@/lib/agents/execution-manager";
8
+
9
+ function makeExecution(taskId: string) {
10
+ return {
11
+ abortController: new AbortController(),
12
+ sessionId: `session-${taskId}`,
13
+ taskId,
14
+ startedAt: new Date(),
15
+ };
16
+ }
17
+
18
+ describe("execution-manager", () => {
19
+ beforeEach(() => {
20
+ // Clear all executions between tests
21
+ for (const key of getAllExecutions().keys()) {
22
+ removeExecution(key);
23
+ }
24
+ });
25
+
26
+ it("returns undefined for non-existent task", () => {
27
+ expect(getExecution("nonexistent")).toBeUndefined();
28
+ });
29
+
30
+ it("stores and retrieves an execution", () => {
31
+ const exec = makeExecution("task-1");
32
+ setExecution("task-1", exec);
33
+ expect(getExecution("task-1")).toBe(exec);
34
+ });
35
+
36
+ it("removes an execution", () => {
37
+ setExecution("task-1", makeExecution("task-1"));
38
+ removeExecution("task-1");
39
+ expect(getExecution("task-1")).toBeUndefined();
40
+ });
41
+
42
+ it("returns all executions", () => {
43
+ setExecution("task-1", makeExecution("task-1"));
44
+ setExecution("task-2", makeExecution("task-2"));
45
+ const all = getAllExecutions();
46
+ expect(all.size).toBe(2);
47
+ expect(all.has("task-1")).toBe(true);
48
+ expect(all.has("task-2")).toBe(true);
49
+ });
50
+
51
+ it("overwrites execution for same taskId", () => {
52
+ const exec1 = makeExecution("task-1");
53
+ const exec2 = makeExecution("task-1");
54
+ setExecution("task-1", exec1);
55
+ setExecution("task-1", exec2);
56
+ expect(getExecution("task-1")).toBe(exec2);
57
+ expect(getAllExecutions().size).toBe(1);
58
+ });
59
+
60
+ it("removing non-existent task does not throw", () => {
61
+ expect(() => removeExecution("nonexistent")).not.toThrow();
62
+ });
63
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { executeTaskWithAgent, resumeTaskWithAgent } from "@/lib/agents/router";
3
+
4
+ const throwIfUnknownRuntime = (agentType?: string | null) => {
5
+ if (agentType === "unknown-agent") {
6
+ throw new Error("Unknown agent type: unknown-agent");
7
+ }
8
+ };
9
+
10
+ vi.mock("@/lib/agents/runtime", () => ({
11
+ executeTaskWithRuntime: vi.fn().mockImplementation(
12
+ async (_taskId: string, agentType?: string | null) => {
13
+ throwIfUnknownRuntime(agentType);
14
+ }
15
+ ),
16
+ resumeTaskWithRuntime: vi.fn().mockImplementation(
17
+ async (_taskId: string, agentType?: string | null) => {
18
+ throwIfUnknownRuntime(agentType);
19
+ }
20
+ ),
21
+ }));
22
+
23
+ describe("executeTaskWithAgent", () => {
24
+ it("delegates to the runtime registry for claude-code agent", async () => {
25
+ const { executeTaskWithRuntime } = await import("@/lib/agents/runtime");
26
+ await executeTaskWithAgent("task-1", "claude-code");
27
+ expect(executeTaskWithRuntime).toHaveBeenCalledWith("task-1", "claude-code");
28
+ });
29
+
30
+ it("defaults to claude-code when no agent type specified", async () => {
31
+ const { executeTaskWithRuntime } = await import("@/lib/agents/runtime");
32
+ await executeTaskWithAgent("task-2");
33
+ expect(executeTaskWithRuntime).toHaveBeenCalledWith("task-2", "claude-code");
34
+ });
35
+
36
+ it("throws for unknown agent type", async () => {
37
+ await expect(
38
+ executeTaskWithAgent("task-1", "unknown-agent")
39
+ ).rejects.toThrow("Unknown agent type: unknown-agent");
40
+ });
41
+ });
42
+
43
+ describe("resumeTaskWithAgent", () => {
44
+ it("delegates to the runtime registry for claude-code agent", async () => {
45
+ const { resumeTaskWithRuntime } = await import("@/lib/agents/runtime");
46
+ await resumeTaskWithAgent("task-1", "claude-code");
47
+ expect(resumeTaskWithRuntime).toHaveBeenCalledWith("task-1", "claude-code");
48
+ });
49
+
50
+ it("defaults to claude-code when no agent type specified", async () => {
51
+ const { resumeTaskWithRuntime } = await import("@/lib/agents/runtime");
52
+ await resumeTaskWithAgent("task-2");
53
+ expect(resumeTaskWithRuntime).toHaveBeenCalledWith("task-2", "claude-code");
54
+ });
55
+
56
+ it("throws for unknown agent type", async () => {
57
+ await expect(
58
+ resumeTaskWithAgent("task-1", "unknown-agent")
59
+ ).rejects.toThrow("Unknown agent type: unknown-agent");
60
+ });
61
+ });
@@ -15,6 +15,9 @@ import { getProfile } from "./profiles/registry";
15
15
  import { resolveProfileRuntimePayload } from "./profiles/compatibility";
16
16
  import type { CanUseToolPolicy } from "./profiles/types";
17
17
  import { buildClaudeSdkEnv } from "./runtime/claude-sdk";
18
+ import { getActiveLearnedContext } from "./learned-context";
19
+ import { analyzeForLearnedPatterns } from "./pattern-extractor";
20
+ import { processSweepResult } from "./sweep";
18
21
  import {
19
22
  extractUsageSnapshot,
20
23
  mergeUsageSnapshot,
@@ -319,6 +322,13 @@ async function processAgentStream(
319
322
  });
320
323
  }
321
324
 
325
+ // Fire-and-forget sweep result processing
326
+ if (agentProfileId === "sweep") {
327
+ processSweepResult(taskId).catch((err) => {
328
+ console.error("[sweep] result processing failed:", err);
329
+ });
330
+ }
331
+
322
332
  await finalizeTaskUsage(usageState, "completed");
323
333
  }
324
334
  }
@@ -354,6 +364,7 @@ export async function executeClaudeTask(taskId: string): Promise<void> {
354
364
  const usageState = createTaskUsageState(task);
355
365
 
356
366
  const abortController = new AbortController();
367
+ const agentProfileId = task.agentProfile ?? "general";
357
368
 
358
369
  setExecution(taskId, {
359
370
  abortController,
@@ -364,7 +375,7 @@ export async function executeClaudeTask(taskId: string): Promise<void> {
364
375
 
365
376
  try {
366
377
  await prepareTaskOutputDirectory(taskId, { clearExisting: true });
367
- const profile = getProfile(task.agentProfile ?? "general");
378
+ const profile = getProfile(agentProfileId);
368
379
  const payload = profile
369
380
  ? resolveProfileRuntimePayload(profile, "claude-code")
370
381
  : null;
@@ -375,7 +386,11 @@ export async function executeClaudeTask(taskId: string): Promise<void> {
375
386
  const basePrompt = task.description || task.title;
376
387
  const docContext = await buildDocumentContext(taskId);
377
388
  const outputInstructions = buildTaskOutputInstructions(taskId);
378
- const prompt = [systemPrompt, docContext, outputInstructions, basePrompt]
389
+ const learnedCtx = getActiveLearnedContext(agentProfileId);
390
+ const learnedCtxBlock = learnedCtx
391
+ ? `## Learned Context\nPatterns and insights learned from previous tasks:\n\n${learnedCtx}`
392
+ : "";
393
+ const prompt = [systemPrompt, learnedCtxBlock, docContext, outputInstructions, basePrompt]
379
394
  .filter(Boolean)
380
395
  .join("\n\n");
381
396
 
@@ -420,16 +435,21 @@ export async function executeClaudeTask(taskId: string): Promise<void> {
420
435
  task.title,
421
436
  response as AsyncIterable<Record<string, unknown>>,
422
437
  abortController,
423
- task.agentProfile ?? "general",
438
+ agentProfileId,
424
439
  usageState
425
440
  );
441
+
442
+ // Fire-and-forget pattern extraction for self-improvement
443
+ analyzeForLearnedPatterns(taskId, agentProfileId).catch((err) => {
444
+ console.error("[self-improvement] pattern extraction failed:", err);
445
+ });
426
446
  } catch (error: unknown) {
427
447
  await handleExecutionError(
428
448
  taskId,
429
449
  task.title,
430
450
  error,
431
451
  abortController,
432
- task.agentProfile ?? "general",
452
+ agentProfileId,
433
453
  usageState
434
454
  );
435
455
  } finally {
@@ -494,7 +514,11 @@ export async function resumeClaudeTask(taskId: string): Promise<void> {
494
514
  const basePrompt = task.description || task.title;
495
515
  const docContext = await buildDocumentContext(taskId);
496
516
  const outputInstructions = buildTaskOutputInstructions(taskId);
497
- const prompt = [systemPrompt, docContext, outputInstructions, basePrompt]
517
+ const learnedCtx = getActiveLearnedContext(profileId);
518
+ const learnedCtxBlock = learnedCtx
519
+ ? `## Learned Context\nPatterns and insights learned from previous tasks:\n\n${learnedCtx}`
520
+ : "";
521
+ const prompt = [systemPrompt, learnedCtxBlock, docContext, outputInstructions, basePrompt]
498
522
  .filter(Boolean)
499
523
  .join("\n\n");
500
524
 
@@ -543,6 +567,11 @@ export async function resumeClaudeTask(taskId: string): Promise<void> {
543
567
  profileId,
544
568
  usageState
545
569
  );
570
+
571
+ // Fire-and-forget pattern extraction for self-improvement
572
+ analyzeForLearnedPatterns(taskId, profileId).catch((err) => {
573
+ console.error("[self-improvement] pattern extraction failed:", err);
574
+ });
546
575
  } catch (error: unknown) {
547
576
  const errorMessage =
548
577
  error instanceof Error ? error.message : String(error);
@@ -0,0 +1,322 @@
1
+ import { db } from "@/lib/db";
2
+ import { learnedContext, notifications } from "@/lib/db/schema";
3
+ import { and, desc, eq } from "drizzle-orm";
4
+ import type { LearnedContextRow } from "@/lib/db/schema";
5
+ import Anthropic from "@anthropic-ai/sdk";
6
+
7
+ const CONTEXT_CHAR_LIMIT = 8_000;
8
+ const SUMMARIZATION_THRESHOLD = 6_000;
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Read helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** Get the latest approved context for a profile (returns content string or null) */
15
+ export function getActiveLearnedContext(profileId: string): string | null {
16
+ const [row] = db
17
+ .select({ content: learnedContext.content })
18
+ .from(learnedContext)
19
+ .where(
20
+ and(
21
+ eq(learnedContext.profileId, profileId),
22
+ eq(learnedContext.changeType, "approved")
23
+ )
24
+ )
25
+ .orderBy(desc(learnedContext.version))
26
+ .limit(1)
27
+ .all();
28
+
29
+ return row?.content ?? null;
30
+ }
31
+
32
+ /** Get full version history for a profile */
33
+ export async function getContextHistory(
34
+ profileId: string
35
+ ): Promise<LearnedContextRow[]> {
36
+ return db
37
+ .select()
38
+ .from(learnedContext)
39
+ .where(eq(learnedContext.profileId, profileId))
40
+ .orderBy(desc(learnedContext.version))
41
+ .all();
42
+ }
43
+
44
+ /** Get the next version number for a profile */
45
+ function getNextVersion(profileId: string): number {
46
+ const [row] = db
47
+ .select({ version: learnedContext.version })
48
+ .from(learnedContext)
49
+ .where(eq(learnedContext.profileId, profileId))
50
+ .orderBy(desc(learnedContext.version))
51
+ .limit(1)
52
+ .all();
53
+
54
+ return (row?.version ?? 0) + 1;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Proposal flow
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /** Insert a context proposal and create a notification for human review */
62
+ export async function proposeContextAddition(
63
+ profileId: string,
64
+ taskId: string,
65
+ additions: string
66
+ ): Promise<string> {
67
+ const version = getNextVersion(profileId);
68
+ const notificationId = crypto.randomUUID();
69
+ const rowId = crypto.randomUUID();
70
+ const now = new Date();
71
+
72
+ // Insert proposal row
73
+ await db.insert(learnedContext).values({
74
+ id: rowId,
75
+ profileId,
76
+ version,
77
+ content: null, // not yet approved
78
+ diff: additions,
79
+ changeType: "proposal",
80
+ sourceTaskId: taskId,
81
+ proposalNotificationId: notificationId,
82
+ proposedAdditions: additions,
83
+ createdAt: now,
84
+ });
85
+
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
+ });
97
+
98
+ return notificationId;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Approval / Rejection / Rollback
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /** Approve a proposal — merges additions into current context, creates approved version */
106
+ export async function approveProposal(
107
+ notificationId: string,
108
+ editedContent?: string
109
+ ): Promise<void> {
110
+ // Find the proposal row by notification ID
111
+ const [proposal] = db
112
+ .select()
113
+ .from(learnedContext)
114
+ .where(eq(learnedContext.proposalNotificationId, notificationId))
115
+ .all();
116
+
117
+ if (!proposal) throw new Error("Proposal not found");
118
+
119
+ const currentContent = getActiveLearnedContext(proposal.profileId) ?? "";
120
+ const additions = editedContent ?? proposal.proposedAdditions ?? "";
121
+ const mergedContent = currentContent
122
+ ? `${currentContent}\n\n${additions}`
123
+ : additions;
124
+
125
+ const version = getNextVersion(proposal.profileId);
126
+
127
+ await db.insert(learnedContext).values({
128
+ id: crypto.randomUUID(),
129
+ profileId: proposal.profileId,
130
+ version,
131
+ content: mergedContent,
132
+ diff: additions,
133
+ changeType: "approved",
134
+ sourceTaskId: proposal.sourceTaskId,
135
+ proposalNotificationId: notificationId,
136
+ proposedAdditions: additions,
137
+ approvedBy: "human",
138
+ createdAt: new Date(),
139
+ });
140
+
141
+ // Mark notification as responded
142
+ await db
143
+ .update(notifications)
144
+ .set({
145
+ response: JSON.stringify({ action: "approved" }),
146
+ respondedAt: new Date(),
147
+ })
148
+ .where(eq(notifications.id, notificationId));
149
+
150
+ // Check if we need to auto-summarize
151
+ const sizeInfo = checkContextSize(proposal.profileId);
152
+ if (sizeInfo.needsSummarization) {
153
+ await summarizeContext(proposal.profileId);
154
+ }
155
+ }
156
+
157
+ /** Reject a proposal */
158
+ export async function rejectProposal(notificationId: string): Promise<void> {
159
+ const [proposal] = db
160
+ .select()
161
+ .from(learnedContext)
162
+ .where(eq(learnedContext.proposalNotificationId, notificationId))
163
+ .all();
164
+
165
+ if (!proposal) throw new Error("Proposal not found");
166
+
167
+ const version = getNextVersion(proposal.profileId);
168
+
169
+ await db.insert(learnedContext).values({
170
+ id: crypto.randomUUID(),
171
+ profileId: proposal.profileId,
172
+ version,
173
+ content: getActiveLearnedContext(proposal.profileId),
174
+ diff: proposal.proposedAdditions,
175
+ changeType: "rejected",
176
+ sourceTaskId: proposal.sourceTaskId,
177
+ proposalNotificationId: notificationId,
178
+ proposedAdditions: proposal.proposedAdditions,
179
+ createdAt: new Date(),
180
+ });
181
+
182
+ // Mark notification as responded
183
+ await db
184
+ .update(notifications)
185
+ .set({
186
+ response: JSON.stringify({ action: "rejected" }),
187
+ respondedAt: new Date(),
188
+ })
189
+ .where(eq(notifications.id, notificationId));
190
+ }
191
+
192
+ /** Rollback to a specific version — creates a new version with that version's content */
193
+ export async function rollbackToVersion(
194
+ profileId: string,
195
+ targetVersion: number
196
+ ): Promise<void> {
197
+ const [target] = db
198
+ .select()
199
+ .from(learnedContext)
200
+ .where(
201
+ and(
202
+ eq(learnedContext.profileId, profileId),
203
+ eq(learnedContext.version, targetVersion)
204
+ )
205
+ )
206
+ .all();
207
+
208
+ if (!target) throw new Error(`Version ${targetVersion} not found`);
209
+
210
+ const version = getNextVersion(profileId);
211
+
212
+ await db.insert(learnedContext).values({
213
+ id: crypto.randomUUID(),
214
+ profileId,
215
+ version,
216
+ content: target.content,
217
+ diff: `Rolled back to version ${targetVersion}`,
218
+ changeType: "rollback",
219
+ createdAt: new Date(),
220
+ });
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Context size management
225
+ // ---------------------------------------------------------------------------
226
+
227
+ export function checkContextSize(profileId: string): {
228
+ currentSize: number;
229
+ limit: number;
230
+ needsSummarization: boolean;
231
+ } {
232
+ const content = getActiveLearnedContext(profileId);
233
+ const currentSize = content?.length ?? 0;
234
+ return {
235
+ currentSize,
236
+ limit: CONTEXT_CHAR_LIMIT,
237
+ needsSummarization: currentSize > SUMMARIZATION_THRESHOLD,
238
+ };
239
+ }
240
+
241
+ /** Auto-condense context via LLM when it grows too large */
242
+ export async function summarizeContext(profileId: string): Promise<void> {
243
+ const content = getActiveLearnedContext(profileId);
244
+ if (!content || content.length <= SUMMARIZATION_THRESHOLD) return;
245
+
246
+ const client = new Anthropic();
247
+ const response = await client.messages.create({
248
+ model: "claude-sonnet-4-20250514",
249
+ max_tokens: 2048,
250
+ messages: [
251
+ {
252
+ role: "user",
253
+ content: `You are condensing learned context for an AI agent profile "${profileId}".
254
+ The current context has grown to ${content.length} characters and needs to be summarized to under ${SUMMARIZATION_THRESHOLD} characters while preserving all key patterns, best practices, and important insights.
255
+
256
+ Current learned context:
257
+ ---
258
+ ${content}
259
+ ---
260
+
261
+ Produce a condensed version that:
262
+ 1. Preserves all actionable patterns and best practices
263
+ 2. Merges related patterns into combined entries
264
+ 3. Removes redundant or superseded information
265
+ 4. Keeps the same format (bullet points or sections)
266
+ 5. Stays under ${SUMMARIZATION_THRESHOLD} characters
267
+
268
+ Output ONLY the condensed context, no preamble.`,
269
+ },
270
+ ],
271
+ });
272
+
273
+ const summarized =
274
+ response.content[0].type === "text" ? response.content[0].text : "";
275
+
276
+ if (!summarized || summarized.length >= content.length) return;
277
+
278
+ const version = getNextVersion(profileId);
279
+
280
+ await db.insert(learnedContext).values({
281
+ id: crypto.randomUUID(),
282
+ profileId,
283
+ version,
284
+ content: summarized,
285
+ diff: `Summarized from ${content.length} to ${summarized.length} chars`,
286
+ changeType: "summarization",
287
+ createdAt: new Date(),
288
+ });
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Manual addition (direct approve, no LLM extraction)
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /** Add context directly without going through the proposal flow */
296
+ export async function addDirectContext(
297
+ profileId: string,
298
+ additions: string
299
+ ): Promise<void> {
300
+ const currentContent = getActiveLearnedContext(profileId) ?? "";
301
+ const mergedContent = currentContent
302
+ ? `${currentContent}\n\n${additions}`
303
+ : additions;
304
+
305
+ const version = getNextVersion(profileId);
306
+
307
+ await db.insert(learnedContext).values({
308
+ id: crypto.randomUUID(),
309
+ profileId,
310
+ version,
311
+ content: mergedContent,
312
+ diff: additions,
313
+ changeType: "approved",
314
+ approvedBy: "human",
315
+ createdAt: new Date(),
316
+ });
317
+
318
+ const sizeInfo = checkContextSize(profileId);
319
+ if (sizeInfo.needsSummarization) {
320
+ await summarizeContext(profileId);
321
+ }
322
+ }
@@ -0,0 +1,150 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { db } from "@/lib/db";
3
+ import { tasks, agentLogs } from "@/lib/db/schema";
4
+ import { eq, desc } from "drizzle-orm";
5
+ import {
6
+ getActiveLearnedContext,
7
+ proposeContextAddition,
8
+ } from "./learned-context";
9
+
10
+ export interface PatternEntry {
11
+ title: string;
12
+ description: string;
13
+ category: "error_resolution" | "best_practice" | "shortcut" | "preference";
14
+ }
15
+
16
+ export interface PatternProposal {
17
+ patterns: PatternEntry[];
18
+ }
19
+
20
+ const PATTERN_TOOL: Anthropic.Messages.Tool = {
21
+ name: "propose_learned_patterns",
22
+ description:
23
+ "Propose patterns learned from this task execution that should be remembered for future tasks with this profile.",
24
+ input_schema: {
25
+ type: "object" as const,
26
+ properties: {
27
+ patterns: {
28
+ type: "array",
29
+ items: {
30
+ type: "object",
31
+ properties: {
32
+ title: {
33
+ type: "string",
34
+ description: "Short pattern name (2-6 words)",
35
+ },
36
+ description: {
37
+ type: "string",
38
+ description:
39
+ "Concise description of the pattern or lesson (1-2 sentences)",
40
+ },
41
+ category: {
42
+ type: "string",
43
+ enum: [
44
+ "error_resolution",
45
+ "best_practice",
46
+ "shortcut",
47
+ "preference",
48
+ ],
49
+ },
50
+ },
51
+ required: ["title", "description", "category"],
52
+ },
53
+ description:
54
+ "Patterns worth remembering. Return empty array if nothing notable.",
55
+ },
56
+ },
57
+ required: ["patterns"],
58
+ },
59
+ };
60
+
61
+ /**
62
+ * Analyze a completed task for patterns worth learning.
63
+ * Makes a focused Claude API call, then proposes additions if patterns found.
64
+ * Returns the notification ID if a proposal was created, null otherwise.
65
+ */
66
+ export async function analyzeForLearnedPatterns(
67
+ taskId: string,
68
+ profileId: string
69
+ ): Promise<string | null> {
70
+ // Gather task data
71
+ const [task] = await db
72
+ .select({
73
+ title: tasks.title,
74
+ description: tasks.description,
75
+ result: tasks.result,
76
+ })
77
+ .from(tasks)
78
+ .where(eq(tasks.id, taskId));
79
+
80
+ if (!task) return null;
81
+
82
+ // Get recent agent logs for this task (last 20)
83
+ const logs = await db
84
+ .select({ event: agentLogs.event, payload: agentLogs.payload })
85
+ .from(agentLogs)
86
+ .where(eq(agentLogs.taskId, taskId))
87
+ .orderBy(desc(agentLogs.timestamp))
88
+ .limit(20);
89
+
90
+ const currentContext = getActiveLearnedContext(profileId);
91
+
92
+ // Build a compact representation of logs
93
+ const logSummary = logs
94
+ .map((log) => {
95
+ const payload = log.payload
96
+ ? JSON.stringify(JSON.parse(log.payload)).slice(0, 200)
97
+ : "";
98
+ return `[${log.event}] ${payload}`;
99
+ })
100
+ .join("\n");
101
+
102
+ const client = new Anthropic();
103
+ const response = await client.messages.create({
104
+ model: "claude-sonnet-4-20250514",
105
+ max_tokens: 1024,
106
+ tools: [PATTERN_TOOL],
107
+ tool_choice: { type: "tool", name: "propose_learned_patterns" },
108
+ messages: [
109
+ {
110
+ role: "user",
111
+ content: `Analyze this completed task for patterns worth learning for the "${profileId}" agent profile.
112
+
113
+ ## Task
114
+ Title: ${task.title}
115
+ Description: ${(task.description ?? "").slice(0, 500)}
116
+
117
+ ## Result (truncated)
118
+ ${(task.result ?? "No result").slice(0, 1500)}
119
+
120
+ ## Recent Agent Logs
121
+ ${logSummary.slice(0, 2000)}
122
+
123
+ ## Currently Learned Context
124
+ ${currentContext ?? "(none yet)"}
125
+
126
+ Extract ONLY genuinely useful patterns — things that would help this profile avoid mistakes or work more efficiently on similar future tasks. If this task was routine with nothing notable, return an empty patterns array. Do NOT repeat patterns already in the learned context.`,
127
+ },
128
+ ],
129
+ });
130
+
131
+ // Extract the tool use result
132
+ const toolBlock = response.content.find(
133
+ (block) => block.type === "tool_use" && block.name === "propose_learned_patterns"
134
+ );
135
+
136
+ if (!toolBlock || toolBlock.type !== "tool_use") return null;
137
+
138
+ const proposal = toolBlock.input as PatternProposal;
139
+ if (!proposal.patterns || proposal.patterns.length === 0) return null;
140
+
141
+ // Format patterns as text for the proposal
142
+ const formattedAdditions = proposal.patterns
143
+ .map(
144
+ (p) =>
145
+ `### ${p.title} [${p.category}]\n${p.description}`
146
+ )
147
+ .join("\n\n");
148
+
149
+ return proposeContextAddition(profileId, taskId, formattedAdditions);
150
+ }