edsger 0.72.6 → 0.73.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.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Report payload normalization for the direct-SDK write path.
3
+ *
4
+ * The LLM that produces intelligence reports sometimes returns structured
5
+ * arrays (`key_findings`, `recommendations`, …) as bare strings, or as objects
6
+ * keyed under synonyms (`description` instead of `finding`, `recommendation`
7
+ * instead of `action`, …). We canonicalize before writing so every reader — the
8
+ * desktop UI especially — can rely on the shape declared in the migration.
9
+ *
10
+ * This mirrors the normalizer in the MCP edge handler
11
+ * (`supabase/functions/mcp/handlers/intelligence.ts`). The two can't share a
12
+ * single module because the edge function runs on Deno (zod v3, URL imports)
13
+ * while this runs on Node — keep them in sync if either changes.
14
+ */
15
+ /**
16
+ * Normalize every structured array field present in the payload. Fields that
17
+ * are `undefined` are left untouched so partial updates don't overwrite stored
18
+ * values with empty arrays.
19
+ */
20
+ export declare function normalizeReportArrays<T extends Record<string, unknown>>(payload: T): T;
21
+ /**
22
+ * Coerce an LLM-supplied value to a finite number, or null. Handles numbers,
23
+ * numeric strings, and null/garbage uniformly.
24
+ */
25
+ export declare function toNumberOrNull(value: unknown): number | null;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Report payload normalization for the direct-SDK write path.
3
+ *
4
+ * The LLM that produces intelligence reports sometimes returns structured
5
+ * arrays (`key_findings`, `recommendations`, …) as bare strings, or as objects
6
+ * keyed under synonyms (`description` instead of `finding`, `recommendation`
7
+ * instead of `action`, …). We canonicalize before writing so every reader — the
8
+ * desktop UI especially — can rely on the shape declared in the migration.
9
+ *
10
+ * This mirrors the normalizer in the MCP edge handler
11
+ * (`supabase/functions/mcp/handlers/intelligence.ts`). The two can't share a
12
+ * single module because the edge function runs on Deno (zod v3, URL imports)
13
+ * while this runs on Node — keep them in sync if either changes.
14
+ */
15
+ const NORMALIZE_SPECS = {
16
+ key_findings: {
17
+ primary: 'finding',
18
+ aliases: ['description', 'insight', 'text', 'title'],
19
+ defaults: { severity: 'fyi', category: '' },
20
+ },
21
+ recommendations: {
22
+ primary: 'action',
23
+ aliases: ['recommendation', 'description', 'text', 'title'],
24
+ defaults: { priority: 'medium', effort: '', impact: '', rationale: '' },
25
+ },
26
+ competitor_highlights: {
27
+ primary: 'highlight',
28
+ aliases: ['description', 'text', 'title'],
29
+ defaults: { competitor_name: '', sentiment: 'neutral' },
30
+ },
31
+ market_signals: {
32
+ primary: 'signal',
33
+ aliases: ['description', 'text', 'title'],
34
+ defaults: { source: '', relevance: '', url: '' },
35
+ },
36
+ trends: {
37
+ primary: 'trend',
38
+ aliases: ['description', 'text', 'title'],
39
+ defaults: { direction: '', evidence: '' },
40
+ },
41
+ };
42
+ function normalizeItem(item, spec) {
43
+ if (typeof item === 'string') {
44
+ return { [spec.primary]: item, ...spec.defaults };
45
+ }
46
+ if (!item || typeof item !== 'object') {
47
+ return { [spec.primary]: '', ...spec.defaults };
48
+ }
49
+ const obj = { ...item };
50
+ if (typeof obj[spec.primary] !== 'string' || !obj[spec.primary]) {
51
+ for (const alias of spec.aliases) {
52
+ const v = obj[alias];
53
+ if (typeof v === 'string' && v.trim().length > 0) {
54
+ obj[spec.primary] = v;
55
+ break;
56
+ }
57
+ }
58
+ if (typeof obj[spec.primary] !== 'string') {
59
+ obj[spec.primary] = '';
60
+ }
61
+ }
62
+ for (const [key, fallback] of Object.entries(spec.defaults)) {
63
+ if (obj[key] === undefined || obj[key] === null) {
64
+ obj[key] = fallback;
65
+ }
66
+ }
67
+ return obj;
68
+ }
69
+ function normalizeArray(value, spec) {
70
+ if (!Array.isArray(value)) {
71
+ return [];
72
+ }
73
+ return value.map((item) => normalizeItem(item, spec));
74
+ }
75
+ /**
76
+ * Normalize every structured array field present in the payload. Fields that
77
+ * are `undefined` are left untouched so partial updates don't overwrite stored
78
+ * values with empty arrays.
79
+ */
80
+ export function normalizeReportArrays(payload) {
81
+ const result = { ...payload };
82
+ for (const [field, spec] of Object.entries(NORMALIZE_SPECS)) {
83
+ if (result[field] !== undefined) {
84
+ ;
85
+ result[field] = normalizeArray(result[field], spec);
86
+ }
87
+ }
88
+ return result;
89
+ }
90
+ /**
91
+ * Coerce an LLM-supplied value to a finite number, or null. Handles numbers,
92
+ * numeric strings, and null/garbage uniformly.
93
+ */
94
+ export function toNumberOrNull(value) {
95
+ if (typeof value === 'number') {
96
+ return Number.isFinite(value) ? value : null;
97
+ }
98
+ if (typeof value === 'string' && value.trim() !== '') {
99
+ const n = Number(value);
100
+ return Number.isFinite(n) ? n : null;
101
+ }
102
+ return null;
103
+ }
@@ -1,5 +1,6 @@
1
- import { getSupabase, hasSupabaseSession } from '../supabase/client.js';
1
+ import { ensureSupabaseSession, getSupabase, hasSupabaseSession, } from '../supabase/client.js';
2
2
  import { logError, logInfo } from '../utils/logger.js';
