wogiflow 1.5.12 → 1.5.13

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,1071 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow - Community Knowledge Module
5
+ *
6
+ * Anonymous, privacy-first knowledge sharing across WogiFlow users.
7
+ * Handles: push/pull community knowledge, PII stripping, data collection,
8
+ * anonymous ID management, suggestion submission, and local caching.
9
+ *
10
+ * Uses ONLY Node.js built-in modules — no external dependencies.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const https = require('https');
16
+ const http = require('http');
17
+ const crypto = require('crypto');
18
+ const os = require('os');
19
+
20
+ const { getConfig, PATHS, safeJsonParse } = require('./flow-utils');
21
+
22
+ // ~/.wogiflow/ directory for user-level state (persists across projects)
23
+ const WOGIFLOW_HOME = path.join(os.homedir(), '.wogiflow');
24
+ const ANON_ID_PATH = path.join(WOGIFLOW_HOME, 'anon-id');
25
+ const COMMUNITY_CACHE_PATH = path.join(WOGIFLOW_HOME, 'community-cache.json');
26
+ const PENDING_SUGGESTIONS_PATH = path.join(WOGIFLOW_HOME, 'pending-suggestions.json');
27
+ const LAST_PUSH_PATH = path.join(WOGIFLOW_HOME, 'last-community-push');
28
+ const CONSENT_PATH = path.join(WOGIFLOW_HOME, 'consent-acknowledged');
29
+
30
+ const REQUEST_TIMEOUT_MS = 5000;
31
+
32
+ // ──────────────────────────────────────────────
33
+ // Anonymous ID
34
+ // ──────────────────────────────────────────────
35
+
36
+ /**
37
+ * Get or create anonymous UUID for this user.
38
+ * Stored in ~/.wogiflow/anon-id (persists across projects).
39
+ * Never regenerated once created.
40
+ * @returns {string} UUID v4
41
+ */
42
+ function getOrCreateAnonId() {
43
+ try {
44
+ // Ensure ~/.wogiflow/ exists
45
+ if (!fs.existsSync(WOGIFLOW_HOME)) {
46
+ fs.mkdirSync(WOGIFLOW_HOME, { recursive: true });
47
+ }
48
+
49
+ // Reuse existing ID
50
+ if (fs.existsSync(ANON_ID_PATH)) {
51
+ const existing = fs.readFileSync(ANON_ID_PATH, 'utf-8').trim();
52
+ if (existing && existing.length >= 32) {
53
+ return existing;
54
+ }
55
+ }
56
+
57
+ // Generate new UUID v4
58
+ const id = crypto.randomUUID();
59
+ fs.writeFileSync(ANON_ID_PATH, id, 'utf-8');
60
+ return id;
61
+ } catch (err) {
62
+ // Fallback: in-memory only (won't persist)
63
+ if (process.env.DEBUG) {
64
+ console.error(`[flow-community] Failed to manage anon ID: ${err.message}`);
65
+ }
66
+ return crypto.randomUUID();
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Check if consent has been acknowledged.
72
+ * @returns {boolean}
73
+ */
74
+ function isConsentAcknowledged() {
75
+ try {
76
+ return fs.existsSync(CONSENT_PATH);
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Mark consent as acknowledged.
84
+ */
85
+ function acknowledgeConsent() {
86
+ try {
87
+ if (!fs.existsSync(WOGIFLOW_HOME)) {
88
+ fs.mkdirSync(WOGIFLOW_HOME, { recursive: true });
89
+ }
90
+ fs.writeFileSync(CONSENT_PATH, new Date().toISOString(), 'utf-8');
91
+ } catch (err) {
92
+ if (process.env.DEBUG) {
93
+ console.error(`[flow-community] Failed to store consent: ${err.message}`);
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Get the consent message to display to users.
100
+ * @returns {string}
101
+ */
102
+ function getConsentMessage() {
103
+ return `
104
+ Community Knowledge Sharing
105
+
106
+ WogiFlow can share anonymous learnings with other users:
107
+ - Model intelligence (which models work best for what)
108
+ - Error recovery strategies
109
+ - Universal coding patterns
110
+
111
+ What is NEVER shared:
112
+ - Your code, file paths, or project names
113
+ - Task descriptions or acceptance criteria
114
+ - Personal information of any kind
115
+
116
+ Per-category controls available in config.json → community.categories
117
+ Enabling community in config.json IS your consent.
118
+ `.trim();
119
+ }
120
+
121
+ // ──────────────────────────────────────────────
122
+ // PII Stripping
123
+ // ──────────────────────────────────────────────
124
+
125
+ /**
126
+ * Strip PII from any data before it leaves the machine.
127
+ *
128
+ * Replaces:
129
+ * - Absolute file paths → [PATH]
130
+ * - Project name → [PROJECT]
131
+ * - Email addresses → [EMAIL]
132
+ * - Git usernames → [USER]
133
+ * - Home directory paths → [PATH]
134
+ *
135
+ * @param {*} data - Any data structure (string, object, array)
136
+ * @param {Object} config - WogiFlow config (needs projectName)
137
+ * @returns {*} Sanitized data
138
+ */
139
+ function stripPII(data, config) {
140
+ if (data === null || data === undefined) return data;
141
+
142
+ const projectName = config?.projectName || '';
143
+ const homeDir = os.homedir();
144
+
145
+ // Get git user info for stripping
146
+ let gitUser = '';
147
+ let gitEmail = '';
148
+ try {
149
+ const { execFileSync } = require('child_process');
150
+ gitUser = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8', timeout: 2000 }).trim();
151
+ gitEmail = execFileSync('git', ['config', 'user.email'], { encoding: 'utf-8', timeout: 2000 }).trim();
152
+ } catch {
153
+ // Git not available or no config — that's fine
154
+ }
155
+
156
+ function stripString(str) {
157
+ if (typeof str !== 'string') return str;
158
+
159
+ let result = str;
160
+
161
+ // Replace absolute paths (Unix and Windows)
162
+ result = result.replace(/(?:\/(?:Users|home|var|tmp|opt|etc|usr)\/[^\s,;:'")\]}>]+)/g, '[PATH]');
163
+ result = result.replace(/(?:[A-Z]:\\[^\s,;:'")\]}>]+)/gi, '[PATH]');
164
+
165
+ // Replace home directory references
166
+ if (homeDir) {
167
+ result = result.replace(new RegExp(escapeRegex(homeDir), 'g'), '[PATH]');
168
+ }
169
+
170
+ // Replace project name (case-insensitive, word boundary)
171
+ if (projectName && projectName.length > 2) {
172
+ result = result.replace(new RegExp(`\\b${escapeRegex(projectName)}\\b`, 'gi'), '[PROJECT]');
173
+ }
174
+
175
+ // Replace email patterns
176
+ result = result.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]');
177
+
178
+ // Replace git user/email
179
+ if (gitUser && gitUser.length > 1) {
180
+ result = result.replace(new RegExp(escapeRegex(gitUser), 'g'), '[USER]');
181
+ }
182
+ if (gitEmail && gitEmail.length > 3) {
183
+ result = result.replace(new RegExp(escapeRegex(gitEmail), 'g'), '[EMAIL]');
184
+ }
185
+
186
+ return result;
187
+ }
188
+
189
+ function stripRecursive(obj) {
190
+ if (typeof obj === 'string') return stripString(obj);
191
+ if (Array.isArray(obj)) return obj.map(stripRecursive);
192
+ if (typeof obj === 'object' && obj !== null) {
193
+ const result = {};
194
+ for (const key of Object.keys(obj)) {
195
+ result[key] = stripRecursive(obj[key]);
196
+ }
197
+ return result;
198
+ }
199
+ return obj;
200
+ }
201
+
202
+ return stripRecursive(data);
203
+ }
204
+
205
+ /**
206
+ * Escape special regex characters in a string.
207
+ * @param {string} str
208
+ * @returns {string}
209
+ */
210
+ function escapeRegex(str) {
211
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
212
+ }
213
+
214
+ // ──────────────────────────────────────────────
215
+ // Data Collection
216
+ // ──────────────────────────────────────────────
217
+
218
+ /**
219
+ * Collect shareable data from local WogiFlow state.
220
+ * Respects per-category toggles in config.community.categories.
221
+ * Returns anonymized, PII-stripped payload ready for push.
222
+ *
223
+ * @param {Object} config - WogiFlow config
224
+ * @returns {Object} Anonymized payload
225
+ */
226
+ function collectShareableData(config) {
227
+ const community = config.community || {};
228
+ const categories = community.categories || {};
229
+ const payload = {
230
+ anonId: getOrCreateAnonId(),
231
+ wogiflowVersion: getWogiFlowVersion(),
232
+ timestamp: new Date().toISOString(),
233
+ data: {}
234
+ };
235
+
236
+ // Model Intelligence
237
+ if (categories.modelIntelligence !== false) {
238
+ payload.data.modelIntelligence = collectModelIntelligence();
239
+ }
240
+
241
+ // Error Recovery
242
+ if (categories.errorRecovery !== false) {
243
+ payload.data.errorRecovery = collectErrorRecovery();
244
+ }
245
+
246
+ // Pattern Convergence
247
+ if (categories.patternConvergence !== false) {
248
+ payload.data.patternConvergence = collectPatternConvergence();
249
+ }
250
+
251
+ // Session Statistics
252
+ if (categories.sessionStatistics !== false) {
253
+ payload.data.sessionStatistics = collectSessionStatistics();
254
+ }
255
+
256
+ // Skill Learnings
257
+ if (categories.skillLearnings !== false) {
258
+ payload.data.skillLearnings = collectSkillLearnings();
259
+ }
260
+
261
+ // Strip PII from entire payload
262
+ return stripPII(payload, config);
263
+ }
264
+
265
+ /**
266
+ * Get WogiFlow version from package.json.
267
+ * @returns {string}
268
+ */
269
+ function getWogiFlowVersion() {
270
+ try {
271
+ const pkgPath = path.join(__dirname, '..', 'package.json');
272
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
273
+ return pkg.version || 'unknown';
274
+ } catch {
275
+ return 'unknown';
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Collect model intelligence from model-adapters.
281
+ * @returns {Array}
282
+ */
283
+ function collectModelIntelligence() {
284
+ const items = [];
285
+ try {
286
+ const adaptersDir = PATHS.modelAdapters;
287
+ if (!fs.existsSync(adaptersDir)) return items;
288
+
289
+ const files = fs.readdirSync(adaptersDir).filter(f => f.endsWith('.md'));
290
+ for (const file of files.slice(0, 10)) {
291
+ try {
292
+ const content = fs.readFileSync(path.join(adaptersDir, file), 'utf-8');
293
+ // Extract model name from filename (e.g., "claude-sonnet-4.md" → "claude-sonnet-4")
294
+ const modelName = file.replace(/\.md$/, '');
295
+
296
+ // Extract strengths/weaknesses/adjustments (look for markdown sections)
297
+ const strengths = extractSection(content, 'strength');
298
+ const weaknesses = extractSection(content, 'weakness');
299
+ const adjustments = extractSection(content, 'adjustment');
300
+
301
+ if (strengths || weaknesses || adjustments) {
302
+ items.push({
303
+ model: modelName,
304
+ strengths: strengths || null,
305
+ weaknesses: weaknesses || null,
306
+ adjustments: adjustments || null
307
+ });
308
+ }
309
+ } catch {
310
+ // Skip unreadable files
311
+ }
312
+ }
313
+ } catch {
314
+ // Model adapters dir may not exist
315
+ }
316
+ return items;
317
+ }
318
+
319
+ /**
320
+ * Extract a section from markdown content by keyword.
321
+ * @param {string} content
322
+ * @param {string} keyword
323
+ * @returns {string|null}
324
+ */
325
+ function extractSection(content, keyword) {
326
+ const regex = new RegExp(`##?\\s*${keyword}[s]?\\b[^\\n]*\\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
327
+ const match = content.match(regex);
328
+ if (match && match[1]) {
329
+ const text = match[1].trim();
330
+ // Truncate to 500 chars to limit payload size
331
+ return text.length > 500 ? text.slice(0, 500) + '...' : text;
332
+ }
333
+ return null;
334
+ }
335
+
336
+ /**
337
+ * Collect error recovery strategies.
338
+ * @returns {Array}
339
+ */
340
+ function collectErrorRecovery() {
341
+ const items = [];
342
+ try {
343
+ // Check adaptive-learning.json
344
+ const adaptivePath = path.join(PATHS.state, 'adaptive-learning.json');
345
+ if (fs.existsSync(adaptivePath)) {
346
+ const data = safeJsonParse(adaptivePath, {});
347
+ const strategies = data.strategies || data.errorStrategies || [];
348
+ for (const strategy of (Array.isArray(strategies) ? strategies : []).slice(0, 20)) {
349
+ if (strategy.category && strategy.strategy) {
350
+ items.push({
351
+ category: strategy.category,
352
+ strategy: strategy.strategy,
353
+ successRate: strategy.successRate || null
354
+ });
355
+ }
356
+ }
357
+ }
358
+
359
+ // Check failure-learnings directory
360
+ const failurePath = path.join(PATHS.workflow, 'failure-learnings');
361
+ if (fs.existsSync(failurePath)) {
362
+ const files = fs.readdirSync(failurePath).filter(f => f.endsWith('.json')).slice(0, 10);
363
+ for (const file of files) {
364
+ try {
365
+ const data = safeJsonParse(path.join(failurePath, file), null);
366
+ if (data && data.errorType && data.resolution) {
367
+ items.push({
368
+ category: data.errorType,
369
+ strategy: data.resolution,
370
+ successRate: data.successRate || null
371
+ });
372
+ }
373
+ } catch {
374
+ // Skip
375
+ }
376
+ }
377
+ }
378
+ } catch {
379
+ // Non-critical
380
+ }
381
+ return items;
382
+ }
383
+
384
+ /**
385
+ * Collect universal pattern convergence data.
386
+ * @returns {Array}
387
+ */
388
+ function collectPatternConvergence() {
389
+ const items = [];
390
+ try {
391
+ // From feedback-patterns.md — extract universal patterns (not project-specific)
392
+ const patternsPath = PATHS.feedbackPatterns;
393
+ if (fs.existsSync(patternsPath)) {
394
+ const content = fs.readFileSync(patternsPath, 'utf-8');
395
+ // Parse markdown table rows: | date | pattern-name | description | count | status |
396
+ const rows = content.match(/\|[^|\n]+\|[^|\n]+\|[^|\n]+\|[^|\n]+\|[^|\n]+\|/g) || [];
397
+ for (const row of rows.slice(0, 20)) {
398
+ const cols = row.split('|').map(c => c.trim()).filter(Boolean);
399
+ if (cols.length >= 4 && cols[1] && cols[2]) {
400
+ // Only include patterns with 2+ occurrences (universal signals)
401
+ const count = parseInt(cols[3], 10);
402
+ if (count >= 2) {
403
+ items.push({
404
+ pattern: cols[1],
405
+ description: cols[2],
406
+ occurrences: count
407
+ });
408
+ }
409
+ }
410
+ }
411
+ }
412
+ } catch {
413
+ // Non-critical
414
+ }
415
+ return items;
416
+ }
417
+
418
+ /**
419
+ * Collect aggregated session statistics (no individual data).
420
+ * @returns {Object}
421
+ */
422
+ function collectSessionStatistics() {
423
+ const stats = {};
424
+ try {
425
+ const metricsPath = PATHS.commandMetrics;
426
+ if (fs.existsSync(metricsPath)) {
427
+ const data = safeJsonParse(metricsPath, {});
428
+ // Only share aggregated counts, not individual commands
429
+ stats.totalCommands = data.totalCommands || 0;
430
+ stats.topCommands = {};
431
+ if (data.commands && typeof data.commands === 'object') {
432
+ // Top 5 most-used command names (no arguments or details)
433
+ const sorted = Object.entries(data.commands)
434
+ .map(([cmd, info]) => [cmd, typeof info === 'number' ? info : (info.count || 0)])
435
+ .sort((a, b) => b[1] - a[1])
436
+ .slice(0, 5);
437
+ for (const [cmd, count] of sorted) {
438
+ stats.topCommands[cmd] = count;
439
+ }
440
+ }
441
+ }
442
+
443
+ // Model usage stats
444
+ const modelStatsPath = PATHS.modelStats;
445
+ if (fs.existsSync(modelStatsPath)) {
446
+ const data = safeJsonParse(modelStatsPath, {});
447
+ if (data.models && typeof data.models === 'object') {
448
+ stats.modelUsage = {};
449
+ for (const [model, info] of Object.entries(data.models)) {
450
+ stats.modelUsage[model] = typeof info === 'number' ? info : (info.sessions || info.count || 0);
451
+ }
452
+ }
453
+ }
454
+ } catch {
455
+ // Non-critical
456
+ }
457
+ return stats;
458
+ }
459
+
460
+ /**
461
+ * Collect skill learnings from skill knowledge directories.
462
+ * @returns {Array}
463
+ */
464
+ function collectSkillLearnings() {
465
+ const items = [];
466
+ try {
467
+ const skillsDir = PATHS.skills;
468
+ if (!fs.existsSync(skillsDir)) return items;
469
+
470
+ const skillNames = fs.readdirSync(skillsDir).filter(d => {
471
+ try {
472
+ return fs.statSync(path.join(skillsDir, d)).isDirectory();
473
+ } catch {
474
+ return false;
475
+ }
476
+ });
477
+
478
+ for (const skillName of skillNames.slice(0, 10)) {
479
+ const knowledgeDir = path.join(skillsDir, skillName, 'knowledge');
480
+ if (!fs.existsSync(knowledgeDir)) continue;
481
+
482
+ const knowledgeFiles = fs.readdirSync(knowledgeDir).filter(f => f.endsWith('.md')).slice(0, 5);
483
+ for (const file of knowledgeFiles) {
484
+ try {
485
+ const content = fs.readFileSync(path.join(knowledgeDir, file), 'utf-8');
486
+ // Truncate to 300 chars
487
+ const truncated = content.length > 300 ? content.slice(0, 300) + '...' : content;
488
+ items.push({
489
+ skill: skillName,
490
+ type: file.replace(/\.md$/, ''),
491
+ content: truncated
492
+ });
493
+ } catch {
494
+ // Skip
495
+ }
496
+ }
497
+ }
498
+ } catch {
499
+ // Non-critical
500
+ }
501
+ return items;
502
+ }
503
+
504
+ // ──────────────────────────────────────────────
505
+ // HTTP Helpers
506
+ // ──────────────────────────────────────────────
507
+
508
+ /**
509
+ * Make an HTTPS request with timeout. Fire-and-forget pattern.
510
+ * @param {string} method - HTTP method
511
+ * @param {string} urlStr - Full URL
512
+ * @param {Object|null} body - JSON body (null for GET)
513
+ * @param {number} timeoutMs - Timeout in milliseconds
514
+ * @returns {Promise<{statusCode: number, body: string}|null>}
515
+ */
516
+ function httpRequest(method, urlStr, body = null, timeoutMs = REQUEST_TIMEOUT_MS) {
517
+ return new Promise((resolve) => {
518
+ try {
519
+ const url = new URL(urlStr);
520
+ const isHttps = url.protocol === 'https:';
521
+ const transport = isHttps ? https : http;
522
+
523
+ const options = {
524
+ hostname: url.hostname,
525
+ port: url.port || (isHttps ? 443 : 80),
526
+ path: url.pathname + url.search,
527
+ method,
528
+ headers: {
529
+ 'User-Agent': `WogiFlow/${getWogiFlowVersion()}`,
530
+ 'Content-Type': 'application/json',
531
+ 'Accept': 'application/json'
532
+ },
533
+ timeout: timeoutMs
534
+ };
535
+
536
+ const req = transport.request(options, (res) => {
537
+ let data = '';
538
+ res.on('data', (chunk) => { data += chunk; });
539
+ res.on('end', () => {
540
+ resolve({ statusCode: res.statusCode, body: data });
541
+ });
542
+ });
543
+
544
+ req.on('error', () => resolve(null));
545
+ req.on('timeout', () => {
546
+ req.destroy();
547
+ resolve(null);
548
+ });
549
+
550
+ if (body) {
551
+ req.write(JSON.stringify(body));
552
+ }
553
+ req.end();
554
+ } catch {
555
+ resolve(null);
556
+ }
557
+ });
558
+ }
559
+
560
+ // ──────────────────────────────────────────────
561
+ // Push / Pull
562
+ // ──────────────────────────────────────────────
563
+
564
+ /**
565
+ * Push community data to server. Fire-and-forget with timeout.
566
+ * @param {Object} payload - Anonymized, PII-stripped payload
567
+ * @param {Object} config - WogiFlow config
568
+ * @returns {Promise<boolean>} true if push succeeded
569
+ */
570
+ async function pushToServer(payload, config) {
571
+ const community = config.community || {};
572
+ const serverUrl = community.serverUrl || 'https://api.wogiflow.com';
573
+
574
+ try {
575
+ const result = await httpRequest('POST', `${serverUrl}/api/community/contribute`, payload);
576
+
577
+ if (result && result.statusCode >= 200 && result.statusCode < 300) {
578
+ // Update last push timestamp
579
+ try {
580
+ if (!fs.existsSync(WOGIFLOW_HOME)) {
581
+ fs.mkdirSync(WOGIFLOW_HOME, { recursive: true });
582
+ }
583
+ fs.writeFileSync(LAST_PUSH_PATH, new Date().toISOString(), 'utf-8');
584
+ } catch {
585
+ // Non-critical
586
+ }
587
+ return true;
588
+ }
589
+
590
+ return false;
591
+ } catch {
592
+ return false;
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Pull community knowledge from server.
598
+ * Uses cache if fresh (< cacheTtlHours).
599
+ * @param {Object} config - WogiFlow config
600
+ * @returns {Promise<Object|null>} Community knowledge or null
601
+ */
602
+ async function pullFromServer(config) {
603
+ const community = config.community || {};
604
+ const serverUrl = community.serverUrl || 'https://api.wogiflow.com';
605
+ const cacheTtlHours = community.cacheTtlHours || 24;
606
+
607
+ // Check cache first
608
+ const cached = loadCommunityCache();
609
+ if (cached && cached._cachedAt) {
610
+ const cacheAge = Date.now() - new Date(cached._cachedAt).getTime();
611
+ const cacheTtlMs = cacheTtlHours * 60 * 60 * 1000;
612
+ if (cacheAge < cacheTtlMs) {
613
+ return cached;
614
+ }
615
+ }
616
+
617
+ // Determine lastSync timestamp
618
+ let lastSync = '1970-01-01T00:00:00.000Z';
619
+ if (cached && cached._cachedAt) {
620
+ lastSync = cached._cachedAt;
621
+ }
622
+
623
+ try {
624
+ const encodedSince = encodeURIComponent(lastSync);
625
+ const result = await httpRequest('GET', `${serverUrl}/api/community/knowledge?since=${encodedSince}`);
626
+
627
+ if (result && result.statusCode >= 200 && result.statusCode < 300) {
628
+ try {
629
+ const knowledge = JSON.parse(result.body);
630
+ knowledge._cachedAt = new Date().toISOString();
631
+ saveCommunityCache(knowledge);
632
+ return knowledge;
633
+ } catch {
634
+ return cached || null;
635
+ }
636
+ }
637
+
638
+ // Server unreachable — use stale cache
639
+ return cached || null;
640
+ } catch {
641
+ return cached || null;
642
+ }
643
+ }
644
+
645
+ // ──────────────────────────────────────────────
646
+ // Suggestions
647
+ // ──────────────────────────────────────────────
648
+
649
+ /**
650
+ * Submit a suggestion to the community server.
651
+ * If offline, queues to pending-suggestions.json.
652
+ *
653
+ * @param {string} text - Suggestion text
654
+ * @param {string} type - idea|bug|improvement (default: idea)
655
+ * @param {Object} config - WogiFlow config
656
+ * @returns {Promise<boolean>} true if submitted (or queued)
657
+ */
658
+ async function submitSuggestion(text, type, config) {
659
+ if (!text || !text.trim()) return false;
660
+
661
+ const community = config.community || {};
662
+ const serverUrl = community.serverUrl || 'https://api.wogiflow.com';
663
+ const validTypes = ['idea', 'bug', 'improvement'];
664
+ const suggestionType = validTypes.includes(type) ? type : 'idea';
665
+
666
+ const suggestion = {
667
+ anonId: getOrCreateAnonId(),
668
+ type: suggestionType,
669
+ content: text.trim(),
670
+ wogiflowVersion: getWogiFlowVersion(),
671
+ submittedAt: new Date().toISOString()
672
+ };
673
+
674
+ try {
675
+ const result = await httpRequest('POST', `${serverUrl}/api/community/suggest`, suggestion);
676
+
677
+ if (result && result.statusCode >= 200 && result.statusCode < 300) {
678
+ return true;
679
+ }
680
+
681
+ // Server unreachable — queue for retry
682
+ queuePendingSuggestion(suggestion);
683
+ return true; // Queued counts as success from user perspective
684
+ } catch {
685
+ queuePendingSuggestion(suggestion);
686
+ return true;
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Queue a suggestion for later retry.
692
+ * @param {Object} suggestion
693
+ */
694
+ function queuePendingSuggestion(suggestion) {
695
+ try {
696
+ if (!fs.existsSync(WOGIFLOW_HOME)) {
697
+ fs.mkdirSync(WOGIFLOW_HOME, { recursive: true });
698
+ }
699
+
700
+ let pending = [];
701
+ if (fs.existsSync(PENDING_SUGGESTIONS_PATH)) {
702
+ try {
703
+ const content = fs.readFileSync(PENDING_SUGGESTIONS_PATH, 'utf-8');
704
+ const parsed = JSON.parse(content);
705
+ if (Array.isArray(parsed)) {
706
+ pending = parsed;
707
+ }
708
+ } catch {
709
+ // Corrupt file — start fresh
710
+ }
711
+ }
712
+
713
+ // Cap at 50 pending suggestions
714
+ if (pending.length >= 50) {
715
+ pending = pending.slice(-49);
716
+ }
717
+
718
+ pending.push(suggestion);
719
+ fs.writeFileSync(PENDING_SUGGESTIONS_PATH, JSON.stringify(pending, null, 2), 'utf-8');
720
+ } catch (err) {
721
+ if (process.env.DEBUG) {
722
+ console.error(`[flow-community] Failed to queue suggestion: ${err.message}`);
723
+ }
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Retry pending suggestions from queue.
729
+ * Called during session-start hook.
730
+ * @param {Object} config - WogiFlow config
731
+ * @returns {Promise<void>}
732
+ */
733
+ async function retryPendingSuggestions(config) {
734
+ try {
735
+ if (!fs.existsSync(PENDING_SUGGESTIONS_PATH)) return;
736
+
737
+ const content = fs.readFileSync(PENDING_SUGGESTIONS_PATH, 'utf-8');
738
+ let pending;
739
+ try {
740
+ pending = JSON.parse(content);
741
+ } catch {
742
+ return;
743
+ }
744
+
745
+ if (!Array.isArray(pending) || pending.length === 0) return;
746
+
747
+ const community = config.community || {};
748
+ const serverUrl = community.serverUrl || 'https://api.wogiflow.com';
749
+ const stillPending = [];
750
+
751
+ for (const suggestion of pending) {
752
+ try {
753
+ const result = await httpRequest('POST', `${serverUrl}/api/community/suggest`, suggestion);
754
+ if (!result || result.statusCode < 200 || result.statusCode >= 300) {
755
+ stillPending.push(suggestion);
756
+ }
757
+ // Successfully sent — don't re-add
758
+ } catch {
759
+ stillPending.push(suggestion);
760
+ }
761
+ }
762
+
763
+ if (stillPending.length === 0) {
764
+ // All sent — remove the file
765
+ try { fs.unlinkSync(PENDING_SUGGESTIONS_PATH); } catch { /* ignore */ }
766
+ } else {
767
+ fs.writeFileSync(PENDING_SUGGESTIONS_PATH, JSON.stringify(stillPending, null, 2), 'utf-8');
768
+ }
769
+ } catch (err) {
770
+ if (process.env.DEBUG) {
771
+ console.error(`[flow-community] Failed to retry suggestions: ${err.message}`);
772
+ }
773
+ }
774
+ }
775
+
776
+ // ──────────────────────────────────────────────
777
+ // Cache
778
+ // ──────────────────────────────────────────────
779
+
780
+ /**
781
+ * Load community cache from ~/.wogiflow/community-cache.json.
782
+ * @returns {Object|null}
783
+ */
784
+ function loadCommunityCache() {
785
+ try {
786
+ if (!fs.existsSync(COMMUNITY_CACHE_PATH)) return null;
787
+ const content = fs.readFileSync(COMMUNITY_CACHE_PATH, 'utf-8');
788
+ return JSON.parse(content);
789
+ } catch {
790
+ return null;
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Save community cache to ~/.wogiflow/community-cache.json.
796
+ * @param {Object} data
797
+ */
798
+ function saveCommunityCache(data) {
799
+ try {
800
+ if (!fs.existsSync(WOGIFLOW_HOME)) {
801
+ fs.mkdirSync(WOGIFLOW_HOME, { recursive: true });
802
+ }
803
+ fs.writeFileSync(COMMUNITY_CACHE_PATH, JSON.stringify(data, null, 2), 'utf-8');
804
+ } catch (err) {
805
+ if (process.env.DEBUG) {
806
+ console.error(`[flow-community] Failed to save cache: ${err.message}`);
807
+ }
808
+ }
809
+ }
810
+
811
+ // ──────────────────────────────────────────────
812
+ // Community Knowledge Merge (Phase C2)
813
+ // ──────────────────────────────────────────────
814
+
815
+ const COMMUNITY_MARKER = '<!-- community-knowledge-v1 -->';
816
+
817
+ /**
818
+ * Merge pulled community knowledge into local state files.
819
+ * Idempotent — safe to call multiple times with the same data.
820
+ *
821
+ * @param {Object} knowledge - Pulled community knowledge from server/cache
822
+ * @param {Object} config - WogiFlow config
823
+ * @returns {{ modelIntelligence: number, errorStrategies: number, patterns: number }} Merge counts
824
+ */
825
+ function mergeCommunityKnowledge(knowledge, config) {
826
+ const counts = { modelIntelligence: 0, errorStrategies: 0, patterns: 0 };
827
+ if (!knowledge || typeof knowledge !== 'object') return counts;
828
+
829
+ try {
830
+ if (Array.isArray(knowledge.modelIntelligence)) {
831
+ counts.modelIntelligence = mergeModelIntelligence(knowledge.modelIntelligence);
832
+ }
833
+ } catch (err) {
834
+ if (process.env.DEBUG) {
835
+ console.error(`[flow-community] Model intelligence merge failed: ${err.message}`);
836
+ }
837
+ }
838
+
839
+ try {
840
+ if (Array.isArray(knowledge.errorStrategies)) {
841
+ counts.errorStrategies = mergeErrorStrategies(knowledge.errorStrategies);
842
+ }
843
+ } catch (err) {
844
+ if (process.env.DEBUG) {
845
+ console.error(`[flow-community] Error strategies merge failed: ${err.message}`);
846
+ }
847
+ }
848
+
849
+ try {
850
+ if (Array.isArray(knowledge.patterns)) {
851
+ counts.patterns = mergePatterns(knowledge.patterns);
852
+ }
853
+ } catch (err) {
854
+ if (process.env.DEBUG) {
855
+ console.error(`[flow-community] Patterns merge failed: ${err.message}`);
856
+ }
857
+ }
858
+
859
+ return counts;
860
+ }
861
+
862
+ /**
863
+ * Merge community model intelligence into local model adapter files.
864
+ * Only updates files that already exist — never creates new adapter files.
865
+ *
866
+ * @param {Array} items - Model intelligence entries [{model, strengths, weaknesses, adjustments}]
867
+ * @returns {number} Number of entries merged
868
+ */
869
+ function mergeModelIntelligence(items) {
870
+ let merged = 0;
871
+ const adaptersDir = PATHS.modelAdapters;
872
+
873
+ try {
874
+ if (!fs.existsSync(adaptersDir)) return 0;
875
+ } catch {
876
+ return 0;
877
+ }
878
+
879
+ for (const item of items.slice(0, 20)) {
880
+ if (!item.model) continue;
881
+
882
+ // Normalize model name to kebab-case filename
883
+ const modelFile = item.model.toLowerCase().replace(/[^a-z0-9.-]/g, '-').replace(/-+/g, '-');
884
+ const filePath = path.join(adaptersDir, `${modelFile}.md`);
885
+
886
+ try {
887
+ if (!fs.existsSync(filePath)) continue;
888
+
889
+ const content = fs.readFileSync(filePath, 'utf-8');
890
+
891
+ // Check if community section already exists
892
+ if (content.includes(COMMUNITY_MARKER)) {
893
+ // Section exists — check for this specific item
894
+ const detail = item.adjustments || item.strengths || item.weaknesses || '';
895
+ if (!detail || content.includes(detail.slice(0, 80))) {
896
+ continue; // Already merged
897
+ }
898
+ // Append to existing section
899
+ const markerIndex = content.indexOf(COMMUNITY_MARKER);
900
+ const insertPoint = content.indexOf('\n', markerIndex) + 1;
901
+ const newLine = `- ${detail}\n`;
902
+ const updated = content.slice(0, insertPoint) + newLine + content.slice(insertPoint);
903
+ fs.writeFileSync(filePath, updated, 'utf-8');
904
+ merged++;
905
+ } else {
906
+ // Add new community section at end of file
907
+ const detail = item.adjustments || item.strengths || item.weaknesses || '';
908
+ if (!detail) continue;
909
+ const section = `\n\n## Community Learnings\n${COMMUNITY_MARKER}\n- ${detail}\n`;
910
+ fs.writeFileSync(filePath, content.trimEnd() + section, 'utf-8');
911
+ merged++;
912
+ }
913
+ } catch {
914
+ // Skip individual file failures
915
+ }
916
+ }
917
+
918
+ return merged;
919
+ }
920
+
921
+ /**
922
+ * Merge community error strategies into local adaptive-learning.json.
923
+ * Deduplicates by category+strategy pair.
924
+ *
925
+ * @param {Array} items - Error strategy entries [{category, strategy, successRate}]
926
+ * @returns {number} Number of entries merged
927
+ */
928
+ function mergeErrorStrategies(items) {
929
+ const filePath = path.join(PATHS.state, 'adaptive-learning.json');
930
+ let data = {};
931
+
932
+ try {
933
+ if (fs.existsSync(filePath)) {
934
+ data = safeJsonParse(filePath, {});
935
+ }
936
+ } catch {
937
+ data = {};
938
+ }
939
+
940
+ if (!data.communityStrategies) {
941
+ data.communityStrategies = [];
942
+ }
943
+
944
+ // Build dedup set from existing community strategies
945
+ const existing = new Set(
946
+ data.communityStrategies.map(s => `${(s.category || '').toLowerCase()}::${(s.strategy || '').toLowerCase()}`)
947
+ );
948
+
949
+ let merged = 0;
950
+ for (const item of items.slice(0, 50)) {
951
+ if (!item.category || !item.strategy) continue;
952
+
953
+ const key = `${item.category.toLowerCase()}::${item.strategy.toLowerCase()}`;
954
+ if (existing.has(key)) continue;
955
+
956
+ data.communityStrategies.push({
957
+ category: item.category,
958
+ strategy: item.strategy,
959
+ successRate: item.successRate || null,
960
+ source: 'community',
961
+ mergedAt: new Date().toISOString()
962
+ });
963
+ existing.add(key);
964
+ merged++;
965
+ }
966
+
967
+ if (merged > 0) {
968
+ try {
969
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
970
+ } catch (err) {
971
+ if (process.env.DEBUG) {
972
+ console.error(`[flow-community] Failed to write adaptive-learning.json: ${err.message}`);
973
+ }
974
+ }
975
+ }
976
+
977
+ return merged;
978
+ }
979
+
980
+ /**
981
+ * Merge community patterns into local feedback-patterns.md.
982
+ * Adds with "community-" prefix and "Informational" status.
983
+ * Deduplicates by checking for existing community entries with same pattern name.
984
+ *
985
+ * @param {Array} items - Pattern entries [{pattern, description, occurrences}]
986
+ * @returns {number} Number of entries merged
987
+ */
988
+ function mergePatterns(items) {
989
+ const filePath = PATHS.feedbackPatterns;
990
+
991
+ let content = '';
992
+ try {
993
+ if (fs.existsSync(filePath)) {
994
+ content = fs.readFileSync(filePath, 'utf-8');
995
+ } else {
996
+ return 0; // Don't create the file if it doesn't exist
997
+ }
998
+ } catch {
999
+ return 0;
1000
+ }
1001
+
1002
+ let merged = 0;
1003
+ const today = new Date().toISOString().split('T')[0];
1004
+ const newRows = [];
1005
+
1006
+ for (const item of items.slice(0, 20)) {
1007
+ if (!item.description) continue;
1008
+
1009
+ const patternName = item.pattern
1010
+ ? `community-${item.pattern}`
1011
+ : `community-${item.description.slice(0, 30).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
1012
+
1013
+ // Check if this community pattern already exists
1014
+ if (content.includes(patternName)) continue;
1015
+
1016
+ const description = item.description.replace(/\|/g, '/'); // Escape pipes for table
1017
+ const occurrences = item.occurrences || 1;
1018
+ newRows.push(`| ${today} | ${patternName} | Community: ${description} | ${occurrences} | Informational |`);
1019
+ merged++;
1020
+ }
1021
+
1022
+ if (newRows.length > 0) {
1023
+ // Find the end of the Patterns Log table to insert before pending patterns
1024
+ const tableEnd = content.indexOf('\n\n### ');
1025
+ if (tableEnd !== -1) {
1026
+ const updated = content.slice(0, tableEnd) + '\n' + newRows.join('\n') + content.slice(tableEnd);
1027
+ try {
1028
+ fs.writeFileSync(filePath, updated, 'utf-8');
1029
+ } catch (err) {
1030
+ if (process.env.DEBUG) {
1031
+ console.error(`[flow-community] Failed to write feedback-patterns.md: ${err.message}`);
1032
+ }
1033
+ }
1034
+ } else {
1035
+ // Append at end
1036
+ try {
1037
+ fs.writeFileSync(filePath, content.trimEnd() + '\n' + newRows.join('\n') + '\n', 'utf-8');
1038
+ } catch (err) {
1039
+ if (process.env.DEBUG) {
1040
+ console.error(`[flow-community] Failed to write feedback-patterns.md: ${err.message}`);
1041
+ }
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ return merged;
1047
+ }
1048
+
1049
+ // ──────────────────────────────────────────────
1050
+ // Exports
1051
+ // ──────────────────────────────────────────────
1052
+
1053
+ module.exports = {
1054
+ collectShareableData,
1055
+ stripPII,
1056
+ pushToServer,
1057
+ pullFromServer,
1058
+ mergeCommunityKnowledge,
1059
+ getOrCreateAnonId,
1060
+ submitSuggestion,
1061
+ retryPendingSuggestions,
1062
+ loadCommunityCache,
1063
+ saveCommunityCache,
1064
+ isConsentAcknowledged,
1065
+ acknowledgeConsent,
1066
+ getConsentMessage,
1067
+ // Exposed for testing
1068
+ WOGIFLOW_HOME,
1069
+ COMMUNITY_CACHE_PATH,
1070
+ PENDING_SUGGESTIONS_PATH
1071
+ };