assistme 0.4.0 → 0.6.0

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.
@@ -89,6 +89,26 @@ export const MEMORY_COMPRESSION_TARGET = 30;
89
89
  /** Max USD spend per task (safety net) */
90
90
  export const MAX_BUDGET_USD = 2.0;
91
91
 
92
+ // ── Self-Analysis ─────────────────────────────────────────────────
93
+
94
+ /** Max session log entries to include in self-analysis context */
95
+ export const SELF_ANALYSIS_MAX_SESSION_LOGS = 200;
96
+
97
+ /** Max message events to include in self-analysis context */
98
+ export const SELF_ANALYSIS_MAX_MESSAGE_EVENTS = 300;
99
+
100
+ /** Max conversation messages to include in self-analysis context */
101
+ export const SELF_ANALYSIS_MAX_CONVERSATION_MESSAGES = 10;
102
+
103
+ /** Max characters for session log context in self-analysis */
104
+ export const SELF_ANALYSIS_LOG_CONTEXT_CHARS = 20_000;
105
+
106
+ /** Max characters for event context in self-analysis */
107
+ export const SELF_ANALYSIS_EVENT_CONTEXT_CHARS = 20_000;
108
+
109
+ /** Edsger feedback product slug for assistme */
110
+ export const EDSGER_PRODUCT_SLUG = "assistme";
111
+
92
112
  // ── Retry & Polling ────────────────────────────────────────────────
93
113
 
94
114
  /** Max retries for event emission */
@@ -2,9 +2,11 @@ import chalk from "chalk";
2
2
  import { randomUUID } from "crypto";
3
3
 
4
4
  export type LogLevel = "debug" | "info" | "warn" | "error";
5
+ export type LogHook = (logType: "stdout" | "stderr", message: string) => void;
5
6
 
6
7
  let currentLevel: LogLevel = "info";
7
8
  let currentCorrelationId: string | null = null;
9
+ let logHook: LogHook | null = null;
8
10
 
