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.
- package/README.md +33 -30
- package/dist/cli.js +376 -49
- package/package.json +20 -21
- package/public/desktop-icon-512.png +0 -0
- package/public/icon-512.png +0 -0
- package/src/app/api/data/clear/route.ts +0 -7
- package/src/app/api/data/seed/route.ts +0 -7
- package/src/app/api/profiles/[id]/context/route.ts +109 -0
- package/src/components/dashboard/__tests__/accessibility.test.tsx +42 -0
- package/src/components/documents/__tests__/document-upload-dialog.test.tsx +46 -0
- package/src/components/notifications/__tests__/pending-approval-host.test.tsx +122 -0
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +79 -0
- package/src/components/notifications/pending-approval-host.tsx +49 -25
- package/src/components/profiles/context-proposal-review.tsx +145 -0
- package/src/components/profiles/learned-context-panel.tsx +286 -0
- package/src/components/profiles/profile-detail-view.tsx +4 -0
- package/src/components/projects/__tests__/dialog-focus.test.tsx +87 -0
- package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +59 -0
- package/src/lib/__tests__/setup-verify.test.ts +28 -0
- package/src/lib/__tests__/utils.test.ts +29 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +946 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +63 -0
- package/src/lib/agents/__tests__/router.test.ts +61 -0
- package/src/lib/agents/claude-agent.ts +34 -5
- package/src/lib/agents/learned-context.ts +322 -0
- package/src/lib/agents/pattern-extractor.ts +150 -0
- package/src/lib/agents/profiles/__tests__/compatibility.test.ts +76 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +177 -0
- package/src/lib/agents/profiles/builtins/sweep/SKILL.md +47 -0
- package/src/lib/agents/profiles/builtins/sweep/profile.yaml +12 -0
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +38 -0
- package/src/lib/agents/runtime/openai-codex.ts +1 -1
- package/src/lib/agents/sweep.ts +65 -0
- package/src/lib/constants/__tests__/task-status.test.ts +119 -0
- package/src/lib/data/seed-data/__tests__/profiles.test.ts +141 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +56 -0
- package/src/lib/db/bootstrap.ts +301 -0
- package/src/lib/db/index.ts +2 -205
- package/src/lib/db/migrations/0004_add_documents.sql +2 -1
- package/src/lib/db/migrations/0005_add_document_preprocessing.sql +2 -0
- package/src/lib/db/migrations/0006_add_agent_profile.sql +1 -0
- package/src/lib/db/migrations/0007_add_usage_metering_ledger.sql +9 -2
- package/src/lib/db/migrations/meta/_journal.json +43 -1
- package/src/lib/db/schema.ts +34 -0
- package/src/lib/desktop/__tests__/sidecar-launch.test.ts +70 -0
- package/src/lib/desktop/sidecar-launch.ts +85 -0
- package/src/lib/documents/__tests__/context-builder.test.ts +57 -0
- package/src/lib/documents/__tests__/output-scanner.test.ts +141 -0
- package/src/lib/notifications/actionable.ts +21 -7
- package/src/lib/settings/__tests__/auth.test.ts +220 -0
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +181 -0
- package/src/lib/tauri-bridge.ts +138 -0
- package/src/lib/usage/__tests__/ledger.test.ts +284 -0
- package/src/lib/utils/__tests__/crypto.test.ts +90 -0
- package/src/lib/validators/__tests__/profile.test.ts +119 -0
- package/src/lib/validators/__tests__/project.test.ts +82 -0
- package/src/lib/validators/__tests__/settings.test.ts +151 -0
- package/src/lib/validators/__tests__/task.test.ts +144 -0
- package/src/lib/workflows/__tests__/definition-validation.test.ts +164 -0
- package/src/lib/workflows/__tests__/engine.test.ts +114 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/parallel.test.ts +75 -0
- package/src/lib/workflows/__tests__/swarm.test.ts +97 -0
- 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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|