oh-my-llmwikimode 1.0.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.
- package/LICENSE +21 -0
- package/README.md +494 -0
- package/bin/llmwiki.js +1493 -0
- package/docs/INSTALLATION.md +228 -0
- package/docs/SCOPE_LOCK.md +79 -0
- package/docs/STAGE1_GUIDE.md +265 -0
- package/docs/STAGE2_AGENT_TEAM_GUIDE.md +141 -0
- package/docs/STAGE3_CONVERSATIONAL_GROWTH_GUIDE.md +50 -0
- package/docs/TEST_WORKSHEET.md +120 -0
- package/docs/github-private-bootstrap.md +53 -0
- package/docs/release.md +79 -0
- package/docs/stage4-slice1-manual-test.md +259 -0
- package/docs/stage4-slice1-user-guide.md +269 -0
- package/docs/user-guide-ko.md +452 -0
- package/package.json +76 -0
- package/scripts/install-llmwiki.ps1 +229 -0
- package/src/config.js +74 -0
- package/src/curator/browser-data.js +134 -0
- package/src/curator/queue.js +324 -0
- package/src/curator/schema.js +237 -0
- package/src/curator/scoring.js +83 -0
- package/src/hooks.js +199 -0
- package/src/librarian/schema.js +218 -0
- package/src/librarian/weekly-digest.js +478 -0
- package/src/security.js +127 -0
- package/src/server.js +860 -0
- package/src/stage4/graph-reasoning/analyzer.js +255 -0
- package/src/stage4/graph-reasoning/browser-data.js +130 -0
- package/src/stage4/graph-reasoning/index.js +35 -0
- package/src/stage4/graph-reasoning/loader.js +122 -0
- package/src/stage4/graph-reasoning/queue.js +154 -0
- package/src/stage4/graph-reasoning/schema.js +190 -0
- package/src/team/browser-data.js +142 -0
- package/src/team/capabilities.js +79 -0
- package/src/team/dispatch.js +108 -0
- package/src/team/queue.js +290 -0
- package/src/team/schema.js +225 -0
- package/src/team/shared-memory.js +183 -0
- package/src/todo/browser-data.js +71 -0
- package/src/todo/queue.js +159 -0
- package/src/todo/schema.js +90 -0
- package/src/utils/embedding-model.js +111 -0
- package/src/wiki/alias-suggestions.js +180 -0
- package/src/wiki/browser-data.js +284 -0
- package/src/wiki/doctor.js +218 -0
- package/src/wiki/entry-normalizer.js +139 -0
- package/src/wiki/ingest.js +443 -0
- package/src/wiki/lesson-proposal-analyzer.js +463 -0
- package/src/wiki/lesson-proposal-manager.js +331 -0
- package/src/wiki/lesson-template.js +182 -0
- package/src/wiki/lint.js +294 -0
- package/src/wiki/notebooklm-adapter.js +264 -0
- package/src/wiki/query.js +304 -0
- package/src/wiki/raw-manager.js +400 -0
- package/src/wiki/search-feedback.js +211 -0
- package/src/wiki/semantic-index.js +333 -0
- package/src/wiki/semantic-search.js +170 -0
- package/src/wiki/source-ledger.js +370 -0
- package/src/wiki/store.js +1329 -0
- package/src/wiki/usage-events.js +144 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B1 Auto Lesson Proposal — Usage Event Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Analyzes usage event logs to detect repeated patterns
|
|
5
|
+
* and generates lesson proposal artifacts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { listUsageEventFiles, readUsageEvents, USAGE_EVENTS } from "./usage-events.js";
|
|
12
|
+
import { getWikiPaths, ensureWikiStructure } from "./store.js";
|
|
13
|
+
|
|
14
|
+
export const PROPOSAL_VERSION = 1;
|
|
15
|
+
export const PROPOSAL_DIR = ".system/lesson-proposals";
|
|
16
|
+
|
|
17
|
+
// Minimum thresholds for generating proposals
|
|
18
|
+
const MIN_QUERY_REPEATS = 2;
|
|
19
|
+
const MIN_ENTRY_REPEATS = 2;
|
|
20
|
+
const MIN_SEARCH_RESULT_USED = 3;
|
|
21
|
+
const MAX_ANALYSIS_DAYS = 30;
|
|
22
|
+
const MAX_PROPOSALS = 100;
|
|
23
|
+
|
|
24
|
+
function hashString(input) {
|
|
25
|
+
return crypto.createHash("sha256").update(String(input)).digest("hex").slice(0, 16);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compareStrings(left, right) {
|
|
29
|
+
return String(left ?? "").localeCompare(String(right ?? ""));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensureProposalDir(wikiRoot) {
|
|
33
|
+
const dir = path.join(wikiRoot, PROPOSAL_DIR);
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function dedupKeysForPatternType(type, queryHashes = [], entryPaths = []) {
|
|
39
|
+
if (type === "repeated_query") {
|
|
40
|
+
return [...queryHashes]
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.sort(compareStrings)
|
|
43
|
+
.map((queryHash) => "query:" + queryHash);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (type === "repeated_entry") {
|
|
47
|
+
return [...entryPaths]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.sort(compareStrings)
|
|
50
|
+
.map((entryPath) => "entry:" + entryPath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function dedupKeysForProposal(content) {
|
|
57
|
+
const evidence = content?.evidence || {};
|
|
58
|
+
const queryHashes = evidence.query_hashes || content?.query_hashes || [];
|
|
59
|
+
const entryPaths = evidence.entry_paths || [];
|
|
60
|
+
const patternType = content?.pattern_type || content?.type;
|
|
61
|
+
const typedKeys = dedupKeysForPatternType(patternType, queryHashes, entryPaths);
|
|
62
|
+
|
|
63
|
+
if (typedKeys.length > 0) return typedKeys;
|
|
64
|
+
|
|
65
|
+
// Legacy proposals did not store pattern_type. Index all stable keys so
|
|
66
|
+
// already-created proposals remain duplicate-protected after upgrading.
|
|
67
|
+
return [
|
|
68
|
+
...dedupKeysForPatternType("repeated_query", queryHashes, entryPaths),
|
|
69
|
+
...dedupKeysForPatternType("repeated_entry", queryHashes, entryPaths),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dedupKeyForPattern(pattern) {
|
|
74
|
+
const evidence = pattern?.evidence || {};
|
|
75
|
+
const queryHashes = pattern?.query_hash
|
|
76
|
+
? [pattern.query_hash]
|
|
77
|
+
: (pattern?.query_hashes || evidence.query_hashes || []);
|
|
78
|
+
const entryPaths = pattern?.entry_path
|
|
79
|
+
? [pattern.entry_path]
|
|
80
|
+
: (evidence.entry_paths || []);
|
|
81
|
+
|
|
82
|
+
return dedupKeysForPatternType(pattern?.type, queryHashes, entryPaths)[0] || "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function listExistingProposals(wikiRoot) {
|
|
86
|
+
const dir = path.join(wikiRoot, PROPOSAL_DIR);
|
|
87
|
+
if (!fs.existsSync(dir)) return new Map();
|
|
88
|
+
|
|
89
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
90
|
+
const existing = new Map();
|
|
91
|
+
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
try {
|
|
94
|
+
const content = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
95
|
+
for (const key of dedupKeysForProposal(content)) {
|
|
96
|
+
existing.set(key, content.id);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Skip malformed
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return existing;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function countProposals(wikiRoot) {
|
|
107
|
+
const dir = path.join(wikiRoot, PROPOSAL_DIR);
|
|
108
|
+
if (!fs.existsSync(dir)) return 0;
|
|
109
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".json")).length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function dateKeyFromUsageFile(fileName) {
|
|
113
|
+
const match = /^(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(fileName);
|
|
114
|
+
return match ? match[1] : "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function deriveUsageEventAnchor(wikiRoot) {
|
|
118
|
+
let latestTimestamp = "";
|
|
119
|
+
let latestEventFileDate = "";
|
|
120
|
+
|
|
121
|
+
for (const fileName of listUsageEventFiles(wikiRoot)) {
|
|
122
|
+
const dateKey = dateKeyFromUsageFile(fileName);
|
|
123
|
+
if (!dateKey) continue;
|
|
124
|
+
|
|
125
|
+
const result = readUsageEvents(wikiRoot, dateKey + "T00:00:00.000Z");
|
|
126
|
+
if (!result.success || !Array.isArray(result.events) || result.events.length === 0) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (compareStrings(dateKey, latestEventFileDate) > 0) {
|
|
131
|
+
latestEventFileDate = dateKey;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const event of result.events) {
|
|
135
|
+
const timestamp = String(event.ts || "");
|
|
136
|
+
if (
|
|
137
|
+
timestamp
|
|
138
|
+
&& !Number.isNaN(new Date(timestamp).getTime())
|
|
139
|
+
&& compareStrings(timestamp, latestTimestamp) > 0
|
|
140
|
+
) {
|
|
141
|
+
latestTimestamp = timestamp;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (latestTimestamp) return new Date(latestTimestamp);
|
|
147
|
+
if (latestEventFileDate) return new Date(latestEventFileDate + "T00:00:00.000Z");
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Read all usage events from the last N days.
|
|
153
|
+
*
|
|
154
|
+
* Deterministic: same anchor + same events = same result.
|
|
155
|
+
* If options.now is omitted, the anchor is derived from the latest usage event.
|
|
156
|
+
* If no events exist, there is no deterministic anchor and the result is empty.
|
|
157
|
+
*/
|
|
158
|
+
export function readAllUsageEvents(wikiRoot, options = {}) {
|
|
159
|
+
const days = Math.min(options.days ?? MAX_ANALYSIS_DAYS, MAX_ANALYSIS_DAYS);
|
|
160
|
+
const anchor = options.now ? new Date(options.now) : deriveUsageEventAnchor(wikiRoot);
|
|
161
|
+
if (!anchor) return [];
|
|
162
|
+
|
|
163
|
+
const allEvents = [];
|
|
164
|
+
for (let i = 0; i < days; i++) {
|
|
165
|
+
const date = new Date(anchor);
|
|
166
|
+
date.setUTCDate(date.getUTCDate() - i);
|
|
167
|
+
const result = readUsageEvents(wikiRoot, date);
|
|
168
|
+
if (result.success && Array.isArray(result.events)) {
|
|
169
|
+
allEvents.push(...result.events);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Sort by timestamp for deterministic processing
|
|
174
|
+
allEvents.sort((a, b) => compareStrings(a.ts, b.ts));
|
|
175
|
+
|
|
176
|
+
return allEvents;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Generate a deterministic proposal ID from stable evidence.
|
|
181
|
+
* Uses evidence content (query hashes + entry paths + timestamps) rather than wall clock
|
|
182
|
+
* so the same usage pattern always produces the same ID.
|
|
183
|
+
*/
|
|
184
|
+
export function generateProposalId(evidence) {
|
|
185
|
+
const hashes = [...(evidence.query_hashes || [])].sort(compareStrings).join("|");
|
|
186
|
+
const entries = [...(evidence.entry_paths || [])].sort(compareStrings).join("|");
|
|
187
|
+
const input = `${hashes}::${entries}::${evidence.first_seen || ""}::${evidence.last_seen || ""}`;
|
|
188
|
+
return "lp_" + hashString(input);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Extract a suggested title from query hashes and entry paths.
|
|
193
|
+
*/
|
|
194
|
+
function suggestTitle(evidence) {
|
|
195
|
+
const { query_hashes, entry_paths } = evidence;
|
|
196
|
+
|
|
197
|
+
// Try to extract from entry paths first
|
|
198
|
+
if (entry_paths?.length > 0) {
|
|
199
|
+
const entry = entry_paths[0];
|
|
200
|
+
const basename = path.basename(entry, ".md");
|
|
201
|
+
// Remove date prefix if present
|
|
202
|
+
const clean = basename.replace(/^\d{4}-\d{2}-\d{2}[-T]/, "").replace(/-/g, " ");
|
|
203
|
+
if (clean) return clean.charAt(0).toUpperCase() + clean.slice(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Fallback to query hash abbreviation
|
|
207
|
+
if (query_hashes?.length > 0) {
|
|
208
|
+
return `Lesson Proposal ${query_hashes[0].slice(0, 8)}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return "Lesson Proposal";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Build a proposal artifact from a detected pattern.
|
|
216
|
+
*
|
|
217
|
+
* Deterministic: same pattern evidence always produces the same artifact
|
|
218
|
+
* (same ID, same created_at, same all fields).
|
|
219
|
+
* created_at defaults to evidence.last_seen so identical usage events
|
|
220
|
+
* always produce identical artifacts regardless of analysis execution time.
|
|
221
|
+
* options.now is accepted only as an explicit override for testing or replay
|
|
222
|
+
* when evidence timestamps are unavailable.
|
|
223
|
+
*/
|
|
224
|
+
export function buildProposalArtifact(pattern, index, options = {}) {
|
|
225
|
+
const id = generateProposalId(pattern.evidence);
|
|
226
|
+
// Default to evidence timestamps for determinism.
|
|
227
|
+
// options.now is a last-resort fallback when evidence lacks timestamps.
|
|
228
|
+
const createdAt = pattern.evidence?.last_seen
|
|
229
|
+
|| pattern.evidence?.first_seen
|
|
230
|
+
|| (options.now ? new Date(options.now).toISOString() : "");
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
version: PROPOSAL_VERSION,
|
|
234
|
+
id,
|
|
235
|
+
pattern_type: pattern.type || "unknown",
|
|
236
|
+
status: "review_required",
|
|
237
|
+
action: "consider_lesson",
|
|
238
|
+
proposed_title: suggestTitle(pattern.evidence),
|
|
239
|
+
evidence: pattern.evidence,
|
|
240
|
+
template_sections: pattern.template_sections || {
|
|
241
|
+
when_to_use: "_(Auto-generated: fill in context for when this lesson applies)_",
|
|
242
|
+
principles: "_(Auto-generated: fill in key principles)_",
|
|
243
|
+
checklist: ["_(Auto-generated: add checklist items)_"],
|
|
244
|
+
examples: "_(Auto-generated: add examples)_",
|
|
245
|
+
failure_patterns: "_(Auto-generated: add failure patterns)_",
|
|
246
|
+
},
|
|
247
|
+
auto_apply: false,
|
|
248
|
+
review_required: true,
|
|
249
|
+
created_at: createdAt,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Analyze usage events for repeated patterns.
|
|
255
|
+
*/
|
|
256
|
+
export function analyzeUsagePatterns(wikiRoot, options = {}) {
|
|
257
|
+
const events = readAllUsageEvents(wikiRoot, options);
|
|
258
|
+
|
|
259
|
+
if (events.length === 0) {
|
|
260
|
+
return {
|
|
261
|
+
success: true,
|
|
262
|
+
patterns: [],
|
|
263
|
+
total_events_analyzed: 0,
|
|
264
|
+
date_range: null,
|
|
265
|
+
proposals_generated: 0,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Count occurrences
|
|
270
|
+
const queryCounts = new Map();
|
|
271
|
+
const entryCounts = new Map();
|
|
272
|
+
const queryEntries = new Map(); // query_hash -> Set of entry_paths
|
|
273
|
+
|
|
274
|
+
for (const event of events) {
|
|
275
|
+
const queryHash = event.query_hash;
|
|
276
|
+
const entryPath = event.entry_path;
|
|
277
|
+
const eventType = event.event;
|
|
278
|
+
|
|
279
|
+
if (queryHash) {
|
|
280
|
+
queryCounts.set(queryHash, (queryCounts.get(queryHash) || 0) + 1);
|
|
281
|
+
if (!queryEntries.has(queryHash)) queryEntries.set(queryHash, new Set());
|
|
282
|
+
if (entryPath) queryEntries.get(queryHash).add(entryPath);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (entryPath) {
|
|
286
|
+
entryCounts.set(entryPath, (entryCounts.get(entryPath) || 0) + 1);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const patterns = [];
|
|
291
|
+
const seenQueryKeys = new Set();
|
|
292
|
+
|
|
293
|
+
// Detect repeated queries
|
|
294
|
+
for (const [queryHash, count] of queryCounts) {
|
|
295
|
+
if (count >= MIN_QUERY_REPEATS) {
|
|
296
|
+
const entries = [...(queryEntries.get(queryHash) || [])].sort(compareStrings);
|
|
297
|
+
const key = queryHash;
|
|
298
|
+
|
|
299
|
+
if (!seenQueryKeys.has(key)) {
|
|
300
|
+
seenQueryKeys.add(key);
|
|
301
|
+
patterns.push({
|
|
302
|
+
type: "repeated_query",
|
|
303
|
+
query_hash: queryHash,
|
|
304
|
+
query_hashes: [queryHash],
|
|
305
|
+
count,
|
|
306
|
+
related_entries: entries,
|
|
307
|
+
confidence: count >= MIN_SEARCH_RESULT_USED ? "high" : "medium",
|
|
308
|
+
evidence: {
|
|
309
|
+
query_hashes: [queryHash],
|
|
310
|
+
entry_paths: entries,
|
|
311
|
+
usage_count: count,
|
|
312
|
+
first_seen: events.find((e) => e.query_hash === queryHash)?.ts || "",
|
|
313
|
+
last_seen: [...events].reverse().find((e) => e.query_hash === queryHash)?.ts || "",
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Detect repeated entry references
|
|
321
|
+
for (const [entryPath, count] of entryCounts) {
|
|
322
|
+
if (count >= MIN_ENTRY_REPEATS) {
|
|
323
|
+
const relatedQueries = [...queryCounts.keys()].filter((q) => {
|
|
324
|
+
const entries = queryEntries.get(q);
|
|
325
|
+
return entries?.has(entryPath);
|
|
326
|
+
}).sort(compareStrings);
|
|
327
|
+
|
|
328
|
+
const key = `entry:${entryPath}`;
|
|
329
|
+
if (!seenQueryKeys.has(key)) {
|
|
330
|
+
seenQueryKeys.add(key);
|
|
331
|
+
patterns.push({
|
|
332
|
+
type: "repeated_entry",
|
|
333
|
+
entry_path: entryPath,
|
|
334
|
+
count,
|
|
335
|
+
related_queries: relatedQueries,
|
|
336
|
+
confidence: "medium",
|
|
337
|
+
evidence: {
|
|
338
|
+
query_hashes: relatedQueries,
|
|
339
|
+
entry_paths: [entryPath],
|
|
340
|
+
usage_count: count,
|
|
341
|
+
first_seen: events.find((e) => e.entry_path === entryPath)?.ts || "",
|
|
342
|
+
last_seen: [...events].reverse().find((e) => e.entry_path === entryPath)?.ts || "",
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Detect high-usage search_result_used events
|
|
350
|
+
// Instead of creating a duplicate pattern, upgrade repeated_query confidence to "high"
|
|
351
|
+
const searchResultCounts = new Map();
|
|
352
|
+
for (const event of events) {
|
|
353
|
+
if (event.event === USAGE_EVENTS.SEARCH_RESULT_USED && event.query_hash) {
|
|
354
|
+
searchResultCounts.set(event.query_hash, (searchResultCounts.get(event.query_hash) || 0) + 1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const [queryHash, count] of searchResultCounts) {
|
|
359
|
+
if (count >= MIN_SEARCH_RESULT_USED) {
|
|
360
|
+
// Find the existing repeated_query pattern and upgrade its confidence
|
|
361
|
+
const existingPattern = patterns.find((p) => p.type === "repeated_query" && p.query_hash === queryHash);
|
|
362
|
+
if (existingPattern) {
|
|
363
|
+
existingPattern.confidence = "high";
|
|
364
|
+
}
|
|
365
|
+
// If there is no repeated_query (e.g. only search_result_used events),
|
|
366
|
+
// create one with high confidence instead of a separate high_usage_search pattern
|
|
367
|
+
if (!existingPattern) {
|
|
368
|
+
const key = queryHash;
|
|
369
|
+
if (!seenQueryKeys.has(key)) {
|
|
370
|
+
seenQueryKeys.add(key);
|
|
371
|
+
const entries = [...(queryEntries.get(queryHash) || [])].sort(compareStrings);
|
|
372
|
+
patterns.push({
|
|
373
|
+
type: "repeated_query",
|
|
374
|
+
query_hash: queryHash,
|
|
375
|
+
query_hashes: [queryHash],
|
|
376
|
+
count,
|
|
377
|
+
related_entries: entries,
|
|
378
|
+
confidence: "high",
|
|
379
|
+
evidence: {
|
|
380
|
+
query_hashes: [queryHash],
|
|
381
|
+
entry_paths: entries,
|
|
382
|
+
usage_count: count,
|
|
383
|
+
first_seen: events.find((e) => e.query_hash === queryHash)?.ts || "",
|
|
384
|
+
last_seen: [...events].reverse().find((e) => e.query_hash === queryHash)?.ts || "",
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
success: true,
|
|
394
|
+
patterns,
|
|
395
|
+
total_events_analyzed: events.length,
|
|
396
|
+
date_range: {
|
|
397
|
+
from: events[0]?.ts || "",
|
|
398
|
+
to: events[events.length - 1]?.ts || "",
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Generate and save lesson proposals from usage patterns.
|
|
405
|
+
*/
|
|
406
|
+
export function generateLessonProposals(wikiRoot, options = {}) {
|
|
407
|
+
try {
|
|
408
|
+
const paths = getWikiPaths(wikiRoot);
|
|
409
|
+
ensureWikiStructure(paths);
|
|
410
|
+
ensureProposalDir(wikiRoot);
|
|
411
|
+
|
|
412
|
+
const analysis = analyzeUsagePatterns(wikiRoot, options);
|
|
413
|
+
if (!analysis.success) return analysis;
|
|
414
|
+
|
|
415
|
+
const existingProposals = listExistingProposals(wikiRoot);
|
|
416
|
+
const currentCount = countProposals(wikiRoot);
|
|
417
|
+
let generated = 0;
|
|
418
|
+
let skipped = 0;
|
|
419
|
+
|
|
420
|
+
// Track in-memory IDs within this run to prevent same-run collisions
|
|
421
|
+
const generatedThisRun = new Set();
|
|
422
|
+
|
|
423
|
+
for (let i = 0; i < analysis.patterns.length; i++) {
|
|
424
|
+
if (currentCount + generated >= MAX_PROPOSALS) break;
|
|
425
|
+
|
|
426
|
+
const pattern = analysis.patterns[i];
|
|
427
|
+
const dedupKey = dedupKeyForPattern(pattern);
|
|
428
|
+
if (!dedupKey) {
|
|
429
|
+
skipped++;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Skip if already proposed (on disk or in this run)
|
|
434
|
+
if (existingProposals.has(dedupKey) || generatedThisRun.has(dedupKey)) {
|
|
435
|
+
skipped++;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const proposal = buildProposalArtifact(pattern, i, options);
|
|
440
|
+
const filePath = path.join(wikiRoot, PROPOSAL_DIR, `${proposal.id}.json`);
|
|
441
|
+
|
|
442
|
+
// NEVER overwrite an existing proposal file (preserves applied/rejected status)
|
|
443
|
+
if (fs.existsSync(filePath)) {
|
|
444
|
+
skipped++;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
fs.writeFileSync(filePath, JSON.stringify(proposal, null, 2), "utf-8");
|
|
449
|
+
generated++;
|
|
450
|
+
generatedThisRun.add(dedupKey);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
success: true,
|
|
455
|
+
proposals_generated: generated,
|
|
456
|
+
proposals_skipped: skipped,
|
|
457
|
+
total_patterns: analysis.patterns.length,
|
|
458
|
+
total_events: analysis.total_events_analyzed,
|
|
459
|
+
};
|
|
460
|
+
} catch (error) {
|
|
461
|
+
return { success: false, error: error.message };
|
|
462
|
+
}
|
|
463
|
+
}
|