@mainahq/core 0.6.0 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mainahq/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Maina core engines — Context, Prompt, and Verify for verification-first development",
@@ -493,4 +493,172 @@ describe("createCloudClient", () => {
493
493
  expect(result.error).toBe("Job not found");
494
494
  }
495
495
  });
496
+
497
+ // ── postFeedbackBatch ─────────────────────────────────────────────────
498
+
499
+ test("postFeedbackBatch sends events in snake_case", async () => {
500
+ mockFetch.mockImplementation(() =>
501
+ Promise.resolve(jsonResponse({ data: { received: 3 } })),
502
+ );
503
+
504
+ const client = setupClient();
505
+ const result = await client.postFeedbackBatch([
506
+ {
507
+ promptHash: "hash-1",
508
+ command: "commit",
509
+ accepted: true,
510
+ timestamp: "2026-01-01T00:00:00Z",
511
+ },
512
+ {
513
+ promptHash: "hash-2",
514
+ command: "review",
515
+ accepted: false,
516
+ context: "user edited",
517
+ diffHash: "diff-abc",
518
+ },
519
+ {
520
+ promptHash: "hash-3",
521
+ command: "fix",
522
+ accepted: true,
523
+ },
524
+ ]);
525
+
526
+ expect(result.ok).toBe(true);
527
+ if (result.ok) {
528
+ expect(result.value.received).toBe(3);
529
+ }
530
+
531
+ const call = mockFetch.mock.calls[0] as unknown[];
532
+ const url = call[0] as string;
533
+ expect(url).toBe("https://api.test.maina.dev/feedback/batch");
534
+
535
+ const requestInit = call[1] as RequestInit;
536
+ expect(requestInit.method).toBe("POST");
537
+
538
+ const body = JSON.parse(requestInit.body as string);
539
+ expect(body.events).toHaveLength(3);
540
+ // Verify snake_case mapping
541
+ expect(body.events[0].prompt_hash).toBe("hash-1");
542
+ expect(body.events[0].command).toBe("commit");
543
+ expect(body.events[0].accepted).toBe(true);
544
+ expect(body.events[0].timestamp).toBe("2026-01-01T00:00:00Z");
545
+ expect(body.events[1].diff_hash).toBe("diff-abc");
546
+ expect(body.events[1].context).toBe("user edited");
547
+ });
548
+
549
+ test("postFeedbackBatch returns error on failure", async () => {
550
+ mockFetch.mockImplementation(() =>
551
+ Promise.resolve(jsonResponse({ error: "Unauthorized" }, 401)),
552
+ );
553
+
554
+ const client = setupClient();
555
+ const result = await client.postFeedbackBatch([]);
556
+
557
+ expect(result.ok).toBe(false);
558
+ if (!result.ok) {
559
+ expect(result.error).toBe("Unauthorized");
560
+ }
561
+ });
562
+
563
+ // ── getFeedbackImprovements ──────────────────────────────────────────
564
+
565
+ test("getFeedbackImprovements returns improvements with camelCase mapping", async () => {
566
+ mockFetch.mockImplementation(() =>
567
+ Promise.resolve(
568
+ jsonResponse({
569
+ data: {
570
+ improvements: [
571
+ {
572
+ command: "commit",
573
+ prompt_hash: "hash-abc",
574
+ samples: 50,
575
+ accept_rate: 0.85,
576
+ status: "healthy",
577
+ },
578
+ {
579
+ command: "review",
580
+ prompt_hash: "hash-def",
581
+ samples: 30,
582
+ accept_rate: 0.4,
583
+ status: "needs_improvement",
584
+ },
585
+ ],
586
+ team_totals: {
587
+ total_events: 200,
588
+ accept_rate: 0.72,
589
+ },
590
+ },
591
+ }),
592
+ ),
593
+ );
594
+
595
+ const client = setupClient();
596
+ const result = await client.getFeedbackImprovements();
597
+
598
+ expect(result.ok).toBe(true);
599
+ if (result.ok) {
600
+ expect(result.value.improvements).toHaveLength(2);
601
+ expect(result.value.improvements[0]?.command).toBe("commit");
602
+ expect(result.value.improvements[0]?.promptHash).toBe("hash-abc");
603
+ expect(result.value.improvements[0]?.samples).toBe(50);
604
+ expect(result.value.improvements[0]?.acceptRate).toBe(0.85);
605
+ expect(result.value.improvements[0]?.status).toBe("healthy");
606
+ expect(result.value.improvements[1]?.status).toBe("needs_improvement");
607
+ expect(result.value.teamTotals.totalEvents).toBe(200);
608
+ expect(result.value.teamTotals.acceptRate).toBe(0.72);
609
+ }
610
+
611
+ const call = mockFetch.mock.calls[0] as unknown[];
612
+ const url = call[0] as string;
613
+ expect(url).toBe("https://api.test.maina.dev/feedback/improvements");
614
+ });
615
+
616
+ test("getFeedbackImprovements handles camelCase response", async () => {
617
+ mockFetch.mockImplementation(() =>
618
+ Promise.resolve(
619
+ jsonResponse({
620
+ data: {
621
+ improvements: [
622
+ {
623
+ command: "fix",
624
+ promptHash: "hash-ghi",
625
+ samples: 10,
626
+ acceptRate: 0.95,
627
+ status: "excellent",
628
+ },
629
+ ],
630
+ teamTotals: {
631
+ totalEvents: 100,
632
+ acceptRate: 0.9,
633
+ },
634
+ },
635
+ }),
636
+ ),
637
+ );
638
+
639
+ const client = setupClient();
640
+ const result = await client.getFeedbackImprovements();
641
+
642
+ expect(result.ok).toBe(true);
643
+ if (result.ok) {
644
+ expect(result.value.improvements[0]?.promptHash).toBe("hash-ghi");
645
+ expect(result.value.improvements[0]?.acceptRate).toBe(0.95);
646
+ expect(result.value.teamTotals.totalEvents).toBe(100);
647
+ expect(result.value.teamTotals.acceptRate).toBe(0.9);
648
+ }
649
+ });
650
+
651
+ test("getFeedbackImprovements returns error on failure", async () => {
652
+ mockFetch.mockImplementation(() =>
653
+ Promise.resolve(jsonResponse({ error: "Forbidden" }, 403)),
654
+ );
655
+
656
+ const client = setupClient();
657
+ const result = await client.getFeedbackImprovements();
658
+
659
+ expect(result.ok).toBe(false);
660
+ if (!result.ok) {
661
+ expect(result.error).toBe("Forbidden");
662
+ }
663
+ });
496
664
  });
