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.
- package/.claude/commands/wogi-session-end.md +41 -2
- package/.claude/commands/wogi-suggest.md +98 -0
- package/.workflow/templates/claude-md.hbs +2 -0
- package/lib/installer.js +28 -0
- package/package.json +1 -1
- package/scripts/flow-community.js +1071 -0
- package/scripts/flow-orchestrate.js +5 -2
- package/scripts/flow-regression.js +9 -7
- package/scripts/flow-script-resolver.js +375 -0
- package/scripts/flow-spec-generator.js +38 -25
- package/scripts/flow-start.js +2 -1
- package/scripts/flow-step-coverage.js +8 -10
- package/scripts/flow-task-enforcer.js +14 -3
- package/scripts/flow-verify.js +77 -56
- package/scripts/hooks/core/extension-registry.js +83 -0
- package/scripts/hooks/core/index.js +7 -1
- package/scripts/hooks/core/session-context.js +42 -0
- package/scripts/hooks/entry/claude-code/session-start.js +47 -0
- package/scripts/postinstall.js +51 -0
|
@@ -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
|
+
};
|