mindlore 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +30 -3
  2. package/dist/scripts/bundle-hooks.d.ts +2 -0
  3. package/dist/scripts/bundle-hooks.d.ts.map +1 -0
  4. package/dist/scripts/bundle-hooks.js +68 -0
  5. package/dist/scripts/bundle-hooks.js.map +1 -0
  6. package/dist/scripts/init.js +0 -3
  7. package/dist/scripts/init.js.map +1 -1
  8. package/dist/scripts/lib/constants.d.ts +0 -2
  9. package/dist/scripts/lib/constants.d.ts.map +1 -1
  10. package/dist/scripts/lib/constants.js +0 -21
  11. package/dist/scripts/lib/constants.js.map +1 -1
  12. package/dist/tests/hook-smoke.test.js +1 -1
  13. package/dist/tests/hook-smoke.test.js.map +1 -1
  14. package/dist/tests/search-hook.test.js +1 -1
  15. package/dist/tests/search-hook.test.js.map +1 -1
  16. package/hooks/cc-memory-bulk-sync.cjs +592 -0
  17. package/hooks/cc-session-sync.cjs +842 -0
  18. package/hooks/hooks.json +149 -0
  19. package/hooks/lib/mindlore-common.cjs +2 -2
  20. package/hooks/lib/secure-io.cjs +17 -0
  21. package/hooks/mindlore-cwd-changed.cjs +19 -34
  22. package/hooks/mindlore-decision-detector.cjs +40 -31
  23. package/hooks/mindlore-dont-repeat.cjs +57 -115
  24. package/hooks/mindlore-fts5-sync.cjs +15 -44
  25. package/hooks/mindlore-index.cjs +100 -101
  26. package/hooks/mindlore-model-router.cjs +20 -32
  27. package/hooks/mindlore-post-compact.cjs +26 -42
  28. package/hooks/mindlore-post-read.cjs +35 -60
  29. package/hooks/mindlore-pre-compact.cjs +55 -73
  30. package/hooks/mindlore-read-guard.cjs +28 -51
  31. package/hooks/mindlore-research-guard.cjs +63 -101
  32. package/hooks/mindlore-search.cjs +1142 -93
  33. package/hooks/mindlore-session-end.cjs +155 -276
  34. package/hooks/mindlore-session-focus.cjs +639 -110
  35. package/hooks/src/lib/constants.cjs +15 -0
  36. package/hooks/src/lib/mindlore-common.cjs +975 -0
  37. package/hooks/src/lib/mindlore-common.d.cts +72 -0
  38. package/hooks/src/lib/secure-io.cjs +17 -0
  39. package/hooks/src/lib/types.d.ts +58 -0
  40. package/hooks/src/mindlore-cwd-changed.cjs +57 -0
  41. package/hooks/src/mindlore-decision-detector.cjs +54 -0
  42. package/hooks/src/mindlore-dont-repeat.cjs +222 -0
  43. package/hooks/src/mindlore-fts5-sync.cjs +98 -0
  44. package/hooks/src/mindlore-index.cjs +230 -0
  45. package/hooks/src/mindlore-model-router.cjs +54 -0
  46. package/hooks/src/mindlore-post-compact.cjs +69 -0
  47. package/hooks/src/mindlore-post-read.cjs +106 -0
  48. package/hooks/src/mindlore-pre-compact.cjs +154 -0
  49. package/hooks/src/mindlore-read-guard.cjs +105 -0
  50. package/hooks/src/mindlore-research-guard.cjs +176 -0
  51. package/hooks/src/mindlore-search.cjs +200 -0
  52. package/hooks/src/mindlore-session-end.cjs +511 -0
  53. package/hooks/src/mindlore-session-focus.cjs +256 -0
  54. package/package.json +7 -3
  55. package/plugin.json +3 -3
  56. package/templates/config.json +1 -1
