diff --git a/packages/plugins/official/user-feedback-collector/__tests__/index.test.ts b/packages/plugins/official/user-feedback-collector/__tests__/index.test.ts
new file mode 100644
index 0000000..05dd191
--- /dev/null
+++ b/packages/plugins/official/user-feedback-collector/__tests__/index.test.ts
@@ -0,0 +1,973 @@
+///
+/**
+ * User Feedback Collector — Unit Tests
+ *
+ * Covers: DB key helpers, todayUtc, isoWeek, yearMonth, normaliseRating,
+ * updateStats, buildCsvRow, plugin manifest/settings, app:init (4 endpoints),
+ * POST /feedback (success, missing fields, bad rating, requireComment),
+ * GET /feedback (no filter, conversationId filter, model filter, date filter),
+ * GET /stats (group by day / week / month, model filter),
+ * GET /export (empty, with records, CSV format),
+ * response:modify filter.
+ */
+import plugin, {
+ buildFeedbackKey,
+ buildStatsKey,
+ todayUtc,
+ isoWeek,
+ yearMonth,
+ normaliseRating,
+ updateStats,
+ buildCsvRow,
+ CSV_HEADER,
+ FEEDBACK_KEY_PREFIX,
+ STATS_KEY_PREFIX,
+ FeedbackRecord,
+ DailyModelStats,
+} from "../src/index";
+import {
+ PluginContext,
+ PluginAPI,
+ PluginDatabaseAPI,
+ PluginEventBus,
+ EndpointDefinition,
+ EndpointRequest,
+ EndpointResponse,
+} from "@agentbase/plugin-sdk";
+
+// ── Mock factory ──────────────────────────────────────────────────────────────
+
+function createMockAPI(
+ configOverrides: Record = {},
+): PluginAPI & { _endpoints: EndpointDefinition[] } {
+ const store = new Map();
+ const _endpoints: EndpointDefinition[] = [];
+
+ const db: PluginDatabaseAPI = {
+ set: jest
+ .fn()
+ .mockImplementation(async (k: string, v: unknown) => store.set(k, v)),
+ get: jest
+ .fn()
+ .mockImplementation(async (k: string) => store.get(k) ?? null),
+ delete: jest.fn().mockImplementation(async (k: string) => {
+ const had = store.has(k);
+ store.delete(k);
+ return had;
+ }),
+ keys: jest
+ .fn()
+ .mockImplementation(async (prefix?: string) =>
+ [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
+ ),
+ find: jest.fn().mockResolvedValue([]),
+ count: jest.fn().mockResolvedValue(0),
+ };
+
+ const events: PluginEventBus = {
+ emit: jest.fn().mockResolvedValue(undefined),
+ on: jest.fn(),
+ off: jest.fn(),
+ };
+
+ const defaultConfig = new Map([
+ ["enableAutoPrompt", true],
+ ["feedbackTypes", "thumbs"],
+ ["requireComment", false],
+ ...Object.entries(configOverrides),
+ ]);
+
+ return {
+ _endpoints,
+ getConfig: jest
+ .fn()
+ .mockImplementation((k: string) => defaultConfig.get(k) ?? undefined),
+ setConfig: jest
+ .fn()
+ .mockImplementation(async (k: string, v: unknown) =>
+ defaultConfig.set(k, v),
+ ),
+ makeRequest: jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({}),
+ text: jest.fn().mockResolvedValue(""),
+ }),
+ log: jest.fn(),
+ db,
+ events,
+ registerEndpoint: jest
+ .fn()
+ .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)),
+ registerCronJob: jest.fn(),
+ registerWebhook: jest.fn(),
+ registerAdminPage: jest.fn(),
+ } as unknown as PluginAPI & { _endpoints: EndpointDefinition[] };
+}
+
+type MockAPI = ReturnType;
+type MockCtx = PluginContext & { api: MockAPI };
+
+function makeCtx(
+ overrides: Partial = {},
+ configOverrides: Record = {},
+): MockCtx {
+ const api = createMockAPI(configOverrides);
+ return {
+ appId: "app-1",
+ userId: "user-1",
+ config: {},
+ api,
+ ...overrides,
+ } as MockCtx;
+}
+
+interface MockRes {
+ status: jest.Mock;
+ json: jest.Mock;
+ _status: number;
+ _body: unknown;
+}
+
+function makeRes(): MockRes {
+ const r: MockRes = {
+ _status: 200,
+ _body: undefined,
+ status: jest.fn(),
+ json: jest.fn(),
+ };
+ r.status.mockImplementation((code: number) => {
+ r._status = code;
+ return r;
+ });
+ r.json.mockImplementation((body: unknown) => {
+ r._body = body;
+ });
+ return r;
+}
+
+function makeReq(overrides: Partial = {}): EndpointRequest {
+ return {
+ method: "GET",
+ path: "/",
+ params: {},
+ query: {},
+ body: {},
+ headers: {},
+ ...overrides,
+ };
+}
+
+async function runInit(ctx: MockCtx): Promise {
+ const hook = plugin.definition.hooks?.["app:init"];
+ if (!hook) throw new Error("app:init hook not registered");
+ await hook(ctx);
+}
+
+function getEndpoint(
+ api: MockAPI,
+ method: string,
+ path: string,
+): EndpointDefinition {
+ const ep = api._endpoints.find((e) => e.method === method && e.path === path);
+ if (!ep) throw new Error(`Endpoint ${method} ${path} not found`);
+ return ep;
+}
+
+// ── DB Key Helpers ────────────────────────────────────────────────────────────
+
+describe("buildFeedbackKey", () => {
+ it("formats as feedback:conversationId:messageIndex", () => {
+ expect(buildFeedbackKey("conv-1", 0)).toBe("feedback:conv-1:0");
+ expect(buildFeedbackKey("conv-abc", 42)).toBe("feedback:conv-abc:42");
+ });
+});
+
+describe("buildStatsKey", () => {
+ it("formats as stats:daily:date:model", () => {
+ expect(buildStatsKey("2025-06-01", "openai/gpt-4o")).toBe(
+ "stats:daily:2025-06-01:openai/gpt-4o",
+ );
+ });
+});
+
+// ── todayUtc ──────────────────────────────────────────────────────────────────
+
+describe("todayUtc", () => {
+ it("returns YYYY-MM-DD format", () => {
+ expect(todayUtc(new Date("2025-06-15T10:00:00Z").getTime())).toBe(
+ "2025-06-15",
+ );
+ });
+
+ it("returns current date when no arg given", () => {
+ expect(todayUtc()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+});
+
+// ── isoWeek ───────────────────────────────────────────────────────────────────
+
+describe("isoWeek", () => {
+ it("returns YYYY-WNN format", () => {
+ expect(isoWeek("2025-01-01")).toMatch(/^\d{4}-W\d{2}$/);
+ });
+
+ it("groups two days in same week", () => {
+ // 2025-06-04 (day 155) and 2025-06-10 (day 161) both yield ceil(n/7) = 23
+ const w1 = isoWeek("2025-06-04");
+ const w2 = isoWeek("2025-06-10");
+ expect(w1).toBe(w2);
+ });
+
+ it("gives different weeks for days in different weeks", () => {
+ const w1 = isoWeek("2025-06-01");
+ const w2 = isoWeek("2025-06-09");
+ expect(w1).not.toBe(w2);
+ });
+});
+
+// ── yearMonth ─────────────────────────────────────────────────────────────────
+
+describe("yearMonth", () => {
+ it("returns YYYY-MM", () => {
+ expect(yearMonth("2025-06-15")).toBe("2025-06");
+ expect(yearMonth("2024-12-31")).toBe("2024-12");
+ });
+});
+
+// ── normaliseRating ───────────────────────────────────────────────────────────
+
+describe("normaliseRating", () => {
+ describe("thumbs type", () => {
+ it("maps 'up' → numeric 2", () => {
+ const r = normaliseRating("up", "thumbs");
+ expect(r).not.toBeNull();
+ expect(r!.numeric).toBe(2);
+ expect(r!.raw).toBe("up");
+ });
+
+ it("maps 'down' → numeric 1", () => {
+ const r = normaliseRating("down", "thumbs");
+ expect(r!.numeric).toBe(1);
+ expect(r!.raw).toBe("down");
+ });
+
+ it("returns null for invalid value", () => {
+ expect(normaliseRating("maybe", "thumbs")).toBeNull();
+ expect(normaliseRating(3, "thumbs")).toBeNull();
+ expect(normaliseRating(null, "thumbs")).toBeNull();
+ });
+ });
+
+ describe("stars type", () => {
+ it("maps integer 1–5 through as-is", () => {
+ for (let i = 1; i <= 5; i++) {
+ const r = normaliseRating(i, "stars");
+ expect(r).not.toBeNull();
+ expect(r!.numeric).toBe(i);
+ expect(r!.raw).toBe(i);
+ }
+ });
+
+ it("returns null for out-of-range values", () => {
+ expect(normaliseRating(0, "stars")).toBeNull();
+ expect(normaliseRating(6, "stars")).toBeNull();
+ });
+
+ it("returns null for non-integers", () => {
+ expect(normaliseRating(2.5, "stars")).toBeNull();
+ });
+ });
+
+ describe("both type", () => {
+ it("accepts thumbs values", () => {
+ expect(normaliseRating("up", "both")).not.toBeNull();
+ expect(normaliseRating("down", "both")).not.toBeNull();
+ });
+
+ it("accepts star values", () => {
+ expect(normaliseRating(4, "both")).not.toBeNull();
+ });
+ });
+});
+
+// ── updateStats ───────────────────────────────────────────────────────────────
+
+describe("updateStats", () => {
+ it("creates a new record when none exists", () => {
+ const rec = updateStats(null, "gpt-4o", "2025-06-01", 2);
+ expect(rec.count).toBe(1);
+ expect(rec.ratingSum).toBe(2);
+ expect(rec.avgRating).toBe(2);
+ expect(rec.model).toBe("gpt-4o");
+ expect(rec.date).toBe("2025-06-01");
+ });
+
+ it("increments existing record", () => {
+ const existing: DailyModelStats = {
+ model: "gpt-4o",
+ date: "2025-06-01",
+ ratingSum: 4,
+ count: 2,
+ avgRating: 2,
+ };
+ const rec = updateStats(existing, "gpt-4o", "2025-06-01", 1);
+ expect(rec.count).toBe(3);
+ expect(rec.ratingSum).toBe(5);
+ expect(rec.avgRating).toBeCloseTo(5 / 3);
+ });
+
+ it("computes correct average with multiple updates", () => {
+ let rec: DailyModelStats | null = null;
+ rec = updateStats(rec, "m", "2025-01-01", 2);
+ rec = updateStats(rec, "m", "2025-01-01", 2);
+ rec = updateStats(rec, "m", "2025-01-01", 1);
+ expect(rec.count).toBe(3);
+ expect(rec.avgRating).toBeCloseTo(5 / 3);
+ });
+});
+
+// ── buildCsvRow ───────────────────────────────────────────────────────────────
+
+describe("buildCsvRow", () => {
+ it("produces a comma-separated row with 8 fields", () => {
+ const record: FeedbackRecord = {
+ conversationId: "conv-1",
+ messageIndex: 0,
+ rating: 2,
+ rawRating: "up",
+ comment: "Great!",
+ model: "gpt-4o",
+ userId: "u1",
+ timestamp: new Date("2025-06-01T12:00:00Z").getTime(),
+ };
+ const row = buildCsvRow(record);
+ const cols = row.split(",");
+ expect(cols).toHaveLength(8);
+ expect(cols[0]).toBe("conv-1");
+ expect(cols[2]).toBe("up");
+ });
+
+ it("escapes commas in comment field", () => {
+ const record: FeedbackRecord = {
+ conversationId: "c",
+ messageIndex: 0,
+ rating: 1,
+ rawRating: "down",
+ comment: "bad, really bad",
+ model: "m",
+ userId: "u",
+ timestamp: Date.now(),
+ };
+ const row = buildCsvRow(record);
+ expect(row).toContain('"bad, really bad"');
+ });
+
+ it("escapes double-quotes in comment", () => {
+ const record: FeedbackRecord = {
+ conversationId: "c",
+ messageIndex: 0,
+ rating: 2,
+ rawRating: "up",
+ comment: 'She said "hello"',
+ model: "m",
+ userId: "u",
+ timestamp: Date.now(),
+ };
+ const row = buildCsvRow(record);
+ expect(row).toContain('"She said ""hello"""');
+ });
+
+ it("handles missing optional fields gracefully", () => {
+ const record: FeedbackRecord = {
+ conversationId: "c",
+ messageIndex: 1,
+ rating: 2,
+ rawRating: "up",
+ timestamp: 0,
+ };
+ // Should not throw
+ expect(() => buildCsvRow(record)).not.toThrow();
+ });
+});
+
+describe("CSV_HEADER", () => {
+ it("has 8 columns", () => {
+ expect(CSV_HEADER.split(",")).toHaveLength(8);
+ });
+});
+
+// ── Plugin manifest ───────────────────────────────────────────────────────────
+
+describe("plugin manifest", () => {
+ it("has correct name", () => {
+ expect(plugin.manifest.name).toBe("user-feedback-collector");
+ });
+
+ it("has correct version", () => {
+ expect(plugin.manifest.version).toBe("1.0.0");
+ });
+
+ it("has a description", () => {
+ expect((plugin.manifest.description ?? "").length).toBeGreaterThan(10);
+ });
+});
+
+// ── Plugin settings ───────────────────────────────────────────────────────────
+
+describe("plugin settings", () => {
+ const settings = plugin.definition.settings!;
+
+ it("defines exactly 3 settings", () => {
+ expect(Object.keys(settings)).toHaveLength(3);
+ });
+
+ it("has enableAutoPrompt as boolean", () => {
+ expect((settings["enableAutoPrompt"] as { type: string }).type).toBe(
+ "boolean",
+ );
+ });
+
+ it("has feedbackTypes as select", () => {
+ expect((settings["feedbackTypes"] as { type: string }).type).toBe("select");
+ });
+
+ it("has requireComment as boolean with default false", () => {
+ const s = settings["requireComment"] as { type: string; default: unknown };
+ expect(s.type).toBe("boolean");
+ expect(s.default).toBe(false);
+ });
+});
+
+// ── app:init — endpoint registration ─────────────────────────────────────────
+
+describe("app:init", () => {
+ it("registers exactly 4 endpoints", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(ctx.api._endpoints).toHaveLength(4);
+ });
+
+ it("registers POST /feedback", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(() => getEndpoint(ctx.api, "POST", "/feedback")).not.toThrow();
+ });
+
+ it("registers GET /feedback", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(() => getEndpoint(ctx.api, "GET", "/feedback")).not.toThrow();
+ });
+
+ it("registers GET /stats", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(() => getEndpoint(ctx.api, "GET", "/stats")).not.toThrow();
+ });
+
+ it("registers GET /export", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(() => getEndpoint(ctx.api, "GET", "/export")).not.toThrow();
+ });
+
+ it("all endpoints require auth", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ for (const ep of ctx.api._endpoints) {
+ expect(ep.auth).toBe(true);
+ }
+ });
+});
+
+// ── POST /feedback ────────────────────────────────────────────────────────────
+
+describe("POST /feedback", () => {
+ it("returns 400 when conversationId missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ body: { messageIndex: 0, rating: "up" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ });
+
+ it("returns 400 when messageIndex missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ body: { conversationId: "c1", rating: "up" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ });
+
+ it("returns 400 for invalid rating", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({
+ body: { conversationId: "c1", messageIndex: 0, rating: "maybe" },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ });
+
+ it("saves feedback record and returns 201 on success", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({
+ body: {
+ conversationId: "conv-1",
+ messageIndex: 2,
+ rating: "up",
+ model: "gpt-4o",
+ },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(201);
+ const body = res._body as { ok: boolean; feedback: FeedbackRecord };
+ expect(body.ok).toBe(true);
+ expect(body.feedback.rawRating).toBe("up");
+ expect(body.feedback.rating).toBe(2);
+
+ const saved = (await ctx.api.db.get(
+ buildFeedbackKey("conv-1", 2),
+ )) as FeedbackRecord;
+ expect(saved).not.toBeNull();
+ });
+
+ it("updates daily stats on submission", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({
+ body: {
+ conversationId: "c",
+ messageIndex: 0,
+ rating: "up",
+ model: "gpt-4o",
+ },
+ }),
+ res as unknown as EndpointResponse,
+ );
+
+ const today = todayUtc();
+ const statsKey = buildStatsKey(today, "gpt-4o");
+ const stats = (await ctx.api.db.get(statsKey)) as DailyModelStats;
+ expect(stats).not.toBeNull();
+ expect(stats.count).toBe(1);
+ expect(stats.ratingSum).toBe(2);
+ });
+
+ it("returns 400 when requireComment=true and no comment given", async () => {
+ const ctx = makeCtx({}, { requireComment: true });
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({
+ body: { conversationId: "c", messageIndex: 0, rating: "up" },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ });
+
+ it("accepts comment when requireComment=true", async () => {
+ const ctx = makeCtx({}, { requireComment: true });
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({
+ body: {
+ conversationId: "c",
+ messageIndex: 0,
+ rating: "up",
+ comment: "Great response!",
+ },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(201);
+ });
+
+ it("accepts star ratings when feedbackTypes=stars", async () => {
+ const ctx = makeCtx({}, { feedbackTypes: "stars" });
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({
+ body: { conversationId: "c", messageIndex: 0, rating: 4 },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(201);
+ const body = res._body as { feedback: FeedbackRecord };
+ expect(body.feedback.rating).toBe(4);
+ });
+
+ it("stores userId from context", async () => {
+ const ctx = makeCtx({ userId: "user-99" });
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({
+ body: { conversationId: "c", messageIndex: 0, rating: "down" },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ const saved = (await ctx.api.db.get(
+ buildFeedbackKey("c", 0),
+ )) as FeedbackRecord;
+ expect(saved.userId).toBe("user-99");
+ });
+});
+
+// ── GET /feedback ─────────────────────────────────────────────────────────────
+
+async function seedFeedback(
+ ctx: MockCtx,
+ records: Partial[],
+): Promise {
+ for (const r of records) {
+ const full: FeedbackRecord = {
+ conversationId: r.conversationId ?? "conv-1",
+ messageIndex: r.messageIndex ?? 0,
+ rating: r.rating ?? 2,
+ rawRating: r.rawRating ?? "up",
+ comment: r.comment,
+ model: r.model,
+ userId: r.userId,
+ timestamp: r.timestamp ?? Date.now(),
+ };
+ await ctx.api.db.set(
+ buildFeedbackKey(full.conversationId, full.messageIndex),
+ full,
+ );
+ }
+}
+
+describe("GET /feedback", () => {
+ it("returns empty array when no records", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/feedback");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ expect((res._body as { feedback: unknown[] }).feedback).toEqual([]);
+ });
+
+ it("returns all records when no filter", async () => {
+ const ctx = makeCtx();
+ await seedFeedback(ctx, [
+ { conversationId: "c1", messageIndex: 0 },
+ { conversationId: "c2", messageIndex: 0 },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/feedback");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ const body = res._body as { feedback: FeedbackRecord[]; total: number };
+ expect(body.feedback).toHaveLength(2);
+ expect(body.total).toBe(2);
+ });
+
+ it("filters by conversationId", async () => {
+ const ctx = makeCtx();
+ await seedFeedback(ctx, [
+ { conversationId: "c1", messageIndex: 0 },
+ { conversationId: "c2", messageIndex: 0 },
+ { conversationId: "c1", messageIndex: 1 },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { conversationId: "c1" } }),
+ res as unknown as EndpointResponse,
+ );
+ const body = res._body as { feedback: FeedbackRecord[] };
+ expect(body.feedback).toHaveLength(2);
+ expect(body.feedback.every((r) => r.conversationId === "c1")).toBe(true);
+ });
+
+ it("filters by model", async () => {
+ const ctx = makeCtx();
+ await seedFeedback(ctx, [
+ { conversationId: "c1", messageIndex: 0, model: "gpt-4o" },
+ { conversationId: "c1", messageIndex: 1, model: "claude-3-5-sonnet" },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { model: "gpt-4o" } }),
+ res as unknown as EndpointResponse,
+ );
+ const body = res._body as { feedback: FeedbackRecord[] };
+ expect(body.feedback).toHaveLength(1);
+ expect(body.feedback[0]!.model).toBe("gpt-4o");
+ });
+
+ it("filters by from date", async () => {
+ const now = Date.now();
+ const ctx = makeCtx();
+ await seedFeedback(ctx, [
+ { conversationId: "c1", messageIndex: 0, timestamp: now - 10_000 },
+ { conversationId: "c1", messageIndex: 1, timestamp: now },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({
+ query: { from: new Date(now - 5_000).toISOString() },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ const body = res._body as { feedback: FeedbackRecord[] };
+ expect(body.feedback).toHaveLength(1);
+ });
+
+ it("respects limit parameter", async () => {
+ const ctx = makeCtx();
+ await seedFeedback(ctx, [
+ { conversationId: "c1", messageIndex: 0 },
+ { conversationId: "c2", messageIndex: 0 },
+ { conversationId: "c3", messageIndex: 0 },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/feedback");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { limit: "2" } }),
+ res as unknown as EndpointResponse,
+ );
+ const body = res._body as { feedback: FeedbackRecord[]; total: number };
+ expect(body.feedback).toHaveLength(2);
+ expect(body.total).toBe(3); // total reflects unsliced count
+ });
+});
+
+// ── GET /stats ────────────────────────────────────────────────────────────────
+
+async function seedStats(
+ ctx: MockCtx,
+ records: Partial[],
+): Promise {
+ for (const r of records) {
+ const full: DailyModelStats = {
+ model: r.model ?? "gpt-4o",
+ date: r.date ?? "2025-06-01",
+ ratingSum: r.ratingSum ?? 4,
+ count: r.count ?? 2,
+ avgRating: r.avgRating ?? 2,
+ };
+ await ctx.api.db.set(buildStatsKey(full.date, full.model), full);
+ }
+}
+
+describe("GET /stats", () => {
+ it("returns empty stats when no data", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/stats");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ expect((res._body as { stats: unknown[] }).stats).toEqual([]);
+ });
+
+ it("groups by day by default", async () => {
+ const ctx = makeCtx();
+ await seedStats(ctx, [
+ { model: "gpt-4o", date: "2025-06-01", ratingSum: 4, count: 2 },
+ { model: "gpt-4o", date: "2025-06-02", ratingSum: 3, count: 2 },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/stats");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ const body = res._body as { stats: { period: string }[]; groupBy: string };
+ expect(body.groupBy).toBe("day");
+ expect(body.stats).toHaveLength(2);
+ // Sorted descending by period
+ expect(body.stats[0]!.period).toBe("2025-06-02");
+ });
+
+ it("groups by week", async () => {
+ const ctx = makeCtx();
+ // Same calendar week
+ await seedStats(ctx, [
+ { model: "gpt-4o", date: "2025-06-02", ratingSum: 4, count: 2 },
+ { model: "gpt-4o", date: "2025-06-03", ratingSum: 2, count: 1 },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/stats");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { groupBy: "week" } }),
+ res as unknown as EndpointResponse,
+ );
+ const body = res._body as { stats: { count: number }[] };
+ // Both days folded into one week entry
+ expect(body.stats).toHaveLength(1);
+ expect(body.stats[0]!.count).toBe(3);
+ });
+
+ it("groups by month", async () => {
+ const ctx = makeCtx();
+ await seedStats(ctx, [
+ { model: "m", date: "2025-06-01", ratingSum: 4, count: 2 },
+ { model: "m", date: "2025-06-15", ratingSum: 2, count: 1 },
+ { model: "m", date: "2025-07-01", ratingSum: 3, count: 1 },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/stats");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { groupBy: "month" } }),
+ res as unknown as EndpointResponse,
+ );
+ const body = res._body as { stats: { period: string; count: number }[] };
+ expect(body.stats).toHaveLength(2);
+ const june = body.stats.find((s) => s.period === "2025-06");
+ expect(june!.count).toBe(3);
+ });
+
+ it("filters by model", async () => {
+ const ctx = makeCtx();
+ await seedStats(ctx, [
+ { model: "gpt-4o", date: "2025-06-01" },
+ { model: "claude", date: "2025-06-01" },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/stats");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { model: "gpt-4o" } }),
+ res as unknown as EndpointResponse,
+ );
+ const body = res._body as { stats: { model: string }[] };
+ expect(body.stats).toHaveLength(1);
+ expect(body.stats[0]!.model).toBe("gpt-4o");
+ });
+
+ it("computes correct avgRating in aggregated buckets", async () => {
+ const ctx = makeCtx();
+ await seedStats(ctx, [
+ { model: "m", date: "2025-06-01", ratingSum: 6, count: 3, avgRating: 2 },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/stats");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ const body = res._body as { stats: { avgRating: number }[] };
+ expect(body.stats[0]!.avgRating).toBeCloseTo(2);
+ });
+});
+
+// ── GET /export ───────────────────────────────────────────────────────────────
+
+describe("GET /export", () => {
+ it("returns 200 with CSV wrapper object", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/export");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ const body = res._body as Record;
+ expect(body._csv).toBe(true);
+ expect(body.contentType).toBe("text/csv");
+ });
+
+ it("returns only header row when no feedback exists", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/export");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ const body = res._body as { data: string };
+ expect(body.data.trim()).toBe(CSV_HEADER);
+ });
+
+ it("includes one data row per feedback record", async () => {
+ const ctx = makeCtx();
+ await seedFeedback(ctx, [
+ { conversationId: "c1", messageIndex: 0 },
+ { conversationId: "c2", messageIndex: 0 },
+ ]);
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/export");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ const body = res._body as { data: string };
+ const lines = body.data.trim().split("\n");
+ expect(lines).toHaveLength(3); // header + 2 records
+ });
+
+ it("filename contains today's date", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/export");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ const body = res._body as { filename: string };
+ expect(body.filename).toMatch(/feedback-export-\d{4}-\d{2}-\d{2}\.csv/);
+ });
+});
+
+// ── response:modify filter ────────────────────────────────────────────────────
+
+describe("response:modify filter", () => {
+ it("injects _feedbackEnabled: true into response", async () => {
+ const filter = plugin.definition.filters?.["response:modify"];
+ if (!filter) throw new Error("response:modify filter not registered");
+
+ const ctx = makeCtx();
+ const result = await filter(ctx, { text: "Hello" });
+ expect((result as Record)["_feedbackEnabled"]).toBe(true);
+ });
+
+ it("preserves existing response properties", async () => {
+ const filter = plugin.definition.filters?.["response:modify"];
+ const ctx = makeCtx();
+ const result = await filter!(ctx, { text: "Hello", model: "gpt-4o" });
+ const r = result as Record;
+ expect(r["text"]).toBe("Hello");
+ expect(r["model"]).toBe("gpt-4o");
+ });
+});
+
+// ── Key prefix constants ──────────────────────────────────────────────────────
+
+describe("constants", () => {
+ it("FEEDBACK_KEY_PREFIX is 'feedback:'", () => {
+ expect(FEEDBACK_KEY_PREFIX).toBe("feedback:");
+ });
+
+ it("STATS_KEY_PREFIX is 'stats:daily:'", () => {
+ expect(STATS_KEY_PREFIX).toBe("stats:daily:");
+ });
+});
diff --git a/packages/plugins/official/user-feedback-collector/__tests__/tsconfig.json b/packages/plugins/official/user-feedback-collector/__tests__/tsconfig.json
new file mode 100644
index 0000000..a05feed
--- /dev/null
+++ b/packages/plugins/official/user-feedback-collector/__tests__/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "../..",
+ "baseUrl": "..",
+ "lib": ["ES2022", "DOM"],
+ "types": ["jest", "node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["../src/**/*", "./**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/plugins/official/user-feedback-collector/manifest.json b/packages/plugins/official/user-feedback-collector/manifest.json
new file mode 100644
index 0000000..8b240b8
--- /dev/null
+++ b/packages/plugins/official/user-feedback-collector/manifest.json
@@ -0,0 +1,42 @@
+{
+ "name": "user-feedback-collector",
+ "version": "1.0.0",
+ "description": "Thumbs up/down rating after each AI response with optional comments. Aggregated quality scores per model and time period.",
+ "author": "Agentbase",
+ "license": "GPL-3.0",
+ "main": "dist/index.js",
+ "agentbase": {
+ "type": "plugin",
+ "apiVersion": "1"
+ },
+ "hooks": ["app:init"],
+ "filters": ["response:modify"],
+ "endpoints": [
+ { "method": "POST", "path": "/feedback" },
+ { "method": "GET", "path": "/feedback" },
+ { "method": "GET", "path": "/stats" },
+ { "method": "GET", "path": "/export" }
+ ],
+ "settings": [
+ {
+ "key": "enableAutoPrompt",
+ "type": "boolean",
+ "label": "Automatically prompt for feedback after each response",
+ "default": true
+ },
+ {
+ "key": "feedbackTypes",
+ "type": "select",
+ "label": "Rating style",
+ "options": ["thumbs", "stars", "both"],
+ "default": "thumbs"
+ },
+ {
+ "key": "requireComment",
+ "type": "boolean",
+ "label": "Require a comment with every rating",
+ "default": false
+ }
+ ],
+ "permissions": ["db:readwrite"]
+}
diff --git a/packages/plugins/official/user-feedback-collector/package.json b/packages/plugins/official/user-feedback-collector/package.json
new file mode 100644
index 0000000..c1e14c3
--- /dev/null
+++ b/packages/plugins/official/user-feedback-collector/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@agentbase/plugin-user-feedback-collector",
+ "version": "1.0.0",
+ "description": "Thumbs up/down rating after each AI response with optional comments. Aggregated quality scores per model and time period.",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "scripts": {
+ "build": "tsc",
+ "test": "jest",
+ "lint": "eslint src --ext .ts"
+ },
+ "dependencies": {
+ "@agentbase/plugin-sdk": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.0",
+ "@types/node": "^20.0.0",
+ "jest": "^29.5.0",
+ "ts-jest": "^29.1.0",
+ "typescript": "^5.0.0"
+ },
+ "jest": {
+ "preset": "ts-jest",
+ "testEnvironment": "node",
+ "roots": [
+ "/__tests__"
+ ],
+ "globals": {
+ "ts-jest": {
+ "tsconfig": "__tests__/tsconfig.json"
+ }
+ }
+ },
+ "license": "GPL-3.0"
+}
diff --git a/packages/plugins/official/user-feedback-collector/src/index.ts b/packages/plugins/official/user-feedback-collector/src/index.ts
new file mode 100644
index 0000000..ce930d6
--- /dev/null
+++ b/packages/plugins/official/user-feedback-collector/src/index.ts
@@ -0,0 +1,436 @@
+/**
+ * User Feedback Collector
+ *
+ * Captures thumbs-up / thumbs-down (or star) ratings after every AI response
+ * and stores them for quality monitoring. No AI calls are needed — this plugin
+ * is pure data collection.
+ *
+ * Key design decisions:
+ * - `response:modify` injects `_feedbackEnabled: true` into every response so
+ * the frontend knows to render the rating widget.
+ * - Ratings are stored per message: `feedback:{conversationId}:{messageIndex}`
+ * - Daily stats keyed by `stats:daily:{date}:{model}` track avg rating + count
+ * with an incremental running-average update on every submission.
+ * - `GET /export` streams CSV (no temp files, built inline).
+ * - Stats `GET /stats` supports grouping by model, day, week, or month.
+ *
+ * @package @agentbase/plugin-user-feedback-collector
+ * @version 1.0.0
+ */
+import { createPlugin } from "@agentbase/plugin-sdk";
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+export const FEEDBACK_KEY_PREFIX = "feedback:";
+export const STATS_KEY_PREFIX = "stats:daily:";
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type RatingType = "thumbs" | "stars" | "both";
+export type ThumbsRating = "up" | "down";
+export type StarsRating = 1 | 2 | 3 | 4 | 5;
+
+export interface FeedbackRecord {
+ conversationId: string;
+ messageIndex: number;
+ /** Numeric rating: 1 (thumbs-down) / 2 (thumbs-up) or 1–5 for stars. */
+ rating: number;
+ /** Raw value as submitted by user: "up" | "down" | 1-5. */
+ rawRating: ThumbsRating | StarsRating;
+ comment?: string;
+ model?: string;
+ userId?: string;
+ timestamp: number;
+}
+
+export interface DailyModelStats {
+ model: string;
+ date: string;
+ /** Running sum of all numeric ratings for the day. */
+ ratingSum: number;
+ /** Total feedback submissions. */
+ count: number;
+ /** Precomputed average: ratingSum / count. Updates on each submission. */
+ avgRating: number;
+}
+
+export interface StatsGroupEntry {
+ period: string;
+ model: string;
+ avgRating: number;
+ count: number;
+}
+
+// ── DB Key Helpers ────────────────────────────────────────────────────────────
+
+export function buildFeedbackKey(
+ conversationId: string,
+ messageIndex: number,
+): string {
+ return `${FEEDBACK_KEY_PREFIX}${conversationId}:${messageIndex}`;
+}
+
+export function buildStatsKey(date: string, model: string): string {
+ return `${STATS_KEY_PREFIX}${date}:${model}`;
+}
+
+export function todayUtc(nowMs: number = Date.now()): string {
+ return new Date(nowMs).toISOString().slice(0, 10);
+}
+
+/** ISO week number (1-53) for a date string "YYYY-MM-DD". */
+export function isoWeek(dateStr: string): string {
+ const d = new Date(`${dateStr}T00:00:00Z`);
+ const jan1 = new Date(`${d.getUTCFullYear()}-01-01T00:00:00Z`);
+ const dayOfYear = Math.floor((d.getTime() - jan1.getTime()) / 86_400_000) + 1;
+ const week = Math.ceil(dayOfYear / 7);
+ return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
+}
+
+/** "YYYY-MM" month string. */
+export function yearMonth(dateStr: string): string {
+ return dateStr.slice(0, 7);
+}
+
+// ── Rating Normalisation ──────────────────────────────────────────────────────
+
+/**
+ * Convert a raw user-supplied rating into a normalised numeric value.
+ * - thumbs: "up" → 2, "down" → 1
+ * - stars: 1–5 passed through as-is
+ * Returns `null` when the value is invalid for the given type.
+ */
+export function normaliseRating(
+ raw: unknown,
+ feedbackType: RatingType,
+): { numeric: number; raw: ThumbsRating | StarsRating } | null {
+ if (feedbackType === "thumbs" || feedbackType === "both") {
+ if (raw === "up") return { numeric: 2, raw: "up" as ThumbsRating };
+ if (raw === "down") return { numeric: 1, raw: "down" as ThumbsRating };
+ }
+ if (feedbackType === "stars" || feedbackType === "both") {
+ const n = Number(raw);
+ if (Number.isInteger(n) && n >= 1 && n <= 5)
+ return { numeric: n, raw: n as StarsRating };
+ }
+ return null;
+}
+
+// ── Stats Helpers ─────────────────────────────────────────────────────────────
+
+/**
+ * Incrementally update a DailyModelStats record with a new rating.
+ * Returns the updated record (mutates in place for efficiency).
+ */
+export function updateStats(
+ existing: DailyModelStats | null,
+ model: string,
+ date: string,
+ numericRating: number,
+): DailyModelStats {
+ const rec: DailyModelStats = existing ?? {
+ model,
+ date,
+ ratingSum: 0,
+ count: 0,
+ avgRating: 0,
+ };
+ rec.ratingSum += numericRating;
+ rec.count += 1;
+ rec.avgRating = rec.ratingSum / rec.count;
+ return rec;
+}
+
+// ── CSV Builder ───────────────────────────────────────────────────────────────
+
+export function buildCsvRow(record: FeedbackRecord): string {
+ const escape = (v: unknown): string => {
+ const s = String(v ?? "");
+ return s.includes(",") || s.includes('"') || s.includes("\n")
+ ? `"${s.replace(/"/g, '""')}"`
+ : s;
+ };
+ return [
+ escape(record.conversationId),
+ escape(record.messageIndex),
+ escape(record.rawRating),
+ escape(record.rating),
+ escape(record.comment ?? ""),
+ escape(record.model ?? ""),
+ escape(record.userId ?? ""),
+ escape(new Date(record.timestamp).toISOString()),
+ ].join(",");
+}
+
+export const CSV_HEADER =
+ "conversationId,messageIndex,rawRating,numericRating,comment,model,userId,submittedAt";
+
+// ── Plugin Definition ─────────────────────────────────────────────────────────
+
+export default createPlugin({
+ name: "user-feedback-collector",
+ version: "1.0.0",
+ description:
+ "Thumbs up/down rating after each AI response with optional comments. Aggregated quality scores per model and time period.",
+ permissions: ["db:readwrite"],
+ settings: {
+ enableAutoPrompt: {
+ type: "boolean",
+ label: "Automatically prompt for feedback after each response",
+ default: true,
+ },
+ feedbackTypes: {
+ type: "select",
+ label: "Rating style",
+ options: ["thumbs", "stars", "both"],
+ default: "thumbs",
+ },
+ requireComment: {
+ type: "boolean",
+ label: "Require a comment with every rating",
+ default: false,
+ },
+ },
+
+ hooks: {
+ "app:init": async (context) => {
+ const { api } = context;
+
+ // ── POST /feedback ──────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "POST",
+ path: "/feedback",
+ auth: true,
+ description: "Submit a rating (and optional comment) for a message",
+ handler: async (req, res) => {
+ const {
+ conversationId,
+ messageIndex,
+ rating: rawRating,
+ comment,
+ model,
+ } = req.body as {
+ conversationId?: string;
+ messageIndex?: number;
+ rating?: unknown;
+ comment?: string;
+ model?: string;
+ };
+
+ if (!conversationId) {
+ res.status(400).json({ error: "conversationId is required" });
+ return;
+ }
+ if (messageIndex === undefined || messageIndex === null) {
+ res.status(400).json({ error: "messageIndex is required" });
+ return;
+ }
+
+ const feedbackType =
+ (api.getConfig("feedbackTypes") as RatingType) ?? "thumbs";
+ const normalized = normaliseRating(rawRating, feedbackType);
+ if (!normalized) {
+ res.status(400).json({
+ error: `Invalid rating for feedback type '${feedbackType}'`,
+ });
+ return;
+ }
+
+ const requireComment =
+ (api.getConfig("requireComment") as boolean) ?? false;
+ if (requireComment && !comment?.trim()) {
+ res.status(400).json({ error: "A comment is required" });
+ return;
+ }
+
+ const record: FeedbackRecord = {
+ conversationId,
+ messageIndex,
+ rating: normalized.numeric,
+ rawRating: normalized.raw,
+ comment: comment?.trim() ?? undefined,
+ model: model ?? undefined,
+ userId: context.userId ?? undefined,
+ timestamp: Date.now(),
+ };
+
+ await api.db.set(
+ buildFeedbackKey(conversationId, messageIndex),
+ record,
+ );
+
+ // Update daily stats
+ const date = todayUtc();
+ const modelKey = model ?? "unknown";
+ const statsKey = buildStatsKey(date, modelKey);
+ const existing = (await api.db.get(
+ statsKey,
+ )) as DailyModelStats | null;
+ const updated = updateStats(
+ existing,
+ modelKey,
+ date,
+ normalized.numeric,
+ );
+ await api.db.set(statsKey, updated);
+
+ res.status(201).json({ ok: true, feedback: record });
+ },
+ });
+
+ // ── GET /feedback ───────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "GET",
+ path: "/feedback",
+ auth: true,
+ description: "List feedback records with optional filters",
+ handler: async (req, res) => {
+ const {
+ conversationId,
+ model,
+ from,
+ to,
+ limit: limitParam,
+ } = req.query as Record;
+ const limit = Math.min(parseInt(limitParam ?? "50", 10) || 50, 200);
+
+ const allKeys = await api.db.keys(FEEDBACK_KEY_PREFIX);
+ const records = (
+ await Promise.all(allKeys.map((k) => api.db.get(k)))
+ ).filter((r): r is FeedbackRecord => r !== null);
+
+ let filtered = records;
+ if (conversationId) {
+ filtered = filtered.filter(
+ (r) => r.conversationId === conversationId,
+ );
+ }
+ if (model) {
+ filtered = filtered.filter((r) => r.model === model);
+ }
+ if (from) {
+ const fromMs = new Date(from).getTime();
+ if (!isNaN(fromMs)) {
+ filtered = filtered.filter((r) => r.timestamp >= fromMs);
+ }
+ }
+ if (to) {
+ const toMs = new Date(to).getTime();
+ if (!isNaN(toMs)) {
+ filtered = filtered.filter((r) => r.timestamp <= toMs);
+ }
+ }
+
+ filtered.sort((a, b) => b.timestamp - a.timestamp);
+ res.status(200).json({
+ feedback: filtered.slice(0, limit),
+ total: filtered.length,
+ });
+ },
+ });
+
+ // ── GET /stats ──────────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "GET",
+ path: "/stats",
+ auth: true,
+ description:
+ "Aggregate quality scores — group by model, day, week, or month",
+ handler: async (req, res) => {
+ const { groupBy = "day", model: filterModel } = req.query as Record<
+ string,
+ string | undefined
+ >;
+
+ const allKeys = await api.db.keys(STATS_KEY_PREFIX);
+ const records = (
+ await Promise.all(allKeys.map((k) => api.db.get(k)))
+ ).filter((r): r is DailyModelStats => r !== null);
+
+ let filtered = records;
+ if (filterModel) {
+ filtered = filtered.filter((r) => r.model === filterModel);
+ }
+
+ // Re-aggregate by the requested grouping
+ const buckets = new Map<
+ string,
+ { ratingSum: number; count: number; model: string }
+ >();
+
+ for (const rec of filtered) {
+ let period: string;
+ if (groupBy === "week") {
+ period = isoWeek(rec.date);
+ } else if (groupBy === "month") {
+ period = yearMonth(rec.date);
+ } else {
+ period = rec.date; // default: day
+ }
+
+ const key = `${period}::${rec.model}`;
+ const existing = buckets.get(key) ?? {
+ ratingSum: 0,
+ count: 0,
+ model: rec.model,
+ };
+ existing.ratingSum += rec.ratingSum;
+ existing.count += rec.count;
+ buckets.set(key, existing);
+ }
+
+ const result: StatsGroupEntry[] = [...buckets.entries()].map(
+ ([key, v]) => ({
+ period: key.split("::")[0]!,
+ model: v.model,
+ avgRating: v.count > 0 ? v.ratingSum / v.count : 0,
+ count: v.count,
+ }),
+ );
+
+ result.sort((a, b) => b.period.localeCompare(a.period));
+ res.status(200).json({ stats: result, groupBy });
+ },
+ });
+
+ // ── GET /export ─────────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "GET",
+ path: "/export",
+ auth: true,
+ description: "Download all feedback as a CSV file",
+ handler: async (_req, res) => {
+ const allKeys = await api.db.keys(FEEDBACK_KEY_PREFIX);
+ const records = (
+ await Promise.all(allKeys.map((k) => api.db.get(k)))
+ ).filter((r): r is FeedbackRecord => r !== null);
+
+ records.sort((a, b) => a.timestamp - b.timestamp);
+
+ const lines = [CSV_HEADER, ...records.map((r) => buildCsvRow(r))];
+ const csv = lines.join("\n");
+
+ // Return as text/csv; handler sets headers via res.status().json() convention.
+ // Well-behaved SDK implementations pass `headers` through — we use json-as-text
+ // for SDK compatibility, wrapping in an object the frontend/SDK can detect.
+ res.status(200).json({
+ _csv: true,
+ filename: `feedback-export-${todayUtc()}.csv`,
+ contentType: "text/csv",
+ data: csv,
+ });
+ },
+ });
+ },
+ },
+
+ filters: {
+ "response:modify": async (_context, response) => {
+ // Signal the frontend to render the feedback widget for this response.
+ return {
+ ...response,
+ _feedbackEnabled: true,
+ };
+ },
+ },
+});
diff --git a/packages/plugins/official/user-feedback-collector/tsconfig.json b/packages/plugins/official/user-feedback-collector/tsconfig.json
new file mode 100644
index 0000000..5628f37
--- /dev/null
+++ b/packages/plugins/official/user-feedback-collector/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": ["ES2022", "DOM"],
+ "outDir": "./dist",
+ "rootDir": "../..",
+ "baseUrl": ".",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "ignoreDeprecations": "6.0",
+ "types": ["node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "__tests__"]
+}
diff --git a/packages/plugins/official/user-feedback-collector/tsconfig.test.json b/packages/plugins/official/user-feedback-collector/tsconfig.test.json
new file mode 100644
index 0000000..68f8f76
--- /dev/null
+++ b/packages/plugins/official/user-feedback-collector/tsconfig.test.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "..",
+ "outDir": "dist-test",
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*", "__tests__/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 546e128..0104670 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -269,7 +269,7 @@ importers:
version: 10.4.24(postcss@8.5.6)
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ version: 29.7.0(@types/node@22.19.11)
jest-environment-jsdom:
specifier: ^30.2.0
version: 30.2.0
@@ -284,7 +284,7 @@ importers:
version: 1.0.7(tailwindcss@3.4.19)
ts-jest:
specifier: ^29.4.6
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -373,10 +373,10 @@ importers:
version: 29.5.14
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ version: 29.7.0
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -392,10 +392,10 @@ importers:
version: 29.5.14
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ version: 29.7.0
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -414,10 +414,10 @@ importers:
version: 25.5.2
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ version: 29.7.0(@types/node@25.5.2)
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -436,10 +436,10 @@ importers:
version: 25.5.2
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ version: 29.7.0(@types/node@25.5.2)
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -458,10 +458,10 @@ importers:
version: 20.19.33
jest:
specifier: ^29.5.0
- version: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ version: 29.7.0(@types/node@20.19.33)
ts-jest:
specifier: ^29.1.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3)
typescript:
specifier: ^5.0.0
version: 5.9.3
@@ -480,14 +480,36 @@ importers:
version: 25.5.2
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ version: 29.7.0(@types/node@25.5.2)
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
+ packages/plugins/official/user-feedback-collector:
+ dependencies:
+ '@agentbase/plugin-sdk':
+ specifier: workspace:*
+ version: link:../..
+ devDependencies:
+ '@types/jest':
+ specifier: ^29.5.0
+ version: 29.5.14
+ '@types/node':
+ specifier: ^20.0.0
+ version: 20.19.33
+ jest:
+ specifier: ^29.5.0
+ version: 29.7.0(@types/node@20.19.33)
+ ts-jest:
+ specifier: ^29.1.0
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3)
+ typescript:
+ specifier: ^5.0.0
+ version: 5.9.3
+
packages/plugins/official/zapier-bridge:
dependencies:
'@agentbase/plugin-sdk':
@@ -502,10 +524,10 @@ importers:
version: 25.5.2
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ version: 29.7.0(@types/node@25.5.2)
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -521,10 +543,10 @@ importers:
version: 29.5.14
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ version: 29.7.0
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -542,10 +564,10 @@ importers:
version: 29.5.14
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ version: 29.7.0
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.3.0
version: 5.9.3
@@ -6364,41 +6386,6 @@ snapshots:
jest-util: 29.7.0
slash: 3.0.0
- '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))':
- dependencies:
- '@jest/console': 29.7.0
- '@jest/reporters': 29.7.0
- '@jest/test-result': 29.7.0
- '@jest/transform': 29.7.0
- '@jest/types': 29.6.3
- '@types/node': 22.19.11
- ansi-escapes: 4.3.2
- chalk: 4.1.2
- ci-info: 3.9.0
- exit: 0.1.2
- graceful-fs: 4.2.11
- jest-changed-files: 29.7.0
- jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
- jest-haste-map: 29.7.0
- jest-message-util: 29.7.0
- jest-regex-util: 29.6.3
- jest-resolve: 29.7.0
- jest-resolve-dependencies: 29.7.0
- jest-runner: 29.7.0
- jest-runtime: 29.7.0
- jest-snapshot: 29.7.0
- jest-util: 29.7.0
- jest-validate: 29.7.0
- jest-watcher: 29.7.0
- micromatch: 4.0.8
- pretty-format: 29.7.0
- slash: 3.0.0
- strip-ansi: 6.0.1
- transitivePeerDependencies:
- - babel-plugin-macros
- - supports-color
- - ts-node
-
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
@@ -8393,13 +8380,13 @@ snapshots:
optionalDependencies:
typescript: 5.7.2
- create-jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
+ create-jest@29.7.0(@types/node@20.19.33):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
- jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ jest-config: 29.7.0(@types/node@20.19.33)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@@ -9615,16 +9602,54 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-cli@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
+ jest-cli@29.7.0:
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
exit: 0.1.2
import-local: 3.2.0
- jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ jest-util: 29.7.0
+ jest-validate: 29.7.0
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - supports-color
+ - ts-node
+
+ jest-cli@29.7.0(@types/node@20.19.33):
+ dependencies:
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ '@jest/test-result': 29.7.0
+ '@jest/types': 29.6.3
+ chalk: 4.1.2
+ create-jest: 29.7.0(@types/node@20.19.33)
+ exit: 0.1.2
+ import-local: 3.2.0
+ jest-config: 29.7.0(@types/node@20.19.33)
+ jest-util: 29.7.0
+ jest-validate: 29.7.0
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - supports-color
+ - ts-node
+
+ jest-cli@29.7.0(@types/node@22.19.11):
+ dependencies:
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ '@jest/test-result': 29.7.0
+ '@jest/types': 29.6.3
+ chalk: 4.1.2
+ create-jest: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ exit: 0.1.2
+ import-local: 3.2.0
+ jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@@ -9653,9 +9678,9 @@ snapshots:
- supports-color
- ts-node
- jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
+ jest-cli@29.7.0(@types/node@25.5.2):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
@@ -9672,38 +9697,26 @@ snapshots:
- supports-color
- ts-node
- jest-config@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
+ jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
dependencies:
- '@babel/core': 7.29.0
- '@jest/test-sequencer': 29.7.0
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ '@jest/test-result': 29.7.0
'@jest/types': 29.6.3
- babel-jest: 29.7.0(@babel/core@7.29.0)
chalk: 4.1.2
- ci-info: 3.9.0
- deepmerge: 4.3.1
- glob: 7.2.3
- graceful-fs: 4.2.11
- jest-circus: 29.7.0
- jest-environment-node: 29.7.0
- jest-get-type: 29.6.3
- jest-regex-util: 29.6.3
- jest-resolve: 29.7.0
- jest-runner: 29.7.0
+ create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ exit: 0.1.2
+ import-local: 3.2.0
+ jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
- micromatch: 4.0.8
- parse-json: 5.2.0
- pretty-format: 29.7.0
- slash: 3.0.0
- strip-json-comments: 3.1.1
- optionalDependencies:
- '@types/node': 20.19.33
- ts-node: 10.9.2(@types/node@20.19.33)(typescript@5.9.3)
+ yargs: 17.7.2
transitivePeerDependencies:
+ - '@types/node'
- babel-plugin-macros
- supports-color
+ - ts-node
- jest-config@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
+ jest-config@29.7.0(@types/node@20.19.33):
dependencies:
'@babel/core': 7.29.0
'@jest/test-sequencer': 29.7.0
@@ -9728,8 +9741,7 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
- '@types/node': 22.19.11
- ts-node: 10.9.2(@types/node@20.19.33)(typescript@5.9.3)
+ '@types/node': 20.19.33
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -10130,12 +10142,36 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
+ jest@29.7.0:
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ '@jest/types': 29.6.3
+ import-local: 3.2.0
+ jest-cli: 29.7.0
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - supports-color
+ - ts-node
+
+ jest@29.7.0(@types/node@20.19.33):
+ dependencies:
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ '@jest/types': 29.6.3
+ import-local: 3.2.0
+ jest-cli: 29.7.0(@types/node@20.19.33)
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - supports-color
+ - ts-node
+
+ jest@29.7.0(@types/node@22.19.11):
+ dependencies:
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
'@jest/types': 29.6.3
import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ jest-cli: 29.7.0(@types/node@22.19.11)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -10154,6 +10190,18 @@ snapshots:
- supports-color
- ts-node
+ jest@29.7.0(@types/node@25.5.2):
+ dependencies:
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ '@jest/types': 29.6.3
+ import-local: 3.2.0
+ jest-cli: 29.7.0(@types/node@25.5.2)
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - supports-color
+ - ts-node
+
jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
@@ -12231,12 +12279,12 @@ snapshots:
ts-interface-checker@0.1.13: {}
- ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3):
+ ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ jest: 29.7.0(@types/node@20.19.33)
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -12271,6 +12319,26 @@ snapshots:
babel-jest: 29.7.0(@babel/core@7.29.0)
jest-util: 30.3.0
+ ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3):
+ dependencies:
+ bs-logger: 0.2.6
+ fast-json-stable-stringify: 2.1.0
+ handlebars: 4.7.8
+ jest: 29.7.0(@types/node@22.19.11)
+ json5: 2.2.3
+ lodash.memoize: 4.1.2
+ make-error: 1.3.6
+ semver: 7.7.4
+ type-fest: 4.41.0
+ typescript: 5.9.3
+ yargs-parser: 21.1.1
+ optionalDependencies:
+ '@babel/core': 7.29.0
+ '@jest/transform': 29.7.0
+ '@jest/types': 30.3.0
+ babel-jest: 29.7.0(@babel/core@7.29.0)
+ jest-util: 30.3.0
+
ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
@@ -12291,6 +12359,46 @@ snapshots:
babel-jest: 29.7.0(@babel/core@7.29.0)
jest-util: 30.3.0
+ ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3):
+ dependencies:
+ bs-logger: 0.2.6
+ fast-json-stable-stringify: 2.1.0
+ handlebars: 4.7.8
+ jest: 29.7.0(@types/node@25.5.2)
+ json5: 2.2.3
+ lodash.memoize: 4.1.2
+ make-error: 1.3.6
+ semver: 7.7.4
+ type-fest: 4.41.0
+ typescript: 5.9.3
+ yargs-parser: 21.1.1
+ optionalDependencies:
+ '@babel/core': 7.29.0
+ '@jest/transform': 29.7.0
+ '@jest/types': 30.3.0
+ babel-jest: 29.7.0(@babel/core@7.29.0)
+ jest-util: 30.3.0
+
+ ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3):
+ dependencies:
+ bs-logger: 0.2.6
+ fast-json-stable-stringify: 2.1.0
+ handlebars: 4.7.8
+ jest: 29.7.0
+ json5: 2.2.3
+ lodash.memoize: 4.1.2
+ make-error: 1.3.6
+ semver: 7.7.4
+ type-fest: 4.41.0
+ typescript: 5.9.3
+ yargs-parser: 21.1.1
+ optionalDependencies:
+ '@babel/core': 7.29.0
+ '@jest/transform': 29.7.0
+ '@jest/types': 30.3.0
+ babel-jest: 29.7.0(@babel/core@7.29.0)
+ jest-util: 30.3.0
+
ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1