@mainahq/core 0.7.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@mainahq/core",
3
- "version": "0.7.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Maina core engines — Context, Prompt, and Verify for verification-first development",
7
- "homepage": "https://beeeku.github.io/maina/",
7
+ "homepage": "https://mainahq.com/",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/beeeku/maina.git",
10
+ "url": "https://github.com/mainahq/maina.git",
11
11
  "directory": "packages/core"
12
12
  },
13
13
  "engines": {
@@ -661,4 +661,175 @@ describe("createCloudClient", () => {
661
661
  expect(result.error).toBe("Forbidden");
662
662
  }
663
663
  });
664
+
665
+ // ── postEpisodicEntries ─────────────────────────────────────────────────
666
+
667
+ test("postEpisodicEntries sends entries in snake_case", async () => {
668
+ mockFetch.mockImplementation(() =>
669
+ Promise.resolve(jsonResponse({ data: { received: 2 } })),
670
+ );
671
+
672
+ const client = setupClient();
673
+ const result = await client.postEpisodicEntries([
674
+ {
675
+ repo: "acme/app",
676
+ entryType: "review",
677
+ title: "Accepted review",
678
+ summary: "Fixed auth middleware",
679
+ relevanceScore: 0.9,
680
+ },
681
+ {
682
+ repo: "acme/app",
683
+ entryType: "commit",
684
+ title: "Commit summary",
685
+ summary: "Added logging",
686
+ },
687
+ ]);
688
+
689
+ expect(result.ok).toBe(true);
690
+ if (result.ok) {
691
+ expect(result.value.received).toBe(2);
692
+ }
693
+
694
+ const call = mockFetch.mock.calls[0] as unknown[];
695
+ const url = call[0] as string;
696
+ expect(url).toBe("https://api.test.maina.dev/context/episodic");
697
+
698
+ const requestInit = call[1] as RequestInit;
699
+ expect(requestInit.method).toBe("POST");
700
+
701
+ const body = JSON.parse(requestInit.body as string);
702
+ expect(body.entries).toHaveLength(2);
703
+ // Verify snake_case mapping
704
+ expect(body.entries[0].entry_type).toBe("review");
705
+ expect(body.entries[0].relevance_score).toBe(0.9);
706
+ expect(body.entries[0].repo).toBe("acme/app");
707
+ expect(body.entries[1].entry_type).toBe("commit");
708
+ });
709
+
710
+ test("postEpisodicEntries returns error on failure", async () => {
711
+ mockFetch.mockImplementation(() =>
712
+ Promise.resolve(jsonResponse({ error: "Unauthorized" }, 401)),
713
+ );
714
+
715
+ const client = setupClient();
716
+ const result = await client.postEpisodicEntries([]);
717
+
718
+ expect(result.ok).toBe(false);
719
+ if (!result.ok) {
720
+ expect(result.error).toBe("Unauthorized");
721
+ }
722
+ });
723
+
724
+ // ── getEpisodicEntries ──────────────────────────────────────────────────
725
+
726
+ test("getEpisodicEntries returns mapped entries", async () => {
727
+ mockFetch.mockImplementation(() =>
728
+ Promise.resolve(
729
+ jsonResponse({
730
+ data: {
731
+ entries: [
732
+ {
733
+ id: "ep_001",
734
+ repo: "acme/app",
735
+ entry_type: "review",
736
+ title: "Auth review",
737
+ summary: "Fixed auth flow",
738
+ relevance_score: 0.85,
739
+ member_id: "mem_abc",
740
+ decay_factor: 0.95,
741
+ created_at: "2026-04-01T00:00:00Z",
742
+ accessed_at: "2026-04-02T00:00:00Z",
743
+ },
744
+ ],
745
+ },
746
+ }),
747
+ ),
748
+ );
749
+
750
+ const client = setupClient();
751
+ const result = await client.getEpisodicEntries("acme/app");
752
+
753
+ expect(result.ok).toBe(true);
754
+ if (result.ok) {
755
+ expect(result.value).toHaveLength(1);
756
+ const entry = result.value[0] as (typeof result.value)[number];
757
+ expect(entry.id).toBe("ep_001");
758
+ expect(entry.entryType).toBe("review");
759
+ expect(entry.relevanceScore).toBe(0.85);
760
+ expect(entry.memberId).toBe("mem_abc");
761
+ expect(entry.decayFactor).toBe(0.95);
762
+ expect(entry.createdAt).toBe("2026-04-01T00:00:00Z");
763
+ expect(entry.accessedAt).toBe("2026-04-02T00:00:00Z");
764
+ }
765
+
766
+ const call = mockFetch.mock.calls[0] as unknown[];
767
+ const url = call[0] as string;
768
+ expect(url).toBe(
769
+ "https://api.test.maina.dev/context/episodic?repo=acme%2Fapp",
770
+ );
771
+ });
772
+
773
+ test("getEpisodicEntries handles camelCase response", async () => {
774
+ mockFetch.mockImplementation(() =>
775
+ Promise.resolve(
776
+ jsonResponse({
777
+ data: {
778
+ entries: [
779
+ {
780
+ id: "ep_002",
781
+ repo: "acme/app",
782
+ entryType: "commit",
783
+ title: "Commit entry",
784
+ summary: "Added tests",
785
+ relevanceScore: 0.7,
786
+ memberId: "mem_xyz",
787
+ decayFactor: 0.8,
788
+ createdAt: "2026-04-03T00:00:00Z",
789
+ accessedAt: "2026-04-03T12:00:00Z",
790
+ },
791
+ ],
792
+ },
793
+ }),
794
+ ),
795
+ );
796
+
797
+ const client = setupClient();
798
+ const result = await client.getEpisodicEntries("acme/app");
799
+
800
+ expect(result.ok).toBe(true);
801
+ if (result.ok) {
802
+ expect(result.value[0]?.entryType).toBe("commit");
803
+ expect(result.value[0]?.relevanceScore).toBe(0.7);
804
+ expect(result.value[0]?.memberId).toBe("mem_xyz");
805
+ }
806
+ });
807
+
808
+ test("getEpisodicEntries returns empty array when no entries", async () => {
809
+ mockFetch.mockImplementation(() =>
810
+ Promise.resolve(jsonResponse({ data: { entries: [] } })),
811
+ );
812
+
813
+ const client = setupClient();
814
+ const result = await client.getEpisodicEntries("acme/app");
815
+
816
+ expect(result.ok).toBe(true);
817
+ if (result.ok) {
818
+ expect(result.value).toEqual([]);
819
+ }
820
+ });
821
+
822
+ test("getEpisodicEntries returns error on failure", async () => {
823
+ mockFetch.mockImplementation(() =>
824
+ Promise.resolve(jsonResponse({ error: "Forbidden" }, 403)),
825
+ );
826
+
827
+ const client = setupClient();
828
+ const result = await client.getEpisodicEntries("acme/app");
829
+
830
+ expect(result.ok).toBe(false);
831
+ if (!result.ok) {
832
+ expect(result.error).toBe("Forbidden");
833
+ }
834
+ });
664
835
  });