@@ -0,0 +1,975 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared utilities for mindlore hooks.
5
+ * Eliminates duplication of findMindloreDir, getLatestDelta, sha256, etc.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const os = require('os');
12
+ const { EPISODE_KINDS, isValidKind, DB_BUSY_TIMEOUT_MS } = require('./constants.cjs');
13
+ const { safeMkdir, safeWriteFile } = require('./secure-io.cjs');
14
+
15
+ const MINDLORE_DIR = '.mindlore';
16
+ const DB_NAME = 'mindlore.db';
17
+ const SKIP_FILES = new Set(['INDEX.md', 'SCHEMA.md', 'log.md']);
18
+
19
+ /**
20
+ * Compute global .mindlore/ path at call time.
21
+ * Separate function so os.homedir() is evaluated lazily (testable).
22
+ * MINDLORE_HOME env var overrides for testing and custom installs.
23
+ */
24
+ function globalDir() {
25
+ if (process.env.MINDLORE_HOME) return process.env.MINDLORE_HOME;
26
+ return path.join(os.homedir(), MINDLORE_DIR);
27
+ }
28
+
29
+ // Convenience export — snapshot at load time for simple references.
30
+ const GLOBAL_MINDLORE_DIR = globalDir();
31
+
32
+ /**
33
+ * v0.3.3 Global-First: always returns global ~/.mindlore/ if it exists.
34
+ */
35
+ function findMindloreDir() {
36
+ const gDir = globalDir();
37
+ if (fs.existsSync(gDir)) return gDir;
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Always returns the global ~/.mindlore/ path.
43
+ * v0.3.3: project scope removed — single global directory.
44
+ */
45
+ function getActiveMindloreDir() {
46
+ return globalDir();
47
+ }
48
+
49
+ function isInsideMindloreDir(resolvedPath) {
50
+ return resolvedPath.includes(path.sep + MINDLORE_DIR + path.sep)
51
+ || resolvedPath.endsWith(path.sep + MINDLORE_DIR);
52
+ }
53
+
54
+ function extractMindloreBaseDir(resolvedPath) {
55
+ const sepDir = path.sep + MINDLORE_DIR;
56
+ let idx = resolvedPath.lastIndexOf(sepDir + path.sep);
57
+ if (idx === -1 && resolvedPath.endsWith(sepDir)) {
58
+ idx = resolvedPath.length - sepDir.length;
59
+ }
60
+ if (idx === -1) return null;
61
+ return resolvedPath.slice(0, idx + sepDir.length);
62
+ }
63
+
64
+ /**
65
+ * Return the single global mindlore DB path.
66
+ * v0.3.3: multi-DB layered search removed — single global DB with project column.
67
+ */
68
+ function getAllDbs() {
69
+ const dbPath = path.join(globalDir(), DB_NAME);
70
+ if (fs.existsSync(dbPath)) return [dbPath];
71
+ return [];
72
+ }
73
+
74
+ /**
75
+ * Get current project name from CWD basename.
76
+ * Used as the `project` column value in FTS5.
77
+ */
78
+ function getProjectName() {
79
+ return path.basename(process.cwd());
80
+ }
81
+
82
+ function resolveProject(ftsProject, filePath, cwdFallback) {
83
+ if (ftsProject) return ftsProject;
84
+ const normalized = filePath.replace(/\\/g, '/');
85
+ const sessionMatch = normalized.match(/raw\/sessions\/([^/]+)\//);
86
+ if (sessionMatch) return sessionMatch[1];
87
+ const diaryMatch = normalized.match(/diary\/([^/]+)\//);
88
+ if (diaryMatch) return diaryMatch[1];
89
+ return cwdFallback;
90
+ }
91
+
92
+ function getLatestDelta(diaryDir) {
93
+ if (!fs.existsSync(diaryDir)) return null;
94
+
95
+ const deltas = fs
96
+ .readdirSync(diaryDir)
97
+ .filter((f) => f.startsWith('delta-') && f.endsWith('.md'))
98
+ .sort()
99
+ .reverse();
100
+
101
+ if (deltas.length === 0) return null;
102
+ return path.join(diaryDir, deltas[0]);
103
+ }
104
+
105
+ function sha256(content) {
106
+ return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
107
+ }
108
+
109
+ /**
110
+ * Parse YAML frontmatter from markdown content.
111
+ * Returns { meta: { key: value }, body: string }
112
+ */
113
+ function parseFrontmatter(content) {
114
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
115
+ if (!match) return { meta: {}, body: content };
116
+
117
+ const meta = Object.create(null);
118
+ const lines = match[1].split('\n');
119
+ for (const line of lines) {
120
+ const colonIdx = line.indexOf(':');
121
+ if (colonIdx === -1) continue;
122
+ const key = line.slice(0, colonIdx).trim();
123
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
124
+ let value = line.slice(colonIdx + 1).trim();
125
+ if (value.startsWith('[') && value.endsWith(']')) {
126
+ value = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''));
127
+ }
128
+ if (typeof value === 'string') {
129
+ value = value.replace(/^["']|["']$/g, '');
130
+ }
131
+ meta[key] = value;
132
+ }
133
+
134
+ const bodyStart = content.indexOf('---', 3);
135
+ const body = bodyStart !== -1 ? content.slice(bodyStart + 3).replace(/^\r?\n/, '') : content;
136
+
137
+ return { meta, body };
138
+ }
139
+
140
+ /**
141
+ * Extract FTS5 metadata from parsed frontmatter + file path.
142
+ * Returns { slug, description, type, category, title }
143
+ */
144
+ function extractFtsMetadata(meta, body, filePath, baseDir) {
145
+ const slug = meta.slug || path.basename(filePath, '.md');
146
+ const description = meta.description || '';
147
+ const type = meta.type || '';
148
+ const relativePath = baseDir ? path.relative(baseDir, filePath) : filePath;
149
+ const category = path.dirname(relativePath).split(path.sep)[0] || 'root';
150
+ let title = meta.title || meta.name || '';
151
+ if (!title) {
152
+ const headingMatch = body.match(/^#\s+(.+)/m);
153
+ title = headingMatch ? headingMatch[1].trim() : path.basename(filePath, '.md');
154
+ }
155
+ let tags = '';
156
+ if (meta.tags) {
157
+ tags = Array.isArray(meta.tags) ? meta.tags.join(', ') : String(meta.tags);
158
+ }
159
+ const quality = meta.quality !== undefined && meta.quality !== null ? meta.quality : null;
160
+ const dateCaptured = meta.date_captured || meta.date || null;
161
+ const project = meta.project || null;
162
+ return { slug, description, type, category, title, tags, quality, dateCaptured, project };
163
+ }
164
+
165
+ /**
166
+ * Shared SQL constants to prevent drift across indexing paths.
167
+ */
168
+ const SQL_FTS_CREATE =
169
+ "CREATE VIRTUAL TABLE IF NOT EXISTS mindlore_fts USING fts5(path UNINDEXED, slug, description, type UNINDEXED, category, title, content, tags, quality UNINDEXED, date_captured UNINDEXED, project UNINDEXED, tokenize='porter unicode61')";
170
+
171
+ const SQL_FTS_INSERT =
172
+ 'INSERT INTO mindlore_fts (path, slug, description, type, category, title, content, tags, quality, date_captured, project) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
173
+
174
+ const SQL_FTS_TRIGRAM_INSERT =
175
+ 'INSERT INTO mindlore_fts_trigram (path, slug, description, type, category, title, content, tags, quality, date_captured, project) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
176
+
177
+ const SQL_FTS_SESSIONS_CREATE =
178
+ "CREATE VIRTUAL TABLE IF NOT EXISTS mindlore_fts_sessions USING fts5(path, slug, description, type, category, title, content, tags, quality, date_captured, project)";
179
+
180
+ const SQL_FTS_SESSIONS_INSERT =
181
+ 'INSERT INTO mindlore_fts_sessions (path, slug, description, type, category, title, content, tags, quality, date_captured, project) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
182
+
183
+ const SESSION_CATEGORIES = ['cc-subagent', 'cc-session'];
184
+
185
+ function isSessionCategory(category) {
186
+ return SESSION_CATEGORIES.includes(category);
187
+ }
188
+
189
+ const VERSION_RE = /v(\d+)\.(\d+)(?:\.(\d+))?/g;
190
+ function fixVersionTokens(query) {
191
+ return query.replace(VERSION_RE, (_m, a, b, c) => c ? `"v${a} ${b} ${c}"` : `"v${a} ${b}"`);
192
+ }
193
+
194
+ /**
195
+ * Insert a row into FTS5 using an object parameter (replaces positional args).
196
+ */
197
+ function insertFtsRow(db, entry) {
198
+ const vals = [
199
+ entry.path || '',
200
+ entry.slug || '',
201
+ entry.description || '',
202
+ entry.type || '',
203
+ entry.category || '',
204
+ entry.title || '',
205
+ entry.content || '',
206
+ entry.tags || '',
207
+ entry.quality || null,
208
+ entry.dateCaptured || null,
209
+ entry.project || null,
210
+ ];
211
+ db.prepare(SQL_FTS_INSERT).run(...vals);
212
+ try {
213
+ db.prepare(SQL_FTS_TRIGRAM_INSERT).run(...vals);
214
+ } catch (_err) {
215
+ // trigram table may not exist yet (pre-v0.6.3)
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Extract headings (h1-h3) from markdown content.
221
+ */
222
+ function extractHeadings(content, max) {
223
+ const headings = [];
224
+ for (const line of content.split('\n')) {
225
+ if (/^#{1,3}\s/.test(line)) {
226
+ headings.push(line.replace(/^#+\s*/, '').trim());
227
+ if (headings.length >= max) break;
228
+ }
229
+ }
230
+ return headings;
231
+ }
232
+
233
+ function requireDatabase() {
234
+ try {
235
+ return require('better-sqlite3');
236
+ } catch (_err) {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ function openDatabase(dbPath, opts) {
242
+ try {
243
+ const Database = requireDatabase();
244
+ if (!Database) return null;
245
+ if (!fs.existsSync(dbPath)) return null;
246
+ const readonly = opts?.readonly ?? false;
247
+ const db = new Database(dbPath, { readonly });
248
+ if (!readonly) {
249
+ db.pragma('journal_mode = WAL');
250
+ db.pragma(`busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
251
+ }
252
+ return db;
253
+ } catch (_err) {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ function getAllMdFiles(dir, skip) {
259
+ const skipSet = skip || SKIP_FILES;
260
+ const results = [];
261
+ if (!fs.existsSync(dir)) return results;
262
+
263
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
264
+ for (const entry of entries) {
265
+ const fullPath = path.join(dir, entry.name);
266
+ if (entry.isDirectory()) {
267
+ results.push(...getAllMdFiles(fullPath, skipSet));
268
+ } else if (entry.name.endsWith('.md') && !skipSet.has(entry.name)) {
269
+ results.push(fullPath);
270
+ }
271
+ }
272
+ return results;
273
+ }
274
+
275
+ /**
276
+ * Read CC hook stdin and parse JSON envelope.
277
+ * Returns the value of the first matching field, or raw text as fallback.
278
+ * @param {string[]} fields - Priority-ordered field names to extract
279
+ */
280
+ function readHookStdin(fields) {
281
+ let input = '';
282
+ try {
283
+ input = fs.readFileSync(0, 'utf8').trim();
284
+ } catch (_err) {
285
+ return '';
286
+ }
287
+ if (!input) return '';
288
+ try {
289
+ const parsed = JSON.parse(input);
290
+ for (const f of fields) {
291
+ if (parsed[f]) return parsed[f];
292
+ }
293
+ } catch (_err) {
294
+ // plain text
295
+ }
296
+ return input;
297
+ }
298
+
299
+ /**
300
+ * Read .mindlore/config.json and return parsed object.
301
+ * Returns null if file doesn't exist or is invalid JSON.
302
+ */
303
+ function readConfig(mindloreDir) {
304
+ if (!mindloreDir) return null;
305
+ const configPath = path.join(mindloreDir, 'config.json');
306
+ try {
307
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
308
+ } catch (_err) {
309
+ return null;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Detect FTS5 schema version by probing columns.
315
+ * FTS5 virtual tables don't support PRAGMA table_info, so try/catch is required.
316
+ * @param {import('better-sqlite3').Database} db
317
+ * @returns {number} 2 | 7 | 9 | 10
318
+ */
319
+ function detectSchemaVersion(db) {
320
+ try {
321
+ db.prepare('SELECT tags, quality, date_captured, project FROM mindlore_fts LIMIT 0').run();
322
+ return 11;
323
+ } catch (_err11) {
324
+ try {
325
+ db.prepare('SELECT tags, quality, date_captured FROM mindlore_fts LIMIT 0').run();
326
+ return 10;
327
+ } catch (_err10) {
328
+ try {
329
+ db.prepare('SELECT tags, quality FROM mindlore_fts LIMIT 0').run();
330
+ return 9;
331
+ } catch (_err9) {
332
+ try {
333
+ db.prepare('SELECT slug, description, category, title FROM mindlore_fts LIMIT 0').run();
334
+ return 7;
335
+ } catch (_err7) {
336
+ return 2;
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ const DEFAULT_MODELS = {
344
+ ingest: 'haiku',
345
+ evolve: 'sonnet',
346
+ explore: 'sonnet',
347
+ default: 'haiku',
348
+ };
349
+
350
+ // ── Episodes (v0.4.0) ─────────────────────────────────────────────
351
+
352
+ const SQL_EPISODES_CREATE = `
353
+ CREATE TABLE IF NOT EXISTS episodes (
354
+ id TEXT PRIMARY KEY,
355
+ kind TEXT NOT NULL,
356
+ scope TEXT NOT NULL DEFAULT 'project',
357
+ project TEXT,
358
+ summary TEXT NOT NULL,
359
+ body TEXT,
360
+ tags TEXT,
361
+ entities TEXT,
362
+ parent_id TEXT,
363
+ status TEXT NOT NULL DEFAULT 'active',
364
+ supersedes TEXT,
365
+ source TEXT,
366
+ created_at TEXT NOT NULL
367
+ )`;
368
+
369
+ // ~625 tokens context budget for multi-session inject (~4 chars/token)
370
+ const MULTI_SESSION_TOKEN_CAP_CHARS = 2500;
371
+
372
+ /**
373
+ * Valid episode statuses. CO-EVOLUTION: mirrors EPISODE_STATUSES in scripts/lib/episodes.ts
374
+ */
375
+ const EPISODE_STATUSES_CJS = ['active', 'superseded', 'deleted', 'staged', 'reviewed', 'approved', 'rejected'];
376
+
377
+ const SQL_EPISODES_INDEXES = [
378
+ 'CREATE INDEX IF NOT EXISTS idx_episodes_kind ON episodes(kind, status)',
379
+ 'CREATE INDEX IF NOT EXISTS idx_episodes_project ON episodes(project, status)',
380
+ 'CREATE INDEX IF NOT EXISTS idx_episodes_created ON episodes(created_at DESC)',
381
+ ];
382
+
383
+ /**
384
+ * Ensure episodes table + indexes exist. Idempotent.
385
+ * @param {import('better-sqlite3').Database} db
386
+ */
387
+ function ensureEpisodesTable(db) {
388
+ db.exec(SQL_EPISODES_CREATE);
389
+ for (const idx of SQL_EPISODES_INDEXES) {
390
+ db.exec(idx);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Check if episodes table exists in the database.
396
+ * @param {import('better-sqlite3').Database} db
397
+ * @returns {boolean}
398
+ */
399
+ function hasEpisodesTable(db) {
400
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='episodes'").get();
401
+ return row !== undefined;
402
+ }
403
+
404
+ /**
405
+ * Generate a time-sortable episode ID.
406
+ * Format: ep-{base36-timestamp}-{12-hex-random}
407
+ */
408
+ function generateEpisodeId() {
409
+ const timestamp = Date.now().toString(36);
410
+ const random = crypto.randomBytes(6).toString('hex');
411
+ return `ep-${timestamp}-${random}`;
412
+ }
413
+
414
+ /**
415
+ * Insert a bare episode from hook context (no LLM needed).
416
+ * @param {import('better-sqlite3').Database} db
417
+ * @param {object} entry
418
+ * @returns {string} episode id
419
+ */
420
+ function insertBareEpisode(db, entry) {
421
+ const id = entry.id || generateEpisodeId();
422
+ const now = new Date().toISOString();
423
+
424
+ db.prepare(`
425
+ INSERT INTO episodes (id, kind, scope, project, summary, body, tags, entities, parent_id, status, supersedes, source, created_at)
426
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
427
+ `).run(
428
+ id,
429
+ entry.kind || 'session',
430
+ entry.scope || 'project',
431
+ entry.project || null,
432
+ entry.summary || '',
433
+ entry.body || null,
434
+ entry.tags || null,
435
+ entry.entities ? JSON.stringify(entry.entities) : null,
436
+ entry.parent_id || null,
437
+ entry.supersedes || null,
438
+ entry.source || 'hook',
439
+ now,
440
+ );
441
+
442
+ return id;
443
+ }
444
+
445
+ /**
446
+ * Query recent episodes for session-focus injection.
447
+ * @param {import('better-sqlite3').Database} db
448
+ * @param {object} opts - { project, limit, maxChars }
449
+ * @returns {Array<{kind: string, summary: string, created_at: string}>}
450
+ */
451
+ function queryRecentEpisodes(db, opts) {
452
+ const project = opts.project || null;
453
+ const limit = opts.limit || 3;
454
+
455
+ const hasConsolCol = db.pragma('table_info(episodes)').some(c => c.name === 'consolidation_status');
456
+ const consolFilter = hasConsolCol ? " AND (consolidation_status IS NULL OR consolidation_status != 'consolidated')" : '';
457
+ let sql = `SELECT kind, summary, created_at FROM episodes WHERE status = 'active'${consolFilter}`;
458
+ const params = [];
459
+
460
+ if (project) {
461
+ sql += ' AND project = ?';
462
+ params.push(project);
463
+ }
464
+
465
+ sql += ' ORDER BY created_at DESC LIMIT ?';
466
+ params.push(limit);
467
+
468
+ return db.prepare(sql).all(...params);
469
+ }
470
+
471
+ /**
472
+ * Query superseded episode chains for session-focus display.
473
+ * CO-EVOLUTION: Uses episodes table schema from SQL_EPISODES_CREATE
474
+ * @param {import('better-sqlite3').Database} db
475
+ * @param {{ project: string, days?: number, limit?: number }} opts
476
+ * @returns {Array<{current: string, previous: string, reason: string|null}>}
477
+ */
478
+ function querySupersededChains(db, opts) {
479
+ const days = opts.days ?? 7;
480
+ const limit = opts.limit ?? 5;
481
+ const modifier = `-${days} days`;
482
+ const rows = db.prepare(`
483
+ SELECT new_ep.summary AS current_summary, old_ep.summary AS previous_summary, new_ep.body
484
+ FROM episodes new_ep
485
+ JOIN episodes old_ep ON new_ep.supersedes = old_ep.id
486
+ WHERE new_ep.project = ?
487
+ AND new_ep.created_at > datetime('now', ?)
488
+ AND old_ep.status = 'superseded'
489
+ ORDER BY new_ep.created_at DESC
490
+ LIMIT ?
491
+ `).all(opts.project, modifier, limit);
492
+
493
+ return rows.map(row => ({
494
+ current: row.current_summary,
495
+ previous: row.previous_summary,
496
+ reason: parseReason(row.body),
497
+ }));
498
+ }
499
+
500
+ /**
501
+ * Parse ## Reason section from episode body.
502
+ * @param {string|null} body
503
+ * @returns {string|null}
504
+ */
505
+ function parseReason(body) {
506
+ if (!body) return null;
507
+ const match = body.match(/## Reason\n(.+?)(?:\n##|\n*$)/s);
508
+ if (!match) return null;
509
+ return match[1].trim().split('\n')[0];
510
+ }
511
+
512
+ /**
513
+ * Format superseded chains for session-focus inject.
514
+ * @param {Array<{current: string, previous: string, reason: string|null}>} chains
515
+ * @returns {string}
516
+ */
517
+ function formatSupersededChains(chains) {
518
+ if (chains.length === 0) return '';
519
+ const lines = chains.map(c => {
520
+ const base = `- ${c.current} \u2190 ${c.previous}`;
521
+ return c.reason ? `${base} (Reason: ${c.reason})` : base;
522
+ });
523
+ return lines.join('\n');
524
+ }
525
+
526
+ /**
527
+ * Query episodes across multiple sessions for enriched inject.
528
+ * Excludes bare session episodes and nominations.
529
+ * CO-EVOLUTION: Uses episodes table schema from SQL_EPISODES_CREATE
530
+ * @param {import('better-sqlite3').Database} db
531
+ * @param {{ project: string, days?: number, limit?: number }} opts
532
+ */
533
+ function queryMultiSessionEpisodes(db, opts) {
534
+ const days = opts.days ?? 3;
535
+ const limit = opts.limit ?? 20;
536
+ const modifier = `-${days} days`;
537
+ return db.prepare(`
538
+ SELECT kind, summary, created_at
539
+ FROM episodes
540
+ WHERE project = ?
541
+ AND status = 'active'
542
+ AND kind != 'session'
543
+ AND kind != 'nomination'
544
+ AND created_at > datetime('now', ?)
545
+ ORDER BY created_at DESC
546
+ LIMIT ?
547
+ `).all(opts.project, modifier, limit);
548
+ }
549
+
550
+ /**
551
+ * Format multi-session episodes for inject.
552
+ * Groups by date. If too many per date, collapses to count-per-kind.
553
+ * @param {Array<{kind: string, summary: string, created_at: string}>} episodes
554
+ * @returns {string}
555
+ */
556
+ function formatMultiSessionEpisodes(episodes) {
557
+ if (episodes.length === 0) return '';
558
+
559
+ const byDate = {};
560
+ for (const ep of episodes) {
561
+ const date = (ep.created_at || '').slice(0, 10);
562
+ if (!byDate[date]) byDate[date] = [];
563
+ byDate[date].push(ep);
564
+ }
565
+
566
+ const lines = [];
567
+ let totalChars = 0;
568
+
569
+ for (const [date, eps] of Object.entries(byDate).sort((a, b) => b[0].localeCompare(a[0]))) {
570
+ if (totalChars > MULTI_SESSION_TOKEN_CAP_CHARS) break;
571
+ if (eps.length <= 5) {
572
+ for (const ep of eps) {
573
+ const line = `- [${date}] ${ep.kind}: ${String(ep.summary).slice(0, 100)}`;
574
+ totalChars += line.length;
575
+ if (totalChars > MULTI_SESSION_TOKEN_CAP_CHARS) break;
576
+ lines.push(line);
577
+ }
578
+ } else {
579
+ const kindCounts = {};
580
+ for (const ep of eps) {
581
+ kindCounts[ep.kind] = (kindCounts[ep.kind] || 0) + 1;
582
+ }
583
+ const counts = Object.entries(kindCounts).map(([k, c]) => `${c} ${k}`).join(', ');
584
+ lines.push(`- [${date}] ${counts}`);
585
+ }
586
+ }
587
+
588
+ return lines.join('\n');
589
+ }
590
+
591
+ // Shared FTS5 search utilities (used by mindlore-search + mindlore-research-guard)
592
+ const SHARED_STOP_WORDS = (() => {
593
+ try {
594
+ const { STOP_WORDS, STOP_WORDS_MIN_LENGTH } = require('../../dist/scripts/lib/constants.js');
595
+ return { STOP_WORDS, MIN_LENGTH: STOP_WORDS_MIN_LENGTH };
596
+ } catch {
597
+ return null;
598
+ }
599
+ })();
600
+
601
+ if (!SHARED_STOP_WORDS) {
602
+ hookLog('common', 'warn', 'STOP_WORDS fallback active — run npm run build');
603
+ }
604
+
605
+ const STOP_WORDS = SHARED_STOP_WORDS?.STOP_WORDS ?? new Set(['the', 'a', 'an', 'is', 'are', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'bir', 'bu', 'de', 'da', 've']);
606
+ const STOP_WORDS_MIN_LENGTH = SHARED_STOP_WORDS?.MIN_LENGTH ?? 2;
607
+
608
+ /**
609
+ * Extract topic keywords from text. Preserves Turkish chars.
610
+ * @param {string} text - Input text
611
+ * @param {number} [maxKeywords=8] - Max keywords to return
612
+ * @returns {string[]} Unique keywords
613
+ */
614
+ function extractKeywords(text, maxKeywords = 8) {
615
+ const words = text
616
+ .toLowerCase()
617
+ .replace(/[^\w\s\u00e7\u011f\u0131\u00f6\u015f\u00fc-]/g, ' ')
618
+ .split(/\s+/)
619
+ .filter((w) => w.length >= STOP_WORDS_MIN_LENGTH && !STOP_WORDS.has(w) && !/^\d+$/.test(w));
620
+ return [...new Set(words)].slice(0, maxKeywords);
621
+ }
622
+
623
+ /**
624
+ * Sanitize keyword for FTS5 MATCH — strip special chars, quote-wrap.
625
+ * @param {string} kw - Raw keyword
626
+ * @returns {string|null} Quoted keyword or null if too short
627
+ */
628
+ function sanitizeKeyword(kw) {
629
+ const clean = kw.replace(/["*(){}[\]^~:]/g, '').replace(/-/g, ' ').trim();
630
+ return clean.length >= 2 ? `"${clean}"` : null;
631
+ }
632
+
633
+ // Derive from compiled constants — single source of truth
634
+ const SHARED_EXPORT_DIRS = (() => {
635
+ try {
636
+ const { DIRECTORIES } = require('../../dist/scripts/lib/constants.js');
637
+ return [...DIRECTORIES, 'memory'];
638
+ } catch {
639
+ return ['raw', 'sources', 'domains', 'analyses', 'insights', 'connections', 'learnings', 'diary', 'decisions', 'memory'];
640
+ }
641
+ })();
642
+
643
+ function resolveWin32Bin(name) {
644
+ if (process.platform === 'win32') {
645
+ try {
646
+ return require('child_process')
647
+ .execSync(`where ${name}`, { encoding: 'utf8', timeout: 3000, windowsHide: true })
648
+ .trim().split('\n')[0].trim();
649
+ } catch (_e) { /* fall through */ }
650
+ }
651
+ return name;
652
+ }
653
+
654
+ // Import from compiled TS — single source of truth
655
+ const extractSkeleton = (() => {
656
+ try {
657
+ return require('../../dist/scripts/lib/skeleton.js').extractSkeleton;
658
+ } catch {
659
+ // Fallback: identity function if dist not built
660
+ return (content) => content;
661
+ }
662
+ })();
663
+
664
+ const TELEMETRY_KEEP_LINES = 200;
665
+
666
+ function _rotateFile(filePath, maxBytes, keepLines) {
667
+ try {
668
+ const stat = fs.statSync(filePath);
669
+ if (stat.size > maxBytes) {
670
+ const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
671
+ const tmpPath = filePath + '.tmp';
672
+ safeWriteFile(tmpPath, lines.slice(-keepLines).join('\n') + '\n');
673
+ fs.renameSync(tmpPath, filePath);
674
+ }
675
+ } catch { /* file may not exist yet */ }
676
+ }
677
+
678
+ let _telDirEnsured = false;
679
+
680
+ function _writeTelemetry({ hookName, duration_ms, ok, extra }) {
681
+ try {
682
+ if (!_telDirEnsured) {
683
+ safeMkdir(GLOBAL_MINDLORE_DIR);
684
+ _telDirEnsured = true;
685
+ }
686
+ const telPath = path.join(GLOBAL_MINDLORE_DIR, 'telemetry.jsonl');
687
+ const entry = { ts: new Date().toISOString(), hook: hookName, duration_ms, ok };
688
+ if (extra && typeof extra === 'object') {
689
+ for (const key of ['inject_tokens', 'source_tokens', 'injected_tokens', 'full_read_tokens']) {
690
+ if (typeof extra[key] === 'number') entry[key] = extra[key];
691
+ }
692
+ }
693
+ const line = JSON.stringify(entry) + '\n';
694
+ _rotateFile(telPath, HOOK_LOG_MAX_BYTES, TELEMETRY_KEEP_LINES);
695
+ fs.appendFileSync(telPath, line);
696
+ } catch { /* silent — telemetry must never crash hook */ }
697
+ }
698
+
699
+ async function withTelemetry(hookName, fn) {
700
+ const start = Date.now();
701
+ let ok = true;
702
+ let result;
703
+ let thrown;
704
+ try {
705
+ result = await fn();
706
+ } catch (err) {
707
+ ok = false;
708
+ thrown = err;
709
+ }
710
+ const extra = (result && typeof result === 'object') ? result : undefined;
711
+ _writeTelemetry({ hookName, duration_ms: Date.now() - start, ok, extra });
712
+ if (thrown) throw thrown;
713
+ return result;
714
+ }
715
+
716
+ function withTelemetrySync(hookName, fn) {
717
+ const start = Date.now();
718
+ let ok = true;
719
+ let result;
720
+ let thrown;
721
+ try {
722
+ result = fn();
723
+ } catch (err) {
724
+ ok = false;
725
+ thrown = err;
726
+ }
727
+ const extra = (result && typeof result === 'object') ? result : undefined;
728
+ _writeTelemetry({ hookName, duration_ms: Date.now() - start, ok, extra });
729
+ if (thrown) throw thrown;
730
+ return result;
731
+ }
732
+
733
+ function withTimeoutDb(db, sql, params = [], { timeoutMs = DB_BUSY_TIMEOUT_MS, mode = 'all' } = {}) {
734
+ if (!db) return mode === 'get' ? undefined : [];
735
+ try {
736
+ db.pragma(`busy_timeout = ${timeoutMs}`);
737
+ const stmt = db.prepare(sql);
738
+ if (mode === 'get') {
739
+ return params.length > 0 ? stmt.get(...params) : stmt.get();
740
+ }
741
+ return params.length > 0 ? stmt.all(...params) : stmt.all();
742
+ } catch (err) {
743
+ hookLog('timeout', 'warn', `DB query timeout/error: ${err.message}`);
744
+ _writeTelemetry({ hookName: 'db_timeout', duration_ms: 0, ok: false });
745
+ return mode === 'get' ? undefined : [];
746
+ }
747
+ }
748
+
749
+ function getNominationCounts(db, project) {
750
+ try {
751
+ const row = withTimeoutDb(db,
752
+ `SELECT
753
+ SUM(CASE WHEN status='staged' THEN 1 ELSE 0 END) AS staged,
754
+ SUM(CASE WHEN status='approved' AND graduated_at IS NOT NULL THEN 1 ELSE 0 END) AS graduated
755
+ FROM episodes
756
+ WHERE kind='nomination' AND project=?`,
757
+ [project], { mode: 'get' });
758
+ return { staged: row?.staged ?? 0, graduated: row?.graduated ?? 0 };
759
+ } catch (_err) { return { staged: 0, graduated: 0 }; }
760
+ }
761
+
762
+ function cleanupExpiredInjectLog(db, ttlMs) {
763
+ if (ttlMs === undefined) ttlMs = 30 * 24 * 60 * 60 * 1000;
764
+ try {
765
+ const cutoff = new Date(Date.now() - ttlMs).toISOString();
766
+ const result = db.prepare('DELETE FROM episode_inject_log WHERE injected_at < ?').run(cutoff);
767
+ return result.changes;
768
+ } catch (_err) { /* table may not exist */ }
769
+ return 0;
770
+ }
771
+
772
+ module.exports = {
773
+ MINDLORE_DIR,
774
+ GLOBAL_MINDLORE_DIR,
775
+ globalDir,
776
+ DB_NAME,
777
+ SKIP_FILES,
778
+ findMindloreDir,
779
+ getActiveMindloreDir,
780
+ getAllDbs,
781
+ getLatestDelta,
782
+ sha256,
783
+ parseFrontmatter,
784
+ extractFtsMetadata,
785
+ readHookStdin,
786
+ SQL_FTS_CREATE,
787
+ SQL_FTS_INSERT,
788
+ SQL_FTS_TRIGRAM_INSERT,
789
+ SQL_FTS_SESSIONS_CREATE,
790
+ SQL_FTS_SESSIONS_INSERT,
791
+ SESSION_CATEGORIES,
792
+ isSessionCategory,
793
+ fixVersionTokens,
794
+ insertFtsRow,
795
+ extractHeadings,
796
+ requireDatabase,
797
+ openDatabase,
798
+ getAllMdFiles,
799
+ readConfig,
800
+ detectSchemaVersion,
801
+ getProjectName,
802
+ resolveProject,
803
+ DEFAULT_MODELS,
804
+ // Episodes (v0.4.1)
805
+ EPISODE_KINDS,
806
+ EPISODE_KINDS_CJS: EPISODE_KINDS,
807
+ isValidKind,
808
+ EPISODE_STATUSES_CJS,
809
+ SQL_EPISODES_CREATE,
810
+ SQL_EPISODES_INDEXES,
811
+ ensureEpisodesTable,
812
+ hasEpisodesTable,
813
+ generateEpisodeId,
814
+ insertBareEpisode,
815
+ queryRecentEpisodes,
816
+ querySupersededChains,
817
+ formatSupersededChains,
818
+ queryMultiSessionEpisodes,
819
+ formatMultiSessionEpisodes,
820
+ // FTS5 search utilities (v0.4.3)
821
+ STOP_WORDS,
822
+ extractKeywords,
823
+ sanitizeKeyword,
824
+ // Hook logging (v0.5.1)
825
+ hookLog,
826
+ getRecentHookErrors,
827
+ // Shared helpers (v0.5.1)
828
+ SHARED_EXPORT_DIRS,
829
+ resolveWin32Bin,
830
+ // Skeleton compression (v0.5.2)
831
+ extractSkeleton,
832
+ // Recall telemetry (v0.5.3)
833
+ incrementRecallCount,
834
+ _rotateFile,
835
+ isInsideMindloreDir,
836
+ extractMindloreBaseDir,
837
+ // Telemetry (v0.6.0)
838
+ withTelemetry,
839
+ withTelemetrySync,
840
+ // DB timeout wrapper (v0.6.1)
841
+ withTimeoutDb,
842
+ // Raw file helpers (v0.6.3)
843
+ getUnpromotedRawFiles,
844
+ // Snapshot helpers (v0.6.3)
845
+ listSnapshots,
846
+ getLatestSnapshot,
847
+ // DB corruption recovery (v0.6.3)
848
+ isCorruptionError,
849
+ recoverCorruptDb,
850
+ // Lesson graduation (v0.6.7)
851
+ getNominationCounts,
852
+ cleanupExpiredInjectLog,
853
+ };
854
+
855
+ function isCorruptionError(err) {
856
+ const code = err?.code ?? '';
857
+ const msg = String(err?.message ?? err);
858
+ return code === 'SQLITE_CORRUPT' || code === 'SQLITE_NOTADB' || /corrupt|malformed/i.test(msg);
859
+ }
860
+
861
+ function recoverCorruptDb(db, dbPath, hookName) {
862
+ try { db.close(); } catch { /* already closed */ }
863
+ const bakPath = dbPath + '.corrupt.bak';
864
+ try { fs.copyFileSync(dbPath, bakPath); } catch { /* best effort */ }
865
+ try { fs.unlinkSync(dbPath); } catch { /* best effort */ }
866
+ hookLog(hookName, 'warn', 'corrupt DB detected, backed up and removed: ' + dbPath);
867
+ }
868
+
869
+ function listSnapshots(diaryDir) {
870
+ if (!fs.existsSync(diaryDir)) return [];
871
+ return fs.readdirSync(diaryDir)
872
+ .filter(f => f.startsWith('delta-') || f.startsWith('compaction-'))
873
+ .sort();
874
+ }
875
+
876
+ function getLatestSnapshot(diaryDir) {
877
+ const files = listSnapshots(diaryDir);
878
+ return files.length > 0 ? files[files.length - 1] : null;
879
+ }
880
+
881
+ function getUnpromotedRawFiles(baseDir) {
882
+ const rawDir = path.join(baseDir, 'raw');
883
+ const sourcesDir = path.join(baseDir, 'sources');
884
+ if (!fs.existsSync(rawDir)) return [];
885
+ const sourceNames = fs.existsSync(sourcesDir)
886
+ ? new Set(fs.readdirSync(sourcesDir).filter(f => f.endsWith('.md')))
887
+ : new Set();
888
+ return fs.readdirSync(rawDir).filter(f => f.endsWith('.md') && !sourceNames.has(f));
889
+ }
890
+
891
+
892
+ /**
893
+ * Increment recall_count and update last_recalled_at for a file in file_hashes.
894
+ * No-op if column missing (old DB without v0.5.3 migration).
895
+ * @param {import('better-sqlite3').Database} db
896
+ * @param {string} filePath
897
+ */
898
+ const _recallColCache = new WeakMap();
899
+ function incrementRecallCount(db, filePath) {
900
+ try {
901
+ let hasCol = _recallColCache.get(db);
902
+ if (hasCol === undefined) {
903
+ hasCol = db.pragma('table_info(file_hashes)').some(c => c.name === 'recall_count');
904
+ _recallColCache.set(db, hasCol);
905
+ }
906
+ if (!hasCol) return;
907
+ db.prepare(`
908
+ UPDATE file_hashes
909
+ SET recall_count = COALESCE(recall_count, 0) + 1,
910
+ last_recalled_at = ?
911
+ WHERE path = ?
912
+ `).run(new Date().toISOString(), filePath);
913
+ } catch (_err) { /* graceful — old DB without columns */ }
914
+ }
915
+
916
+ // --- Hook Logging (v0.5.1) ---
917
+
918
+ function hookLogPath() { return path.join(globalDir(), 'diary', '_hook-log.jsonl'); }
919
+
920
+ /**
921
+ * Append a structured log entry for any mindlore hook.
922
+ * JSONL format — one JSON object per line.
923
+ * Levels: 'info' | 'warn' | 'error'
924
+ * @param {string} hook - Hook name (e.g. 'session-end', 'search', 'read-guard')
925
+ * @param {'info'|'warn'|'error'} level
926
+ * @param {string} message
927
+ */
928
+ const HOOK_LOG_MAX_BYTES = 512 * 1024; // 500KB
929
+ const HOOK_LOG_KEEP_LINES = 500;
930
+
931
+ function hookLog(hook, level, message) {
932
+ try {
933
+ const logFile = hookLogPath();
934
+ const entry = JSON.stringify({
935
+ ts: new Date().toISOString(),
936
+ hook,
937
+ level,
938
+ msg: message,
939
+ pid: process.pid,
940
+ });
941
+ _rotateFile(logFile, HOOK_LOG_MAX_BYTES, HOOK_LOG_KEEP_LINES);
942
+ fs.appendFileSync(logFile, entry + '\n');
943
+ } catch (_err) {
944
+ // Best effort — never crash a hook for logging
945
+ }
946
+ }
947
+
948
+ /**
949
+ * Read recent hook errors/warnings since a given ISO date.
950
+ * Returns array of { ts, hook, level, msg } for level 'error' or 'warn'.
951
+ * Used by SessionStart to inject warnings into CC context.
952
+ * @param {string} [since] - ISO date string, defaults to 24h ago
953
+ * @param {number} [limit=10]
954
+ * @returns {Array<{ts: string, hook: string, level: string, msg: string}>}
955
+ */
956
+ function getRecentHookErrors(since, limit) {
957
+ const maxEntries = limit ?? 10;
958
+ const cutoff = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
959
+ const results = [];
960
+ try {
961
+ if (!fs.existsSync(hookLogPath())) return results;
962
+ const lines = fs.readFileSync(hookLogPath(), 'utf8').trim().split('\n');
963
+ for (let i = lines.length - 1; i >= 0 && results.length < maxEntries; i--) {
964
+ if (!lines[i]) continue;
965
+ try {
966
+ const entry = JSON.parse(lines[i]);
967
+ if (entry.ts < cutoff) break; // JSONL is chronological, stop early
968
+ if (entry.level === 'error' || entry.level === 'warn') {
969
+ results.push(entry);
970
+ }
971
+ } catch (_parseErr) { /* skip malformed lines */ }
972
+ }
973
+ } catch (_err) { /* silent */ }
974
+ return results.reverse(); // chronological order
975
+ }