9
11
  const LEVEL_ORDER: Record<LogLevel, number> = {
10
12
  debug: 0,
@@ -17,6 +19,14 @@ export function setLogLevel(level: LogLevel) {
17
19
  currentLevel = level;
18
20
  }
19
21
 
22
+ /**
23
+ * Set a hook to capture all log output for persistence.
24
+ * Call with null to remove.
25
+ */
26
+ export function setLogHook(hook: LogHook | null) {
27
+ logHook = hook;
28
+ }
29
+
20
30
  /**
21
31
  * Set a correlation ID that will be included in all subsequent log messages.
22
32
  * Call with null to clear.
@@ -54,36 +64,54 @@ function prefix(): string {
54
64
  export const log = {
55
65
  debug(msg: string, ...args: unknown[]) {
56
66
  if (shouldLog("debug")) {
67
+ const text = formatArgs(msg, args);
68
+ logHook?.("stdout", `[DEBUG] ${text}`);
57
69
  console.log(chalk.gray(`[${prefix()}] DEBUG`), msg, ...args);
58
70
  }
59
71
  },
60
72
  info(msg: string, ...args: unknown[]) {
61
73
  if (shouldLog("info")) {
74
+ const text = formatArgs(msg, args);
75
+ logHook?.("stdout", text);
62
76
  console.log(chalk.blue(`[${prefix()}]`), msg, ...args);
63
77
  }
64
78
  },
65
79
  success(msg: string, ...args: unknown[]) {
66
80
  if (shouldLog("info")) {
81
+ const text = formatArgs(msg, args);
82
+ logHook?.("stdout", `✓ ${text}`);
67
83
  console.log(chalk.green(`[${prefix()}] ✓`), msg, ...args);
68
84
  }
69
85
  },
70
86
  warn(msg: string, ...args: unknown[]) {
71
87
  if (shouldLog("warn")) {
88
+ const text = formatArgs(msg, args);
89
+ logHook?.("stderr", `[WARN] ${text}`);
72
90
  console.log(chalk.yellow(`[${prefix()}] WARN`), msg, ...args);
73
91
  }
74
92
  },
75
93
  error(msg: string, ...args: unknown[]) {
76
94
  if (shouldLog("error")) {
95
+ const text = formatArgs(msg, args);
96
+ logHook?.("stderr", `[ERROR] ${text}`);
77
97
  console.error(chalk.red(`[${prefix()}] ERROR`), msg, ...args);
78
98
  }
79
99
  },
80
100
  agent(msg: string) {
101
+ logHook?.("stdout", `▸ ${msg}`);
81
102
  console.log(chalk.cyan(" ▸"), msg);
82
103
  },
83
104
  tool(name: string, msg: string) {
105
+ logHook?.("stdout", `⚡ ${name}: ${msg}`);
84
106
  console.log(chalk.magenta(` ⚡ ${name}:`), msg);
85
107
  },
86
108
  result(msg: string) {
109
+ logHook?.("stdout", `← ${msg}`);
87
110
  console.log(chalk.green(" ←"), msg);
88
111
  },
89
112
  };
113
+
114
+ function formatArgs(msg: string, args: unknown[]): string {
115
+ if (args.length === 0) return msg;
116
+ return `${msg} ${args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")}`;
117
+ }
@@ -114,6 +114,36 @@ export const BrowseSkillRowSchema = z.object({
114
114
  rating_count: z.number().optional().default(0),
115
115
  });
116
116
 
117
+ // ── Self-Analysis Schemas ────────────────────────────────────────
118
+
119
+ export const SelfAnalysisResultSchema = z.object({
120
+ is_perfect: z.boolean(),
121
+ overall_score: z.number().min(1).max(10),
122
+ task_completion_quality: z.object({
123
+ score: z.number().min(1).max(10),
124
+ assessment: z.string(),
125
+ }),
126
+ improvements: z.array(
127
+ z.object({
128
+ area: z.string(),
129
+ severity: z.enum(["critical", "major", "minor", "suggestion"]),
130
+ description: z.string(),
131
+ suggestion: z.string(),
132
+ })
133
+ ),
134
+ data_quality: z.object({
135
+ session_logs_useful: z.boolean(),
136
+ session_logs_gaps: z.string().nullable().optional(),
137
+ message_events_useful: z.boolean(),
138
+ message_events_gaps: z.string().nullable().optional(),
139
+ conversation_context_useful: z.boolean(),
140
+ conversation_context_gaps: z.string().nullable().optional(),
141
+ }),
142
+ summary: z.string(),
143
+ });
144
+
145
+ export type SelfAnalysisResult = z.infer<typeof SelfAnalysisResultSchema>;
146
+
117
147
  // ── JSON Extraction ──────────────────────────────────────────────
118
148
 
119
149
  /**
@@ -131,6 +131,10 @@ vi.mock("../../src/agent/event-hooks.js", () => ({
131
131
  })),
132
132
  }));
133
133
 
134
+ vi.mock("../../src/agent/self-analyzer.js", () => ({
135
+ analyzeSelfPostTask: vi.fn().mockResolvedValue(undefined),
136
+ }));
137
+
134
138
  const { TaskProcessor } = await import("../../src/agent/processor.js");
135
139
 
136
140
  describe("TaskProcessor", () => {
@@ -0,0 +1,349 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // ── Mocks ────────────────────────────────────────────────────────
4
+
5
+ // Track what was passed to query()
6
+ let mockQueryCalls: unknown[] = [];
7
+ const mockQueryMessages: unknown[] = [];
8
+ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
9
+ query: vi.fn((params: unknown) => {
10
+ mockQueryCalls.push(params);
11
+ return {
12
+ [Symbol.asyncIterator]: async function* () {
13
+ for (const msg of mockQueryMessages) {
14
+ yield msg;
15
+ }
16
+ },
17
+ };
18
+ }),
19
+ }));
20
+
21
+ // Mock edsger-feedback
22
+ const mockSubmitFeedback = vi.fn().mockResolvedValue({ id: "fb-001", createdAt: "2026-01-01" });
23
+ vi.mock("edsger-feedback", () => ({
24
+ submitFeedback: (...args: unknown[]) => mockSubmitFeedback(...args),
25
+ FeedbackError: class FeedbackError extends Error {
26
+ statusCode: number;
27
+ constructor(message: string, statusCode: number) {
28
+ super(message);
29
+ this.statusCode = statusCode;
30
+ }
31
+ },
32
+ }));
33
+
34
+ // Mock analysis data fetchers
35
+ const mockGetSessionLogs = vi.fn().mockResolvedValue([]);
36
+ const mockGetMessageEvents = vi.fn().mockResolvedValue([]);
37
+ const mockGetConversationMessages = vi.fn().mockResolvedValue([]);
38
+ vi.mock("../../src/db/analysis-data.js", () => ({
39
+ getSessionLogs: (...args: unknown[]) => mockGetSessionLogs(...args),
40
+ getMessageEvents: (...args: unknown[]) => mockGetMessageEvents(...args),
41
+ getConversationMessages: (...args: unknown[]) => mockGetConversationMessages(...args),
42
+ }));
43
+
44
+ vi.mock("../../src/utils/logger.js", () => ({
45
+ log: {
46
+ debug: vi.fn(),
47
+ info: vi.fn(),
48
+ warn: vi.fn(),
49
+ error: vi.fn(),
50
+ success: vi.fn(),
51
+ },
52
+ }));
53
+
54
+ vi.mock("../../src/utils/errors.js", () => ({
55
+ errorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
56
+ }));
57
+
58
+ const { analyzeSelfPostTask } = await import("../../src/agent/self-analyzer.js");
59
+
60
+ // ── Helper ──────────────────────────────────────────────────────
61
+
62
+ function makeAnalysisResult(overrides: Record<string, unknown> = {}) {
63
+ return {
64
+ is_perfect: false,
65
+ overall_score: 7,
66
+ task_completion_quality: {
67
+ score: 8,
68
+ assessment: "Task completed successfully with minor inefficiencies.",
69
+ },
70
+ improvements: [
71
+ {
72
+ area: "Error Handling",
73
+ severity: "minor",
74
+ description: "Browser tool errors could be more descriptive.",
75
+ suggestion: "Include the CSS selector in error messages for easier debugging.",
76
+ },
77
+ ],
78
+ data_quality: {
79
+ session_logs_useful: true,
80
+ session_logs_gaps: "Missing timing information for each tool call.",
81
+ message_events_useful: true,
82
+ message_events_gaps: null,
83
+ conversation_context_useful: true,
84
+ conversation_context_gaps: null,
85
+ },
86
+ summary: "AssistMe handled the task well but has room for improvement in error reporting.",
87
+ ...overrides,
88
+ };
89
+ }
90
+
91
+ const baseOpts = {
92
+ model: "claude-sonnet-4-20250514",
93
+ taskId: "task-001",
94
+ conversationId: "conv-001",
95
+ taskPrompt: "Navigate to example.com and extract the title",
96
+ taskResponse: "The title of example.com is 'Example Domain'.",
97
+ toolCallRecords: [
98
+ { name: "browser_navigate", input: { url: "https://example.com" }, result: "Navigated" },
99
+ { name: "browser_read_page", input: {}, result: "Example Domain" },
100
+ ],
101
+ toolFailures: [],
102
+ tokenUsage: { input_tokens: 1000, output_tokens: 500 },
103
+ sessionId: "session-001",
104
+ };
105
+
106
+ // ── Tests ────────────────────────────────────────────────────────
107
+
108
+ describe("analyzeSelfPostTask", () => {
109
+ beforeEach(() => {
110
+ vi.clearAllMocks();
111
+ mockQueryMessages.length = 0;
112
+ mockQueryCalls = [];
113
+ });
114
+
115
+ it("submits feedback when improvements are found", async () => {
116
+ const analysis = makeAnalysisResult();
117
+ mockQueryMessages.push({
118
+ type: "result",
119
+ subtype: "success",
120
+ structured_output: analysis,
121
+ total_cost_usd: 0.005,
122
+ num_turns: 1,
123
+ usage: { input_tokens: 2000, output_tokens: 500 },
124
+ });
125
+
126
+ await analyzeSelfPostTask(baseOpts);
127
+
128
+ expect(mockSubmitFeedback).toHaveBeenCalledTimes(1);
129
+ const feedbackCall = mockSubmitFeedback.mock.calls[0][0];
130
+ expect(feedbackCall.slug).toBe("assistme");
131
+ expect(feedbackCall.title).toContain("Score 7/10");
132
+ expect(feedbackCall.title).toContain("1 improvement");
133
+ expect(feedbackCall.description).toContain("Error Handling");
134
+ expect(feedbackCall.category).toBe("improvement");
135
+ });
136
+
137
+ it("does NOT submit feedback when analysis is perfect", async () => {
138
+ const analysis = makeAnalysisResult({
139
+ is_perfect: true,
140
+ overall_score: 10,
141
+ improvements: [],
142
+ });
143
+ mockQueryMessages.push({
144
+ type: "result",
145
+ subtype: "success",
146
+ structured_output: analysis,
147
+ total_cost_usd: 0.003,
148
+ num_turns: 1,
149
+ usage: { input_tokens: 1500, output_tokens: 300 },
150
+ });
151
+
152
+ await analyzeSelfPostTask(baseOpts);
153
+
154
+ expect(mockSubmitFeedback).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("uses independent query (no session resume)", async () => {
158
+ mockQueryMessages.push({
159
+ type: "result",
160
+ subtype: "success",
161
+ structured_output: makeAnalysisResult({ is_perfect: true, improvements: [] }),
162
+ total_cost_usd: 0.003,
163
+ num_turns: 1,
164
+ usage: { input_tokens: 1000, output_tokens: 200 },
165
+ });
166
+
167
+ await analyzeSelfPostTask(baseOpts);
168
+
169
+ expect(mockQueryCalls).toHaveLength(1);
170
+ const queryParams = mockQueryCalls[0] as { options: Record<string, unknown> };
171
+ expect(queryParams.options.resume).toBeUndefined();
172
+ expect(queryParams.options.maxTurns).toBe(1);
173
+ expect(queryParams.options.allowedTools).toEqual([]);
174
+ });
175
+
176
+ it("includes session logs and filtered events in context prompt", async () => {
177
+ mockGetSessionLogs.mockResolvedValueOnce([
178
+ { log_type: "stdout", message: "Processing task...", sequence_number: 1, created_at: "2026-01-01" },
179
+ { log_type: "stderr", message: "Warning: slow response", sequence_number: 2, created_at: "2026-01-01" },
180
+ ]);
181
+ mockGetMessageEvents.mockResolvedValueOnce([
182
+ { event_type: "tool_use_start", event_data: { name: "browser_navigate" }, sequence_number: 1, created_at: "2026-01-01" },
183
+ { event_type: "tool_result", event_data: { name: "browser_navigate", result: "ok" }, sequence_number: 2, created_at: "2026-01-01" },
184
+ ]);
185
+ mockGetConversationMessages.mockResolvedValueOnce([
186
+ { role: "user", content: "Go to example.com", status: null },
187
+ ]);
188
+
189
+ mockQueryMessages.push({
190
+ type: "result",
191
+ subtype: "success",
192
+ structured_output: makeAnalysisResult({ is_perfect: true, improvements: [] }),
193
+ total_cost_usd: 0.003,
194
+ num_turns: 1,
195
+ usage: { input_tokens: 1000, output_tokens: 200 },
196
+ });
197
+
198
+ await analyzeSelfPostTask(baseOpts);
199
+
200
+ const queryParams = mockQueryCalls[0] as { prompt: string };
201
+ expect(queryParams.prompt).toContain("Session Logs (2 entries)");
202
+ expect(queryParams.prompt).toContain("Processing task...");
203
+ // Events are now aggregated — shows raw → aggregated count
204
+ expect(queryParams.prompt).toContain("Message Events (2 raw");
205
+ expect(queryParams.prompt).toContain("aggregated)");
206
+ expect(queryParams.prompt).toContain("[tool_result] browser_navigate: ok");
207
+ expect(queryParams.prompt).toContain("Conversation Messages (1 entries)");
208
+ expect(queryParams.prompt).toContain("Go to example.com");
209
+ });
210
+
211
+ it("aggregates consecutive text_delta events and drops thinking", async () => {
212
+ mockGetMessageEvents.mockResolvedValueOnce([
213
+ { event_type: "text_delta", event_data: { text: "Hello " }, sequence_number: 1, created_at: "2026-01-01" },
214
+ { event_type: "text_delta", event_data: { text: "world" }, sequence_number: 2, created_at: "2026-01-01" },
215
+ { event_type: "thinking", event_data: { text: "I should analyze this carefully..." }, sequence_number: 3, created_at: "2026-01-01" },
216
+ { event_type: "tool_use_start", event_data: { name: "Bash" }, sequence_number: 4, created_at: "2026-01-01" },
217
+ { event_type: "tool_use_input", event_data: { input: { command: "ls" } }, sequence_number: 5, created_at: "2026-01-01" },
218
+ { event_type: "tool_result", event_data: { name: "Bash", result: "file1.ts file2.ts" }, sequence_number: 6, created_at: "2026-01-01" },
219
+ { event_type: "text_delta", event_data: { text: "Done!" }, sequence_number: 7, created_at: "2026-01-01" },
220
+ ]);
221
+
222
+ mockQueryMessages.push({
223
+ type: "result",
224
+ subtype: "success",
225
+ structured_output: makeAnalysisResult({ is_perfect: true, improvements: [] }),
226
+ total_cost_usd: 0.003,
227
+ num_turns: 1,
228
+ usage: { input_tokens: 1000, output_tokens: 200 },
229
+ });
230
+
231
+ await analyzeSelfPostTask(baseOpts);
232
+
233
+ const queryParams = mockQueryCalls[0] as { prompt: string };
234
+ // 7 raw events → 4 aggregated (2 text_delta merged, thinking dropped, tool_use_input dropped)
235
+ expect(queryParams.prompt).toContain("7 raw");
236
+ // Merged text_delta
237
+ expect(queryParams.prompt).toContain("[text_delta x2] Hello world");
238
+ // Thinking should be dropped
239
+ expect(queryParams.prompt).not.toContain("I should analyze this carefully");
240
+ // tool_use_input should be dropped
241
+ expect(queryParams.prompt).not.toContain("[tool_use_input]");
242
+ // tool_result kept with summary
243
+ expect(queryParams.prompt).toContain("[tool_result] Bash: file1.ts file2.ts");
244
+ // Second text_delta batch
245
+ expect(queryParams.prompt).toContain("[text_delta x1] Done!");
246
+ });
247
+
248
+ it("includes tool failures in context when present", async () => {
249
+ mockQueryMessages.push({
250
+ type: "result",
251
+ subtype: "success",
252
+ structured_output: makeAnalysisResult({ is_perfect: true, improvements: [] }),
253
+ total_cost_usd: 0.003,
254
+ num_turns: 1,
255
+ usage: { input_tokens: 1000, output_tokens: 200 },
256
+ });
257
+
258
+ await analyzeSelfPostTask({
259
+ ...baseOpts,
260
+ toolFailures: [
261
+ { toolName: "browser_click", input: { selector: "#btn" }, error: "Element not found", timestamp: Date.now() },
262
+ ],
263
+ });
264
+
265
+ const queryParams = mockQueryCalls[0] as { prompt: string };
266
+ expect(queryParams.prompt).toContain("Tool Failures (1 failures)");
267
+ expect(queryParams.prompt).toContain("browser_click");
268
+ expect(queryParams.prompt).toContain("Element not found");
269
+ });
270
+
271
+ it("handles SDK errors gracefully without throwing", async () => {
272
+ mockQueryMessages.push({
273
+ type: "result",
274
+ subtype: "error_during_execution",
275
+ errors: ["Analysis failed"],
276
+ total_cost_usd: 0,
277
+ num_turns: 0,
278
+ usage: { input_tokens: 10, output_tokens: 0 },
279
+ });
280
+
281
+ // Should not throw
282
+ await expect(analyzeSelfPostTask(baseOpts)).resolves.toBeUndefined();
283
+ expect(mockSubmitFeedback).not.toHaveBeenCalled();
284
+ });
285
+
286
+ it("handles feedback submission failure gracefully", async () => {
287
+ const { FeedbackError } = await import("edsger-feedback");
288
+ mockSubmitFeedback.mockRejectedValueOnce(new FeedbackError("Rate limited", 429));
289
+
290
+ mockQueryMessages.push({
291
+ type: "result",
292
+ subtype: "success",
293
+ structured_output: makeAnalysisResult(),
294
+ total_cost_usd: 0.005,
295
+ num_turns: 1,
296
+ usage: { input_tokens: 2000, output_tokens: 500 },
297
+ });
298
+
299
+ // Should not throw
300
+ await expect(analyzeSelfPostTask(baseOpts)).resolves.toBeUndefined();
301
+ });
302
+
303
+ it("handles multiple improvements with correct count in title", async () => {
304
+ const analysis = makeAnalysisResult({
305
+ improvements: [
306
+ { area: "Error Handling", severity: "minor", description: "desc1", suggestion: "sug1" },
307
+ { area: "Performance", severity: "major", description: "desc2", suggestion: "sug2" },
308
+ { area: "Observability", severity: "suggestion", description: "desc3", suggestion: "sug3" },
309
+ ],
310
+ });
311
+ mockQueryMessages.push({
312
+ type: "result",
313
+ subtype: "success",
314
+ structured_output: analysis,
315
+ total_cost_usd: 0.005,
316
+ num_turns: 1,
317
+ usage: { input_tokens: 2000, output_tokens: 500 },
318
+ });
319
+
320
+ await analyzeSelfPostTask(baseOpts);
321
+
322
+ const feedbackCall = mockSubmitFeedback.mock.calls[0][0];
323
+ expect(feedbackCall.title).toContain("3 improvement(s)");
324
+ expect(feedbackCall.description).toContain("Performance");
325
+ expect(feedbackCall.description).toContain("Observability");
326
+ });
327
+
328
+ it("notes missing data when no logs/events available", async () => {
329
+ mockGetSessionLogs.mockResolvedValueOnce([]);
330
+ mockGetMessageEvents.mockResolvedValueOnce([]);
331
+ mockGetConversationMessages.mockResolvedValueOnce([]);
332
+
333
+ mockQueryMessages.push({
334
+ type: "result",
335
+ subtype: "success",
336
+ structured_output: makeAnalysisResult({ is_perfect: true, improvements: [] }),
337
+ total_cost_usd: 0.003,
338
+ num_turns: 1,
339
+ usage: { input_tokens: 1000, output_tokens: 200 },
340
+ });
341
+
342
+ await analyzeSelfPostTask(baseOpts);
343
+
344
+ const queryParams = mockQueryCalls[0] as { prompt: string };
345
+ expect(queryParams.prompt).toContain("No session logs available");
346
+ expect(queryParams.prompt).toContain("No message events available");
347
+ expect(queryParams.prompt).toContain("data gap");
348
+ });
349
+ });