3
+ import { normalizeReportArrays, toNumberOrNull, } from './intelligence-normalize.js';
3
4
  import { callMcpEndpoint } from './mcp-client.js';
4
5
  // =============================================================================
5
6
  // MCP response helper
@@ -180,8 +181,41 @@ export async function saveSnapshot(snapshot, verbose) {
180
181
  logInfo(`Saving snapshot for competitor: ${snapshot.competitor_id}`);
181
182
  }
182
183
  try {
183
- const result = await callMcpEndpoint('intelligence/snapshots/save', snapshot);
184
- return parseMcpResponse(result, null);
184
+ // Direct SDK write under the user's RLS session — no MCP fallback.
185
+ // Await the synced session first so the insert carries the user JWT
186
+ // (otherwise it races out as `anon` and trips RLS).
187
+ const ready = await ensureSupabaseSession();
188
+ if (!ready) {
189
+ logError('Failed to save snapshot: no Supabase session (sign in to the desktop app)');
190
+ return null;
191
+ }
192
+ const source = ['ai_analysis', 'manual', 'scheduled'].includes(snapshot.source)
193
+ ? snapshot.source
194
+ : 'ai_analysis';
195
+ const { data, error } = await getSupabase()
196
+ .from('competitor_snapshots')
197
+ .insert({
198
+ competitor_id: snapshot.competitor_id,
199
+ product_id: snapshot.product_id,
200
+ issues: snapshot.issues ?? [],
201
+ pricing: snapshot.pricing ?? {},
202
+ tech_stack: snapshot.tech_stack ?? [],
203
+ app_rating: toNumberOrNull(snapshot.app_rating),
204
+ app_review_count: toNumberOrNull(snapshot.app_review_count),
205
+ app_version: snapshot.app_version ?? null,
206
+ app_last_updated: snapshot.app_last_updated ?? null,
207
+ recent_reviews: snapshot.recent_reviews ?? [],
208
+ social_mentions: snapshot.social_mentions ?? {},
209
+ changes_detected: snapshot.changes_detected ?? [],
210
+ source,
211
+ raw_data: snapshot.raw_data ?? null,
212
+ })
213
+ .select()
214
+ .single();
215
+ if (error) {
216
+ throw new Error(error.message);
217
+ }
218
+ return data ?? null;
185
219
  }