@@ -10,6 +10,9 @@ import type {
10
10
  ApiResponse,
11
11
  CloudConfig,
12
12
  CloudFeedbackPayload,
13
+ CloudPromptImprovement,
14
+ FeedbackEvent,
15
+ FeedbackImprovementsResponse,
13
16
  PromptRecord,
14
17
  SubmitVerifyPayload,
15
18
  TeamInfo,
@@ -74,6 +77,16 @@ export interface CloudClient {
74
77
  payload: CloudFeedbackPayload,
75
78
  ): Promise<Result<{ recorded: boolean }, string>>;
76
79
 
80
+ /** Upload a batch of feedback events to the cloud. */
81
+ postFeedbackBatch(
82
+ events: FeedbackEvent[],
83
+ ): Promise<Result<{ received: number }, string>>;
84
+
85
+ /** Fetch feedback-based improvement suggestions from the cloud. */
86
+ getFeedbackImprovements(): Promise<
87
+ Result<FeedbackImprovementsResponse, string>
88
+ >;
89
+
77
90
  /** Submit a diff for cloud verification. */
78
91
  submitVerify(
79
92
  payload: SubmitVerifyPayload,
@@ -201,6 +214,47 @@ export function createCloudClient(config: CloudConfig): CloudClient {
201
214
  postFeedback: (payload) =>
202
215
  request<{ recorded: boolean }>("POST", "/feedback", payload),
203
216
 
217
+ postFeedbackBatch: async (events) => {
218
+ // Map camelCase → snake_case for cloud API
219
+ const snakeEvents = events.map((e) => ({
220
+ prompt_hash: e.promptHash,
221
+ command: e.command,
222
+ accepted: e.accepted,
223
+ context: e.context,
224
+ diff_hash: e.diffHash,
225
+ timestamp: e.timestamp,
226
+ }));
227
+ return request<{ received: number }>("POST", "/feedback/batch", {
228
+ events: snakeEvents,
229
+ });
230
+ },
231
+
232
+ getFeedbackImprovements: async () => {
233
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
234
+ const result = await request<any>("GET", "/feedback/improvements");
235
+ if (!result.ok) return result;
236
+ const d = result.value;
237
+ const rawImprovements = d.improvements ?? [];
238
+ const improvements: CloudPromptImprovement[] = rawImprovements.map(
239
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
240
+ (i: any) => ({
241
+ command: i.command,
242
+ promptHash: i.promptHash ?? i.prompt_hash,
243
+ samples: i.samples,
244
+ acceptRate: i.acceptRate ?? i.accept_rate,
245
+ status: i.status,
246
+ }),
247
+ );
248
+ const totals = d.teamTotals ?? d.team_totals ?? {};
249
+ return ok({
250
+ improvements,
251
+ teamTotals: {
252
+ totalEvents: totals.totalEvents ?? totals.total_events ?? 0,
253
+ acceptRate: totals.acceptRate ?? totals.accept_rate ?? 0,
254
+ },
255
+ });
256
+ },
257
+
204
258
  submitVerify: async (payload) => {
205
259
  // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
206
260
  const result = await request<any>("POST", "/verify", {
@@ -156,3 +156,45 @@ export interface CloudFeedbackPayload {
156
156
  /** Optional context about the feedback. */
157
157
  context?: string;
158
158
  }
159
+
160
+ // ── Feedback Batch (learn --cloud) ─────────────────────────────────────────
161
+
162
+ export interface FeedbackEvent {
163
+ /** Prompt hash the feedback refers to. */
164
+ promptHash: string;
165
+ /** Command that generated the output. */
166
+ command: string;
167
+ /** Whether the user accepted the output. */
168
+ accepted: boolean;
169
+ /** Optional context about the feedback. */
170
+ context?: string;
171
+ /** Optional diff hash for traceability. */
172
+ diffHash?: string;
173
+ /** ISO-8601 timestamp. */
174
+ timestamp?: string;
175
+ }
176
+
177
+ export interface FeedbackBatchPayload {
178
+ /** Array of feedback events to upload. */
179
+ events: FeedbackEvent[];
180
+ }
181
+
182
+ export interface CloudPromptImprovement {
183
+ /** Command the improvement applies to. */
184
+ command: string;
185
+ /** Prompt hash that was analysed. */
186
+ promptHash: string;
187
+ /** Number of feedback samples analysed. */
188
+ samples: number;
189
+ /** Accept rate (0–1). */
190
+ acceptRate: number;
191
+ /** Health status based on accept rate. */
192
+ status: "needs_improvement" | "healthy" | "excellent";
193
+ }
194
+
195
+ export interface FeedbackImprovementsResponse {
196
+ /** Per-command improvement assessments. */
197
+ improvements: CloudPromptImprovement[];
198
+ /** Aggregated team-wide feedback totals. */
199
+ teamTotals: { totalEvents: number; acceptRate: number };
200
+ }
@@ -0,0 +1,104 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { recordOutcome } from "../../prompts/engine";
5
+ import { exportFeedbackForCloud } from "../sync";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = join(
11
+ import.meta.dir,
12
+ `tmp-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`,
13
+ );
14
+ mkdirSync(tmpDir, { recursive: true });
15
+ });
16
+
17
+ afterEach(() => {
18
+ try {
19
+ const { rmSync } = require("node:fs");
20
+ rmSync(tmpDir, { recursive: true, force: true });
21
+ } catch {
22
+ // ignore
23
+ }
24
+ });
25
+
26
+ describe("exportFeedbackForCloud", () => {
27
+ test("returns empty array when no feedback exists", () => {
28
+ const events = exportFeedbackForCloud(tmpDir);
29
+ expect(events).toEqual([]);
30
+ });
31
+
32
+ test("exports accepted feedback events", () => {
33
+ recordOutcome(tmpDir, "hash-abc", {
34
+ accepted: true,
35
+ command: "commit",
36
+ });
37
+
38
+ const events = exportFeedbackForCloud(tmpDir);
39
+
40
+ expect(events).toHaveLength(1);
41
+ expect(events[0]?.promptHash).toBe("hash-abc");
42
+ expect(events[0]?.command).toBe("commit");
43
+ expect(events[0]?.accepted).toBe(true);
44
+ expect(events[0]?.timestamp).toBeDefined();
45
+ });
46
+
47
+ test("exports rejected feedback events", () => {
48
+ recordOutcome(tmpDir, "hash-def", {
49
+ accepted: false,
50
+ command: "review",
51
+ });
52
+
53
+ const events = exportFeedbackForCloud(tmpDir);
54
+
55
+ expect(events).toHaveLength(1);
56
+ expect(events[0]?.accepted).toBe(false);
57
+ expect(events[0]?.command).toBe("review");
58
+ });
59
+
60
+ test("exports context when present", () => {
61
+ recordOutcome(tmpDir, "hash-ctx", {
62
+ accepted: true,
63
+ command: "commit",
64
+ context: "user edited the message",
65
+ });
66
+
67
+ const events = exportFeedbackForCloud(tmpDir);
68
+
69
+ expect(events).toHaveLength(1);
70
+ expect(events[0]?.context).toBe("user edited the message");
71
+ });
72
+
73
+ test("omits context when not present", () => {
74
+ recordOutcome(tmpDir, "hash-no-ctx", {
75
+ accepted: true,
76
+ command: "commit",
77
+ });
78
+
79
+ const events = exportFeedbackForCloud(tmpDir);
80
+
81
+ expect(events).toHaveLength(1);
82
+ expect(events[0]?.context).toBeUndefined();
83
+ });
84
+
85
+ test("exports multiple events in chronological order", () => {
86
+ recordOutcome(tmpDir, "hash-1", { accepted: true, command: "commit" });
87
+ recordOutcome(tmpDir, "hash-2", { accepted: false, command: "review" });
88
+ recordOutcome(tmpDir, "hash-3", { accepted: true, command: "fix" });
89
+
90
+ const events = exportFeedbackForCloud(tmpDir);
91
+
92
+ expect(events).toHaveLength(3);
93
+ expect(events[0]?.promptHash).toBe("hash-1");
94
+ expect(events[1]?.promptHash).toBe("hash-2");
95
+ expect(events[2]?.promptHash).toBe("hash-3");
96
+ });
97
+
98
+ test("returns empty array on invalid db path", () => {
99
+ const events = exportFeedbackForCloud(
100
+ "/nonexistent/path/that/does/not/exist",
101
+ );
102
+ expect(events).toEqual([]);
103
+ });
104
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Feedback sync — exports local feedback records for cloud upload.
3
+ *
4
+ * Reads from the local SQLite feedback.db and maps records to the
5
+ * cloud-compatible FeedbackEvent format for batch upload.
6
+ */
7
+
8
+ import type { FeedbackEvent } from "../cloud/types";
9
+ import { getFeedbackDb } from "../db/index";
10
+
11
+ /** Raw row shape from the feedback table. */
12
+ interface FeedbackRow {
13
+ prompt_hash: string;
14
+ command: string;
15
+ accepted: number;
16
+ context: string | null;
17
+ created_at: string;
18
+ }
19
+
20
+ /**
21
+ * Export all local feedback records in the cloud-compatible format.
22
+ *
23
+ * Reads from the feedback table in the SQLite database at `mainaDir/feedback.db`
24
+ * and maps each row to a `FeedbackEvent` object ready for batch upload.
25
+ */
26
+ export function exportFeedbackForCloud(mainaDir: string): FeedbackEvent[] {
27
+ const dbResult = getFeedbackDb(mainaDir);
28
+ if (!dbResult.ok) {
29
+ return [];
30
+ }
31
+
32
+ const { db } = dbResult.value;
33
+
34
+ const rows = db
35
+ .query(
36
+ "SELECT prompt_hash, command, accepted, context, created_at FROM feedback ORDER BY created_at ASC",
37
+ )
38
+ .all() as FeedbackRow[];
39
+
40
+ return rows.map((row) => {
41
+ const event: FeedbackEvent = {
42
+ promptHash: row.prompt_hash,
43
+ command: row.command,
44
+ accepted: row.accepted === 1,
45
+ timestamp: row.created_at,
46
+ };
47
+
48
+ if (row.context) {
49
+ event.context = row.context;
50
+ }
51
+
52
+ return event;
53
+ });
54
+ }
package/src/index.ts CHANGED
@@ -64,7 +64,11 @@ export type {
64
64
  ApiResponse,
65
65
  CloudConfig,
66
66
  CloudFeedbackPayload,
67
+ CloudPromptImprovement,
67
68
  DeviceCodeResponse,
69
+ FeedbackBatchPayload,
70
+ FeedbackEvent,
71
+ FeedbackImprovementsResponse,
68
72
  PromptRecord,
69
73
  SubmitVerifyPayload,
70
74
  TeamInfo,
@@ -168,6 +172,7 @@ export {
168
172
  type RulePreference,
169
173
  savePreferences,
170
174
  } from "./feedback/preferences";
175
+ export { exportFeedbackForCloud } from "./feedback/sync";
171
176
  export {
172
177
  analyzeWorkflowTrace,
173
178
  type PromptImprovement,
@@ -0,0 +1 @@
1
+ {"packages": []}