package/src/cloud/auth.ts CHANGED
@@ -229,6 +229,7 @@ export async function pollForToken(
229
229
  accessToken: (d.accessToken ?? d.access_token) as string,
230
230
  refreshToken: (d.refreshToken ?? d.refresh_token) as string | undefined,
231
231
  expiresIn: (d.expiresIn ?? d.expires_in ?? 0) as number,
232
+ firstTime: (d.firstTime ?? d.first_time ?? false) as boolean,
232
233
  });
233
234
  } catch (e) {
234
235
  // Network errors during polling are transient — keep trying
@@ -9,10 +9,14 @@ import type { Result } from "../db/index";
9
9
  import type {
10
10
  ApiResponse,
11
11
  CloudConfig,
12
+ CloudEpisodicEntry,
12
13
  CloudFeedbackPayload,
13
14
  CloudPromptImprovement,
15
+ EpisodicCloudEntry,
14
16
  FeedbackEvent,
15
17
  FeedbackImprovementsResponse,
18
+ ProfileUpdatePayload,
19
+ ProfileUpdateResponse,
16
20
  PromptRecord,
17
21
  SubmitVerifyPayload,
18
22
  TeamInfo,
@@ -54,6 +58,11 @@ export interface CloudClient {
54
58
  /** Check API availability. */
55
59
  health(): Promise<Result<{ status: string }, string>>;
56
60
 
61
+ /** Update user profile (email, name) during onboarding. */
62
+ updateProfile(
63
+ payload: ProfileUpdatePayload,
64
+ ): Promise<Result<ProfileUpdateResponse, string>>;
65
+
57
66
  /** Download team prompts. */
58
67
  getPrompts(): Promise<Result<PromptRecord[], string>>;
59
68
 
@@ -87,6 +96,27 @@ export interface CloudClient {
87
96
  Result<FeedbackImprovementsResponse, string>
88
97
  >;
89
98
 
99
+ /** Post workflow stats to cloud analytics. */
100
+ postWorkflowStats(stats: {
101
+ totalCommits: number;
102
+ totalVerifyTimeMs: number;
103
+ avgVerifyTimeMs: number;
104
+ totalFindings: number;
105
+ totalContextTokens: number;
106
+ cacheHitRate: number;
107
+ passRate: number;
108
+ }): Promise<Result<{ received: boolean }, string>>;
109
+
110
+ /** Upload episodic entries to the cloud for team sharing. */
111
+ postEpisodicEntries(
112
+ entries: EpisodicCloudEntry[],
113
+ ): Promise<Result<{ received: number }, string>>;
114
+
115
+ /** Fetch team's episodic entries for a repo from the cloud. */
116
+ getEpisodicEntries(
117
+ repo: string,
118
+ ): Promise<Result<CloudEpisodicEntry[], string>>;
119
+
90
120
  /** Submit a diff for cloud verification. */
91
121
  submitVerify(
92
122
  payload: SubmitVerifyPayload,
@@ -200,6 +230,18 @@ export function createCloudClient(config: CloudConfig): CloudClient {
200
230
  return {
201
231
  health: () => request<{ status: string }>("GET", "/health"),
202
232
 
233
+ updateProfile: async (payload) => {
234
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
235
+ const result = await request<any>("PATCH", "/auth/profile", payload);
236
+ if (!result.ok) return result;
237
+ const d = result.value;
238
+ return ok({
239
+ email: d.email,
240
+ name: d.name,
241
+ isOnboarded: d.isOnboarded ?? d.is_onboarded ?? false,
242
+ });
243
+ },
244
+
203
245
  getPrompts: () => request<PromptRecord[]>("GET", "/prompts"),
204
246
 
205
247
  putPrompts: (prompts) => request<void>("PUT", "/prompts", { prompts }),
@@ -255,6 +297,58 @@ export function createCloudClient(config: CloudConfig): CloudClient {
255
297
  });
256
298
  },
257
299
 
300
+ postEpisodicEntries: async (entries) => {
301
+ // Map camelCase → snake_case for cloud API
302
+ const snakeEntries = entries.map((e) => ({
303
+ repo: e.repo,
304
+ entry_type: e.entryType,
305
+ title: e.title,
306
+ summary: e.summary,
307
+ relevance_score: e.relevanceScore,
308
+ }));
309
+ return request<{ received: number }>("POST", "/context/episodic", {
310
+ entries: snakeEntries,
311
+ });
312
+ },
313
+
314
+ getEpisodicEntries: async (repo) => {
315
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
316
+ const result = await request<any>(
317
+ "GET",
318
+ `/context/episodic?repo=${encodeURIComponent(repo)}`,
319
+ );
320
+ if (!result.ok) return result;
321
+ const d = result.value;
322
+ const rawEntries = d.entries ?? [];
323
+ const mapped: CloudEpisodicEntry[] = rawEntries.map(
324
+ // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
325
+ (e: any) => ({
326
+ id: e.id,
327
+ repo: e.repo,
328
+ entryType: e.entryType ?? e.entry_type,
329
+ title: e.title,
330
+ summary: e.summary,
331
+ relevanceScore: e.relevanceScore ?? e.relevance_score ?? 1.0,
332
+ memberId: e.memberId ?? e.member_id,
333
+ decayFactor: e.decayFactor ?? e.decay_factor ?? 1.0,
334
+ createdAt: e.createdAt ?? e.created_at,
335
+ accessedAt: e.accessedAt ?? e.accessed_at,
336
+ }),
337
+ );
338
+ return ok(mapped);
339
+ },
340
+
341
+ postWorkflowStats: (stats) =>
342
+ request<{ received: boolean }>("POST", "/feedback/stats", {
343
+ total_commits: stats.totalCommits,
344
+ total_verify_time_ms: stats.totalVerifyTimeMs,
345
+ avg_verify_time_ms: stats.avgVerifyTimeMs,
346
+ total_findings: stats.totalFindings,
347
+ total_context_tokens: stats.totalContextTokens,
348
+ cache_hit_rate: stats.cacheHitRate,
349
+ pass_rate: stats.passRate,
350
+ }),
351
+
258
352
  submitVerify: async (payload) => {
259
353
  // biome-ignore lint/suspicious/noExplicitAny: snake_case API mapping
260
354
  const result = await request<any>("POST", "/verify", {
@@ -77,6 +77,26 @@ export interface TokenResponse {
77
77
  refreshToken?: string;
78
78
  /** Seconds until the access token expires. */
79
79
  expiresIn: number;
80
+ /** True when the user has no email set (first-time signup). */
81
+ firstTime?: boolean;
82
+ }
83
+
84
+ // ── Profile ────────────────────────────────────────────────────────────────
85
+
86
+ export interface ProfileUpdatePayload {
87
+ /** User email address. */
88
+ email: string;
89
+ /** User display name. */
90
+ name: string;
91
+ }
92
+
93
+ export interface ProfileUpdateResponse {
94
+ /** Updated email. */
95
+ email: string;
96
+ /** Updated name. */
97
+ name: string;
98
+ /** Whether onboarding is complete. */
99
+ isOnboarded: boolean;
80
100
  }
81
101
 
82
102
  // ── API Envelope ────────────────────────────────────────────────────────────
@@ -157,6 +177,34 @@ export interface CloudFeedbackPayload {
157
177
  context?: string;
158
178
  }
159
179
 
180
+ // ── Episodic Context (team sharing) ───────────────────────────────────────
181
+
182
+ export interface EpisodicCloudEntry {
183
+ /** Repository identifier (e.g. "owner/repo"). */
184
+ repo: string;
185
+ /** Type of episodic entry (e.g. "review", "commit", "session"). */
186
+ entryType: string;
187
+ /** Short title for the entry. */
188
+ title: string;
189
+ /** Compressed summary of the entry. */
190
+ summary: string;
191
+ /** Relevance score (0-1). Default: 1.0. */
192
+ relevanceScore?: number;
193
+ }
194
+
195
+ export interface CloudEpisodicEntry extends EpisodicCloudEntry {
196
+ /** Unique identifier (ep_<uuid>). */
197
+ id: string;
198
+ /** Team member who created the entry. */
199
+ memberId: string;
200
+ /** Ebbinghaus decay factor (0-1). */
201
+ decayFactor: number;
202
+ /** ISO-8601 creation timestamp. */
203
+ createdAt: string;
204
+ /** ISO-8601 last access timestamp. */
205
+ accessedAt: string;
206
+ }
207
+
160
208
  // ── Feedback Batch (learn --cloud) ─────────────────────────────────────────
161
209
 
162
210
  export interface FeedbackEvent {
@@ -1,6 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
- import { getChangedFiles, getStagedFiles } from "../git/index";
4
+ import { loadAuthConfig } from "../cloud/auth";
5
+ import { createCloudClient } from "../cloud/client";
6
+ import type { CloudEpisodicEntry } from "../cloud/types";
7
+ import { getChangedFiles, getRepoSlug, getStagedFiles } from "../git/index";
4
8
  import {
5
9
  assembleBudget,
6
10
  type BudgetAllocation,
@@ -172,10 +176,51 @@ async function buildWorkingLayer(
172
176
  }
173
177
  }
174
178
 
179
+ /**
180
+ * Deduplicate cloud entries against local entries by hashing title+summary.
181
+ * Returns only the cloud entries not already present locally.
182
+ */
183
+ function deduplicateCloudEntries(
184
+ localEntries: import("./episodic").EpisodicEntry[],
185
+ cloudEntries: CloudEpisodicEntry[],
186
+ ): import("./episodic").EpisodicEntry[] {
187
+ const localHashes = new Set(
188
+ localEntries.map((e) => {
189
+ const key = `${e.summary}::${e.content}`;
190
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
191
+ }),
192
+ );
193
+
194
+ return cloudEntries
195
+ .filter((ce) => {
196
+ const key = `${ce.title}::${ce.summary}`;
197
+ const hash = createHash("sha256").update(key).digest("hex").slice(0, 16);
198
+ return !localHashes.has(hash);
199
+ })
200
+ .map((ce) => ({
201
+ id: ce.id,
202
+ content: ce.summary,
203
+ summary: ce.title,
204
+ relevance: (ce.relevanceScore ?? 1.0) * ce.decayFactor,
205
+ accessCount: 0,
206
+ createdAt: ce.createdAt,
207
+ lastAccessedAt: ce.accessedAt,
208
+ type: ce.entryType,
209
+ }));
210
+ }
211
+
212
+ const CLOUD_URL = process.env.MAINA_CLOUD_URL ?? "https://api.mainahq.com";
213
+
175
214
  /**
176
215
  * Build the episodic layer content. Never throws.
216
+ * When the user is logged into the cloud, also fetches team episodic entries
217
+ * and merges them (deduplicated by title+summary hash) with local entries.
177
218
  */
178
- function buildEpisodicLayer(mainaDir: string, filter?: string[]): LayerContent {
219
+ async function buildEpisodicLayer(
220
+ mainaDir: string,
221
+ repoRoot: string,
222
+ filter?: string[],
223
+ ): Promise<LayerContent> {
179
224
  try {
180
225
  decayAllEntries(mainaDir);
181
226
 
@@ -194,6 +239,30 @@ function buildEpisodicLayer(mainaDir: string, filter?: string[]): LayerContent {
194
239
  entries = getEntries(mainaDir);
195
240
  }
196
241
 
242
+ // Merge cloud episodic entries if logged in
243
+ try {
244
+ const auth = loadAuthConfig();
245
+ if (auth.ok && auth.value.accessToken) {
246
+ const client = createCloudClient({
247
+ baseUrl: CLOUD_URL,
248
+ token: auth.value.accessToken,
249
+ });
250
+ const repo = await getRepoSlug(repoRoot);
251
+ const cloudResult = await client.getEpisodicEntries(repo);
252
+ if (cloudResult.ok && cloudResult.value.length > 0) {
253
+ const uniqueCloud = deduplicateCloudEntries(
254
+ entries,
255
+ cloudResult.value,
256
+ );
257
+ entries = [...entries, ...uniqueCloud];
258
+ // Re-sort by relevance descending after merging
259
+ entries.sort((a, b) => b.relevance - a.relevance);
260
+ }
261
+ }
262
+ } catch {
263
+ // Cloud fetch failure is silent — local entries are still available
264
+ }
265
+
197
266
  const text = assembleEpisodicText(entries);
198
267
  return {
199
268
  name: "episodic",
@@ -291,9 +360,7 @@ export async function assembleContext(
291
360
  const episodicFilter = Array.isArray(needs.episodic)
292
361
  ? needs.episodic
293
362
  : undefined;
294
- layerPromises.push(
295
- Promise.resolve(buildEpisodicLayer(mainaDir, episodicFilter)),
296
- );
363
+ layerPromises.push(buildEpisodicLayer(mainaDir, repoRoot, episodicFilter));
297
364
  }
298
365
 
299
366
  // Retrieval layer — auto-generates search query from staged/changed files if not provided
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { addEntry } from "../../context/episodic";
4
5
  import { recordOutcome } from "../../prompts/engine";
5
- import { exportFeedbackForCloud } from "../sync";
6
+ import { exportEpisodicForCloud, exportFeedbackForCloud } from "../sync";
6
7
 
7
8
  let tmpDir: string;
8
9
 
@@ -102,3 +103,64 @@ describe("exportFeedbackForCloud", () => {
102
103
  expect(events).toEqual([]);
103
104
  });
104
105
  });
106
+
107
+ describe("exportEpisodicForCloud", () => {
108
+ test("returns empty array when no episodic entries exist", () => {
109
+ const entries = exportEpisodicForCloud(tmpDir, "acme/app");
110
+ expect(entries).toEqual([]);
111
+ });
112
+
113
+ test("exports episodic entries in cloud format", () => {
114
+ addEntry(tmpDir, {
115
+ content: "Fixed authentication middleware to use JWT",
116
+ summary: "Auth fix",
117
+ type: "review",
118
+ });
119
+
120
+ const entries = exportEpisodicForCloud(tmpDir, "acme/app");
121
+
122
+ expect(entries).toHaveLength(1);
123
+ expect(entries[0]?.repo).toBe("acme/app");
124
+ expect(entries[0]?.entryType).toBe("review");
125
+ expect(entries[0]?.title).toBe("Auth fix");
126
+ expect(entries[0]?.summary).toBe(
127
+ "Fixed authentication middleware to use JWT",
128
+ );
129
+ expect(entries[0]?.relevanceScore).toBe(1.0);
130
+ });
131
+
132
+ test("exports multiple entries with correct types", () => {
133
+ addEntry(tmpDir, {
134
+ content: "Added rate limiting",
135
+ summary: "Rate limit",
136
+ type: "commit",
137
+ });
138
+ addEntry(tmpDir, {
139
+ content: "Code review feedback on error handling",
140
+ summary: "Error handling review",
141
+ type: "review",
142
+ });
143
+
144
+ const entries = exportEpisodicForCloud(tmpDir, "org/repo");
145
+
146
+ expect(entries).toHaveLength(2);
147
+ expect(entries[0]?.entryType).toBe("commit");
148
+ expect(entries[1]?.entryType).toBe("review");
149
+ expect(entries[0]?.repo).toBe("org/repo");
150
+ expect(entries[1]?.repo).toBe("org/repo");
151
+ });
152
+
153
+ test("uses type as title fallback when summary is empty", () => {
154
+ addEntry(tmpDir, {
155
+ content: "Some content",
156
+ summary: "",
157
+ type: "session",
158
+ });
159
+
160
+ const entries = exportEpisodicForCloud(tmpDir, "acme/app");
161
+
162
+ expect(entries).toHaveLength(1);
163
+ // When summary is empty string, title falls back to type
164
+ expect(entries[0]?.title).toBe("session");
165
+ });
166
+ });
@@ -1,8 +1,13 @@
1
1
  import { hashContent } from "../cache/keys";
2
+ import { loadAuthConfig } from "../cloud/auth";
3
+ import { createCloudClient } from "../cloud/client";
2
4
  import { getFeedbackDb } from "../db/index";
5
+ import { getRepoSlug } from "../git/index";
3
6
  import { recordOutcome } from "../prompts/engine";
4
7
  import { compressReview, storeCompressedReview } from "./compress";
5
8
 
9
+ const CLOUD_URL = process.env.MAINA_CLOUD_URL ?? "https://api.mainahq.com";
10
+
6
11
  export interface FeedbackRecord {
7
12
  promptHash: string;
8
13
  task: string;
@@ -88,6 +93,34 @@ export function recordFeedbackWithCompression(
88
93
  });
89
94
  if (compressed) {
90
95
  storeCompressedReview(mainaDir, compressed, record.task);
96
+
97
+ // Auto-sync episodic entry to cloud (fire-and-forget)
98
+ queueMicrotask(async () => {
99
+ try {
100
+ const auth = loadAuthConfig();
101
+ if (auth.ok && auth.value.accessToken) {
102
+ const client = createCloudClient({
103
+ baseUrl: CLOUD_URL,
104
+ token: auth.value.accessToken,
105
+ });
106
+ const repo = await getRepoSlug();
107
+ const title =
108
+ record.task === "review"
109
+ ? "Accepted review"
110
+ : `Accepted ${record.task} review`;
111
+ await client.postEpisodicEntries([
112
+ {
113
+ repo,
114
+ entryType: record.task,
115
+ title,
116
+ summary: compressed,
117
+ },
118
+ ]);
119
+ }
120
+ } catch {
121
+ // Cloud sync failure is silent
122
+ }
123
+ });
91
124
  }
92
125
  }
93
126
  }
@@ -123,6 +156,27 @@ export function recordFeedbackAsync(
123
156
  } catch {
124
157
  // Never throw from background feedback
125
158
  }
159
+
160
+ // Auto-sync to cloud if logged in (fire-and-forget, never blocks)
161
+ try {
162
+ const auth = loadAuthConfig();
163
+ if (auth.ok && auth.value.accessToken) {
164
+ const client = createCloudClient({
165
+ baseUrl: CLOUD_URL,
166
+ token: auth.value.accessToken,
167
+ });
168
+ client.postFeedbackBatch([
169
+ {
170
+ promptHash: record.promptHash,
171
+ command: record.task,
172
+ accepted: record.accepted,
173
+ context: record.modification,
174
+ },
175
+ ]);
176
+ }
177
+ } catch {
178
+ // Cloud sync failure is silent
179
+ }
126
180
  });
127
181
  }
128
182