186
220
  catch (error) {
187
221
  logError(`Failed to save snapshot: ${error instanceof Error ? error.message : String(error)}`);
@@ -273,8 +307,54 @@ export async function saveReport(report, verbose) {
273
307
  logInfo(`Saving intelligence report: ${report.title}`);
274
308
  }
275
309
  try {
276
- const result = await callMcpEndpoint('intelligence/reports/save', report);
277
- return parseMcpResponse(result, null);
310
+ // Direct SDK write no MCP fallback. intelligence_reports.created_by is
311
+ // NOT NULL and RLS checks `created_by = auth.uid()`, so we resolve the
312
+ // user from the synced session and canonicalize the LLM arrays first.
313
+ const ready = await ensureSupabaseSession();
314
+ if (!ready) {
315
+ logError('Failed to save report: no Supabase session (sign in to the desktop app)');
316
+ return null;
317
+ }
318
+ const { data: sessionData } = await getSupabase().auth.getSession();
319
+ const userId = sessionData.session?.user?.id;
320
+ if (!userId) {
321
+ logError('Failed to save report: could not resolve user from session');
322
+ return null;
323
+ }
324
+ const normalized = normalizeReportArrays({
325
+ key_findings: report.key_findings ?? [],
326
+ recommendations: report.recommendations ?? [],
327
+ competitor_highlights: report.competitor_highlights ?? [],
328
+ market_signals: report.market_signals ?? [],
329
+ trends: report.trends ?? [],
330
+ });
331
+ const { data, error } = await getSupabase()
332
+ .from('intelligence_reports')
333
+ .insert({
334
+ product_id: report.product_id,
335
+ report_type: report.report_type,
336
+ title: report.title,
337
+ summary: report.summary ?? null,
338
+ full_report: report.full_report ?? null,
339
+ key_findings: normalized.key_findings,
340
+ recommendations: normalized.recommendations,
341
+ competitor_highlights: normalized.competitor_highlights,
342
+ market_signals: normalized.market_signals,
343
+ trends: normalized.trends,
344
+ persona_updates: report.persona_updates ?? null,
345
+ pmf_score: toNumberOrNull(report.pmf_score),
346
+ pmf_signals: report.pmf_signals ?? null,
347
+ guidance: report.guidance ?? null,
348
+ status: report.status ?? 'completed',
349
+ snapshot_ids: report.snapshot_ids ?? [],
350
+ created_by: userId,
351
+ })
352
+ .select()
353
+ .single();
354
+ if (error) {
355
+ throw new Error(error.message);
356
+ }
357
+ return data ?? null;
278
358
  }
279
359
  catch (error) {
280
360
  logError(`Failed to save report: ${error instanceof Error ? error.message : String(error)}`);
@@ -286,11 +366,34 @@ export async function updateReport(reportId, updates, verbose) {
286
366
  logInfo(`Updating report: ${reportId}`);
287
367
  }
288
368
  try {
289
- const result = await callMcpEndpoint('intelligence/reports/update', {
290
- report_id: reportId,
291
- ...updates,
292
- });
293
- return parseMcpResponse(result, null);
369
+ // Direct SDK write no MCP fallback.
370
+ const ready = await ensureSupabaseSession();
371
+ if (!ready) {
372
+ logError('Failed to update report: no Supabase session (sign in to the desktop app)');
373
+ return null;
374
+ }
375
+ // Only send keys the caller provided so a partial update doesn't clobber
376
+ // stored columns. Numeric/array fields are coerced/canonicalized.
377
+ const payload = {};
378
+ for (const [key, value] of Object.entries(updates)) {
379
+ if (value !== undefined) {
380
+ payload[key] = value;
381
+ }
382
+ }
383
+ if ('pmf_score' in payload) {
384
+ payload.pmf_score = toNumberOrNull(payload.pmf_score);
385
+ }
386
+ const normalized = normalizeReportArrays(payload);
387
+ const { data, error } = await getSupabase()
388
+ .from('intelligence_reports')
389
+ .update(normalized)
390
+ .eq('id', reportId)
391
+ .select()
392
+ .single();
393
+ if (error) {
394
+ throw new Error(error.message);
395
+ }
396
+ return data ?? null;
294
397
  }
295
398
  catch (error) {
296
399
  logError(`Failed to update report: ${error instanceof Error ? error.message : String(error)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.72.6",
3
+ "version": "0.73.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
@@ -54,8 +54,8 @@
54
54
  "commander": "^12.0.0",
55
55
  "cosmiconfig": "^9.0.0",
56
56
  "dotenv": "^16.4.5",
57
- "edsger-contract": "0.9.2",
58
- "edsger-tools": "0.9.2",
57
+ "edsger-contract": "0.10.0",
58
+ "edsger-tools": "0.10.0",
59
59
  "gray-matter": "^4.0.3",
60
60
  "zod": "^4.0.0"
61
61
  },