@mainahq/core 1.0.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 +1 -1
- package/src/cloud/__tests__/client.test.ts +171 -0
- package/src/cloud/auth.ts +1 -0
- package/src/cloud/client.ts +94 -0
- package/src/cloud/types.ts +48 -0
- package/src/context/engine.ts +72 -5
- package/src/feedback/__tests__/sync.test.ts +63 -1
- package/src/feedback/collector.ts +54 -0
- package/src/feedback/sync.ts +92 -3
- package/src/git/__tests__/git.test.ts +15 -0
- package/src/git/index.ts +25 -0
- package/src/index.ts +12 -1
- package/src/init/__tests__/detect-stack.test.ts +237 -0
- package/src/init/__tests__/init.test.ts +184 -0
- package/src/init/index.ts +443 -74
- package/src/verify/__tests__/detect-filter.test.ts +303 -0
- package/src/verify/detect.ts +162 -25
package/package.json
CHANGED
|
@@ -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
|
package/src/cloud/client.ts
CHANGED
|
@@ -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", {
|
package/src/cloud/types.ts
CHANGED
|
@@ -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 {
|
package/src/context/engine.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|