mindlore 0.3.5 → 0.4.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 (52) hide show
  1. package/README.md +9 -6
  2. package/dist/scripts/init.js +9 -1
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/scripts/lib/episodes.d.ts +66 -0
  5. package/dist/scripts/lib/episodes.d.ts.map +1 -0
  6. package/dist/scripts/lib/episodes.js +180 -0
  7. package/dist/scripts/lib/episodes.js.map +1 -0
  8. package/dist/scripts/mindlore-episodes.d.ts +12 -0
  9. package/dist/scripts/mindlore-episodes.d.ts.map +1 -0
  10. package/dist/scripts/mindlore-episodes.js +193 -0
  11. package/dist/scripts/mindlore-episodes.js.map +1 -0
  12. package/dist/tests/diary.test.d.ts +6 -0
  13. package/dist/tests/diary.test.d.ts.map +1 -0
  14. package/dist/tests/diary.test.js +169 -0
  15. package/dist/tests/diary.test.js.map +1 -0
  16. package/dist/tests/episodes-inject.test.d.ts +6 -0
  17. package/dist/tests/episodes-inject.test.d.ts.map +1 -0
  18. package/dist/tests/episodes-inject.test.js +161 -0
  19. package/dist/tests/episodes-inject.test.js.map +1 -0
  20. package/dist/tests/episodes.test.d.ts +5 -0
  21. package/dist/tests/episodes.test.d.ts.map +1 -0
  22. package/dist/tests/episodes.test.js +254 -0
  23. package/dist/tests/episodes.test.js.map +1 -0
  24. package/dist/tests/helpers/db.d.ts +7 -0
  25. package/dist/tests/helpers/db.d.ts.map +1 -1
  26. package/dist/tests/helpers/db.js +20 -1
  27. package/dist/tests/helpers/db.js.map +1 -1
  28. package/dist/tests/nomination.test.d.ts +2 -0
  29. package/dist/tests/nomination.test.d.ts.map +1 -0
  30. package/dist/tests/nomination.test.js +94 -0
  31. package/dist/tests/nomination.test.js.map +1 -0
  32. package/dist/tests/session-focus.test.js +82 -0
  33. package/dist/tests/session-focus.test.js.map +1 -1
  34. package/dist/tests/supersedes-chain.test.d.ts +2 -0
  35. package/dist/tests/supersedes-chain.test.d.ts.map +1 -0
  36. package/dist/tests/supersedes-chain.test.js +109 -0
  37. package/dist/tests/supersedes-chain.test.js.map +1 -0
  38. package/hooks/lib/mindlore-common.cjs +260 -1
  39. package/hooks/mindlore-index.cjs +17 -14
  40. package/hooks/mindlore-post-read.cjs +11 -2
  41. package/hooks/mindlore-pre-compact.cjs +2 -2
  42. package/hooks/mindlore-read-guard.cjs +12 -4
  43. package/hooks/mindlore-search.cjs +39 -1
  44. package/hooks/mindlore-session-end.cjs +105 -22
  45. package/hooks/mindlore-session-focus.cjs +56 -2
  46. package/package.json +1 -1
  47. package/plugin.json +8 -8
  48. package/skills/mindlore-evolve/SKILL.md +8 -4
  49. package/skills/mindlore-explore/SKILL.md +8 -4
  50. package/skills/mindlore-log/SKILL.md +134 -17
  51. package/skills/mindlore-query/SKILL.md +8 -5
  52. package/templates/config.json +5 -1
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const db_js_1 = require("./helpers/db.js");
4
+ const episodes_js_1 = require("../scripts/lib/episodes.js");
5
+ const { querySupersededChains, formatSupersededChains, } = require('../hooks/lib/mindlore-common.cjs');
6
+ let env;
7
+ let db;
8
+ beforeEach(() => {
9
+ env = (0, db_js_1.createEpisodesTestEnv)('supersedes-chain');
10
+ db = env.db;
11
+ });
12
+ afterEach(() => {
13
+ (0, db_js_1.destroyEpisodesTestEnv)(env);
14
+ });
15
+ describe('supersedes chain query', () => {
16
+ test('returns empty array when no superseded episodes', () => {
17
+ const result = querySupersededChains(db, { project: 'test-project' });
18
+ expect(result).toEqual([]);
19
+ });
20
+ test('returns chain when episode supersedes another', () => {
21
+ const old = (0, episodes_js_1.createEpisode)(db, {
22
+ kind: 'decision',
23
+ summary: 'JSONL kullanacagiz',
24
+ project: 'test-project',
25
+ source: 'decide',
26
+ });
27
+ (0, episodes_js_1.supersede)(db, old.id, {
28
+ kind: 'decision',
29
+ summary: 'SQLite\'a gectik',
30
+ body: '## Reason\nQuery gucu daha yuksek',
31
+ project: 'test-project',
32
+ source: 'decide',
33
+ });
34
+ const chains = querySupersededChains(db, { project: 'test-project' });
35
+ expect(chains).toHaveLength(1);
36
+ expect(chains[0]?.current).toBe('SQLite\'a gectik');
37
+ expect(chains[0]?.previous).toBe('JSONL kullanacagiz');
38
+ expect(chains[0]?.reason).toBe('Query gucu daha yuksek');
39
+ });
40
+ test('respects limit parameter', () => {
41
+ for (let i = 0; i < 3; i++) {
42
+ const old = (0, episodes_js_1.createEpisode)(db, {
43
+ kind: 'decision',
44
+ summary: `Old decision ${i}`,
45
+ project: 'test-project',
46
+ source: 'decide',
47
+ });
48
+ (0, episodes_js_1.supersede)(db, old.id, {
49
+ kind: 'decision',
50
+ summary: `New decision ${i}`,
51
+ project: 'test-project',
52
+ source: 'decide',
53
+ });
54
+ }
55
+ const chains = querySupersededChains(db, { project: 'test-project', limit: 2 });
56
+ expect(chains).toHaveLength(2);
57
+ });
58
+ test('filters by project', () => {
59
+ const old = (0, episodes_js_1.createEpisode)(db, {
60
+ kind: 'decision',
61
+ summary: 'Other project decision',
62
+ project: 'other-project',
63
+ source: 'decide',
64
+ });
65
+ (0, episodes_js_1.supersede)(db, old.id, {
66
+ kind: 'decision',
67
+ summary: 'Other project new',
68
+ project: 'other-project',
69
+ source: 'decide',
70
+ });
71
+ const chains = querySupersededChains(db, { project: 'test-project' });
72
+ expect(chains).toHaveLength(0);
73
+ });
74
+ test('parses reason from body ## Reason section', () => {
75
+ const old = (0, episodes_js_1.createEpisode)(db, {
76
+ kind: 'decision',
77
+ summary: 'Old way',
78
+ project: 'test-project',
79
+ source: 'decide',
80
+ });
81
+ (0, episodes_js_1.supersede)(db, old.id, {
82
+ kind: 'decision',
83
+ summary: 'New way',
84
+ body: '## Context\nSome context\n\n## Reason\nPerformance 3x better\n\n## Notes\nExtra info',
85
+ project: 'test-project',
86
+ source: 'decide',
87
+ });
88
+ const chains = querySupersededChains(db, { project: 'test-project' });
89
+ expect(chains[0]?.reason).toBe('Performance 3x better');
90
+ });
91
+ });
92
+ describe('supersedes chain format', () => {
93
+ test('formats chain as "current <- previous (Reason: ...)"', () => {
94
+ const chains = [
95
+ { current: 'SQLite\'a gectik', previous: 'JSONL kullanacagiz', reason: 'Query gucu' },
96
+ { current: 'FTS5 11-col', previous: '7-col', reason: null },
97
+ ];
98
+ const formatted = formatSupersededChains(chains);
99
+ expect(formatted).toContain('SQLite\'a gectik');
100
+ expect(formatted).toContain('JSONL kullanacagiz');
101
+ expect(formatted).toContain('Reason: Query gucu');
102
+ expect(formatted).toContain('FTS5 11-col');
103
+ expect(formatted).toContain('7-col');
104
+ });
105
+ test('returns empty string for no chains', () => {
106
+ expect(formatSupersededChains([])).toBe('');
107
+ });
108
+ });
109
+ //# sourceMappingURL=supersedes-chain.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supersedes-chain.test.js","sourceRoot":"","sources":["../../tests/supersedes-chain.test.ts"],"names":[],"mappings":"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"}
@@ -87,12 +87,13 @@ function parseFrontmatter(content) {
87
87
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
88
88
  if (!match) return { meta: {}, body: content };
89
89
 
90
- const meta = {};
90
+ const meta = Object.create(null);
91
91
  const lines = match[1].split('\n');
92
92
  for (const line of lines) {
93
93
  const colonIdx = line.indexOf(':');
94
94
  if (colonIdx === -1) continue;
95
95
  const key = line.slice(0, colonIdx).trim();
96
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
96
97
  let value = line.slice(colonIdx + 1).trim();
97
98
  if (value.startsWith('[') && value.endsWith(']')) {
98
99
  value = value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''));
@@ -287,6 +288,250 @@ const DEFAULT_MODELS = {
287
288
  default: 'haiku',
288
289
  };
289
290
 
291
+ // ── Episodes (v0.4.0) ─────────────────────────────────────────────
292
+
293
+ const SQL_EPISODES_CREATE = `
294
+ CREATE TABLE IF NOT EXISTS episodes (
295
+ id TEXT PRIMARY KEY,
296
+ kind TEXT NOT NULL,
297
+ scope TEXT NOT NULL DEFAULT 'project',
298
+ project TEXT,
299
+ summary TEXT NOT NULL,
300
+ body TEXT,
301
+ tags TEXT,
302
+ entities TEXT,
303
+ parent_id TEXT,
304
+ status TEXT NOT NULL DEFAULT 'active',
305
+ supersedes TEXT,
306
+ source TEXT,
307
+ created_at TEXT NOT NULL
308
+ )`;
309
+
310
+ /**
311
+ * Valid episode kinds. CO-EVOLUTION: mirrors EPISODE_KINDS in scripts/lib/episodes.ts
312
+ */
313
+ // ~500 tokens context budget for multi-session inject
314
+ const MULTI_SESSION_TOKEN_CAP_CHARS = 2500;
315
+
316
+ const EPISODE_KINDS_CJS = ['session', 'decision', 'event', 'preference', 'learning', 'friction', 'discovery', 'nomination'];
317
+
318
+ /**
319
+ * Valid episode statuses. CO-EVOLUTION: mirrors EPISODE_STATUSES in scripts/lib/episodes.ts
320
+ */
321
+ const EPISODE_STATUSES_CJS = ['active', 'superseded', 'deleted', 'staged', 'reviewed', 'approved', 'rejected'];
322
+
323
+ const SQL_EPISODES_INDEXES = [
324
+ 'CREATE INDEX IF NOT EXISTS idx_episodes_kind ON episodes(kind, status)',
325
+ 'CREATE INDEX IF NOT EXISTS idx_episodes_project ON episodes(project, status)',
326
+ 'CREATE INDEX IF NOT EXISTS idx_episodes_created ON episodes(created_at DESC)',
327
+ ];
328
+
329
+ /**
330
+ * Ensure episodes table + indexes exist. Idempotent.
331
+ * @param {import('better-sqlite3').Database} db
332
+ */
333
+ function ensureEpisodesTable(db) {
334
+ db.exec(SQL_EPISODES_CREATE);
335
+ for (const idx of SQL_EPISODES_INDEXES) {
336
+ db.exec(idx);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Check if episodes table exists in the database.
342
+ * @param {import('better-sqlite3').Database} db
343
+ * @returns {boolean}
344
+ */
345
+ function hasEpisodesTable(db) {
346
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='episodes'").get();
347
+ return row !== undefined;
348
+ }
349
+
350
+ /**
351
+ * Generate a time-sortable episode ID.
352
+ * Format: ep-{base36-timestamp}-{12-hex-random}
353
+ */
354
+ function generateEpisodeId() {
355
+ const timestamp = Date.now().toString(36);
356
+ const random = crypto.randomBytes(6).toString('hex');
357
+ return `ep-${timestamp}-${random}`;
358
+ }
359
+
360
+ /**
361
+ * Insert a bare episode from hook context (no LLM needed).
362
+ * @param {import('better-sqlite3').Database} db
363
+ * @param {object} entry
364
+ * @returns {string} episode id
365
+ */
366
+ function insertBareEpisode(db, entry) {
367
+ const id = entry.id || generateEpisodeId();
368
+ const now = new Date().toISOString();
369
+
370
+ db.prepare(`
371
+ INSERT INTO episodes (id, kind, scope, project, summary, body, tags, entities, parent_id, status, supersedes, source, created_at)
372
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
373
+ `).run(
374
+ id,
375
+ entry.kind || 'session',
376
+ entry.scope || 'project',
377
+ entry.project || null,
378
+ entry.summary || '',
379
+ entry.body || null,
380
+ entry.tags || null,
381
+ entry.entities ? JSON.stringify(entry.entities) : null,
382
+ entry.parent_id || null,
383
+ entry.supersedes || null,
384
+ entry.source || 'hook',
385
+ now,
386
+ );
387
+
388
+ return id;
389
+ }
390
+
391
+ /**
392
+ * Query recent episodes for session-focus injection.
393
+ * @param {import('better-sqlite3').Database} db
394
+ * @param {object} opts - { project, limit, maxChars }
395
+ * @returns {Array<{kind: string, summary: string, created_at: string}>}
396
+ */
397
+ function queryRecentEpisodes(db, opts) {
398
+ const project = opts.project || null;
399
+ const limit = opts.limit || 3;
400
+
401
+ let sql = "SELECT kind, summary, created_at FROM episodes WHERE status = 'active'";
402
+ const params = [];
403
+
404
+ if (project) {
405
+ sql += ' AND project = ?';
406
+ params.push(project);
407
+ }
408
+
409
+ sql += ' ORDER BY created_at DESC LIMIT ?';
410
+ params.push(limit);
411
+
412
+ return db.prepare(sql).all(...params);
413
+ }
414
+
415
+ /**
416
+ * Query superseded episode chains for session-focus display.
417
+ * CO-EVOLUTION: Uses episodes table schema from SQL_EPISODES_CREATE
418
+ * @param {import('better-sqlite3').Database} db
419
+ * @param {{ project: string, days?: number, limit?: number }} opts
420
+ * @returns {Array<{current: string, previous: string, reason: string|null}>}
421
+ */
422
+ function querySupersededChains(db, opts) {
423
+ const days = opts.days ?? 7;
424
+ const limit = opts.limit ?? 5;
425
+ const modifier = `-${days} days`;
426
+ const rows = db.prepare(`
427
+ SELECT new_ep.summary AS current_summary, old_ep.summary AS previous_summary, new_ep.body
428
+ FROM episodes new_ep
429
+ JOIN episodes old_ep ON new_ep.supersedes = old_ep.id
430
+ WHERE new_ep.project = ?
431
+ AND new_ep.created_at > datetime('now', ?)
432
+ AND old_ep.status = 'superseded'
433
+ ORDER BY new_ep.created_at DESC
434
+ LIMIT ?
435
+ `).all(opts.project, modifier, limit);
436
+
437
+ return rows.map(row => ({
438
+ current: row.current_summary,
439
+ previous: row.previous_summary,
440
+ reason: parseReason(row.body),
441
+ }));
442
+ }
443
+
444
+ /**
445
+ * Parse ## Reason section from episode body.
446
+ * @param {string|null} body
447
+ * @returns {string|null}
448
+ */
449
+ function parseReason(body) {
450
+ if (!body) return null;
451
+ const match = body.match(/## Reason\n(.+?)(?:\n##|\n*$)/s);
452
+ if (!match) return null;
453
+ return match[1].trim().split('\n')[0];
454
+ }
455
+
456
+ /**
457
+ * Format superseded chains for session-focus inject.
458
+ * @param {Array<{current: string, previous: string, reason: string|null}>} chains
459
+ * @returns {string}
460
+ */
461
+ function formatSupersededChains(chains) {
462
+ if (chains.length === 0) return '';
463
+ const lines = chains.map(c => {
464
+ const base = `- ${c.current} \u2190 ${c.previous}`;
465
+ return c.reason ? `${base} (Reason: ${c.reason})` : base;
466
+ });
467
+ return lines.join('\n');
468
+ }
469
+
470
+ /**
471
+ * Query episodes across multiple sessions for enriched inject.
472
+ * Excludes bare session episodes and nominations.
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
+ */
477
+ function queryMultiSessionEpisodes(db, opts) {
478
+ const days = opts.days ?? 3;
479
+ const limit = opts.limit ?? 20;
480
+ const modifier = `-${days} days`;
481
+ return db.prepare(`
482
+ SELECT kind, summary, created_at
483
+ FROM episodes
484
+ WHERE project = ?
485
+ AND status = 'active'
486
+ AND kind != 'session'
487
+ AND kind != 'nomination'
488
+ AND created_at > datetime('now', ?)
489
+ ORDER BY created_at DESC
490
+ LIMIT ?
491
+ `).all(opts.project, modifier, limit);
492
+ }
493
+
494
+ /**
495
+ * Format multi-session episodes for inject.
496
+ * Groups by date. If too many per date, collapses to count-per-kind.
497
+ * @param {Array<{kind: string, summary: string, created_at: string}>} episodes
498
+ * @returns {string}
499
+ */
500
+ function formatMultiSessionEpisodes(episodes) {
501
+ if (episodes.length === 0) return '';
502
+
503
+ const byDate = {};
504
+ for (const ep of episodes) {
505
+ const date = (ep.created_at || '').slice(0, 10);
506
+ if (!byDate[date]) byDate[date] = [];
507
+ byDate[date].push(ep);
508
+ }
509
+
510
+ const lines = [];
511
+ let totalChars = 0;
512
+
513
+ for (const [date, eps] of Object.entries(byDate).sort((a, b) => b[0].localeCompare(a[0]))) {
514
+ if (totalChars > MULTI_SESSION_TOKEN_CAP_CHARS) break;
515
+ if (eps.length <= 5) {
516
+ for (const ep of eps) {
517
+ const line = `- [${date}] ${ep.kind}: ${String(ep.summary).slice(0, 100)}`;
518
+ totalChars += line.length;
519
+ if (totalChars > MULTI_SESSION_TOKEN_CAP_CHARS) break;
520
+ lines.push(line);
521
+ }
522
+ } else {
523
+ const kindCounts = {};
524
+ for (const ep of eps) {
525
+ kindCounts[ep.kind] = (kindCounts[ep.kind] || 0) + 1;
526
+ }
527
+ const counts = Object.entries(kindCounts).map(([k, c]) => `${c} ${k}`).join(', ');
528
+ lines.push(`- [${date}] ${counts}`);
529
+ }
530
+ }
531
+
532
+ return lines.join('\n');
533
+ }
534
+
290
535
  module.exports = {
291
536
  MINDLORE_DIR,
292
537
  GLOBAL_MINDLORE_DIR,
@@ -312,4 +557,18 @@ module.exports = {
312
557
  detectSchemaVersion,
313
558
  getProjectName,
314
559
  DEFAULT_MODELS,
560
+ // Episodes (v0.4.1)
561
+ EPISODE_KINDS_CJS,
562
+ EPISODE_STATUSES_CJS,
563
+ SQL_EPISODES_CREATE,
564
+ SQL_EPISODES_INDEXES,
565
+ ensureEpisodesTable,
566
+ hasEpisodesTable,
567
+ generateEpisodeId,
568
+ insertBareEpisode,
569
+ queryRecentEpisodes,
570
+ querySupersededChains,
571
+ formatSupersededChains,
572
+ queryMultiSessionEpisodes,
573
+ formatMultiSessionEpisodes,
315
574
  };
@@ -16,8 +16,10 @@ function main() {
16
16
  const filePath = readHookStdin(['path', 'file_path']);
17
17
  if (!filePath) return;
18
18
 
19
- // Only process .md files inside .mindlore/
20
- if (!filePath.includes(MINDLORE_DIR) || !filePath.endsWith('.md')) return;
19
+ // Only process .md files inside .mindlore/ (resolved path check prevents traversal)
20
+ if (!filePath.endsWith('.md')) return;
21
+ const resolvedFile = path.resolve(filePath);
22
+ if (!resolvedFile.includes(path.sep + MINDLORE_DIR + path.sep) && !resolvedFile.includes(path.sep + MINDLORE_DIR)) return;
21
23
 
22
24
  const fileName = path.basename(filePath);
23
25
  if (SKIP_FILES.has(fileName)) return;
@@ -60,18 +62,19 @@ function main() {
60
62
  const { meta, body } = parseFrontmatter(content);
61
63
  const { slug, description, type, category, title, tags, quality, dateCaptured } = extractFtsMetadata(meta, body, filePath, baseDir);
62
64
 
63
- // Update FTS5
64
- db.prepare('DELETE FROM mindlore_fts WHERE path = ?').run(filePath);
65
- insertFtsRow(db, { path: filePath, slug, description, type, category, title, content: body, tags, quality, dateCaptured, project: getProjectName() });
66
-
67
- // Update hash
68
- db.prepare(
69
- `INSERT INTO file_hashes (path, content_hash, last_indexed)
70
- VALUES (?, ?, ?)
71
- ON CONFLICT(path) DO UPDATE SET
72
- content_hash = excluded.content_hash,
73
- last_indexed = excluded.last_indexed`
74
- ).run(filePath, hash, new Date().toISOString());
65
+ // Update FTS5 + hash atomically
66
+ const updateIndex = db.transaction(() => {
67
+ db.prepare('DELETE FROM mindlore_fts WHERE path = ?').run(filePath);
68
+ insertFtsRow(db, { path: filePath, slug, description, type, category, title, content: body, tags, quality, dateCaptured, project: getProjectName() });
69
+ db.prepare(
70
+ `INSERT INTO file_hashes (path, content_hash, last_indexed)
71
+ VALUES (?, ?, ?)
72
+ ON CONFLICT(path) DO UPDATE SET
73
+ content_hash = excluded.content_hash,
74
+ last_indexed = excluded.last_indexed`
75
+ ).run(filePath, hash, new Date().toISOString());
76
+ });
77
+ updateIndex();
75
78
  } finally {
76
79
  db.close();
77
80
  }
@@ -7,8 +7,8 @@
7
7
  * After a file is read, estimate its token count
8
8
  * and store in _session-reads.json for the read-guard to reference.
9
9
  *
10
- * Does NOT output anything (pure bookkeeping).
11
- * PostToolUse stdout goes to debug log only — no inject needed.
10
+ * Outputs token estimate via additionalContext JSON.
11
+ * Also stores token info in _session-reads.json for read-guard.
12
12
  */
13
13
 
14
14
  const fs = require('fs');
@@ -87,6 +87,15 @@ function main() {
87
87
  }
88
88
 
89
89
  fs.writeFileSync(readsPath, JSON.stringify(reads, null, 2), 'utf8');
90
+
91
+ // Output token estimate to Claude via additionalContext
92
+ const basename = path.basename(filePath);
93
+ process.stdout.write(JSON.stringify({
94
+ hookSpecificOutput: {
95
+ hookEventName: 'PostToolUse',
96
+ additionalContext: `[Mindlore: ${basename} — ~${tokens} token (${charCount} char). Edit etmeyeceksen ctx_execute_file kullan.]`
97
+ }
98
+ }));
90
99
  } catch {
91
100
  // Silent fail
92
101
  }
@@ -21,8 +21,8 @@ function main() {
21
21
  const indexScript = path.join(__dirname, '..', 'scripts', 'mindlore-fts5-index.cjs');
22
22
  if (fs.existsSync(indexScript)) {
23
23
  try {
24
- const { execSync } = require('child_process');
25
- execSync(`node "${indexScript}" "${baseDir}"`, {
24
+ const { spawnSync } = require('child_process');
25
+ spawnSync('node', [indexScript, baseDir], {
26
26
  timeout: 10000,
27
27
  stdio: 'pipe',
28
28
  });
@@ -68,15 +68,23 @@ function main() {
68
68
  // Write updated reads
69
69
  fs.writeFileSync(readsPath, JSON.stringify(reads, null, 2), 'utf8');
70
70
 
71
- // Warn on repeated reads (2nd+ time)
71
+ const basename = path.basename(filePath);
72
+ const tokenInfo = tokens > 0 ? ` (~${tokens} token)` : '';
73
+
74
+ // Block on 3+ repeated reads (exit 2 = block tool call)
75
+ if (count >= 3) {
76
+ const totalWaste = tokens > 0 ? ` Toplam israf: ~${tokens * (count - 1)} token.` : '';
77
+ process.stderr.write(`[Mindlore BLOCK] ${basename}${tokenInfo} bu session'da ${count}. kez okunuyor.${totalWaste} Edit icin gerekiyorsa once degisikligini yap, sonra tekrar oku. Analiz icin ctx_execute_file kullan.`);
78
+ process.exit(2);
79
+ }
80
+
81
+ // Warn on 2nd read (exit 0 = allow but warn)
72
82
  if (count > 1) {
73
- const basename = path.basename(filePath);
74
- const tokenInfo = tokens > 0 ? ` (~${tokens} token)` : '';
75
83
  const totalWaste = tokens > 0 ? ` Toplam tekrar: ~${tokens * (count - 1)} token.` : '';
76
84
  process.stdout.write(JSON.stringify({
77
85
  hookSpecificOutput: {
78
86
  hookEventName: 'PreToolUse',
79
- additionalContext: `[Mindlore: ${basename}${tokenInfo} bu session'da ${count}. kez okunuyor.${totalWaste} Değişiklik yoksa tekrar okumayı atlayabilirsin.]`
87
+ additionalContext: `[Mindlore: ${basename}${tokenInfo} bu session'da ${count}. kez okunuyor.${totalWaste} Bir sonraki okuma engellenecek — Edit gerekiyorsa simdi yap.]`
80
88
  }
81
89
  }));
82
90
  }
@@ -80,7 +80,9 @@ function searchDb(dbPath, keywords, Database) {
80
80
 
81
81
  for (const kw of keywords) {
82
82
  try {
83
- const r = matchStmt.get(row.path, '"' + kw + '"');
83
+ const sanitized = kw.replace(/["*(){}[\]^~:]/g, '');
84
+ if (!sanitized) continue;
85
+ const r = matchStmt.get(row.path, '"' + sanitized + '"');
84
86
  if (r) {
85
87
  hits++;
86
88
  totalRank += r.rank;
@@ -104,6 +106,27 @@ function searchDb(dbPath, keywords, Database) {
104
106
  return results;
105
107
  }
106
108
 
109
+ /**
110
+ * Search episodes via FTS5 mirror (type = 'episode').
111
+ * Reuses an already-open DB handle — no extra sqlite3_open.
112
+ */
113
+ function searchEpisodesFts(db, keywords) {
114
+ try {
115
+ const ftsQuery = keywords.map(kw => '"' + kw.replace(/["*(){}[\]^~:]/g, '') + '"').filter(q => q !== '""').join(' OR ');
116
+ const rows = db.prepare(
117
+ "SELECT title, category, slug, tags FROM mindlore_fts WHERE type = 'episode' AND mindlore_fts MATCH ? LIMIT 2"
118
+ ).all(ftsQuery);
119
+
120
+ return rows.map(r => {
121
+ const tags = r.tags || '';
122
+ const kind = tags.split(',')[0]?.trim() || 'episode';
123
+ return `[episode] ${kind}: ${r.title || r.slug}`;
124
+ });
125
+ } catch (_err) {
126
+ return [];
127
+ }
128
+ }
129
+
107
130
  function main() {
108
131
  const userMessage = readHookStdin(['prompt', 'content', 'message', 'query']);
109
132
  if (!userMessage || userMessage.length < MIN_QUERY_WORDS) return;
@@ -162,6 +185,21 @@ function main() {
162
185
  );
163
186
  }
164
187
 
188
+ // v0.4.0: Search episode mirrors in FTS5 (reuses searchDb's DB path, no extra open)
189
+ if (relevant.length < MAX_RESULTS) {
190
+ for (const dbPath of dbPaths) {
191
+ try {
192
+ const db = new Database(dbPath, { readonly: true });
193
+ const episodeResults = searchEpisodesFts(db, keywords);
194
+ db.close();
195
+ if (episodeResults.length > 0) {
196
+ output.push(`[Mindlore Episodes]\n${episodeResults.join('\n')}`);
197
+ break;
198
+ }
199
+ } catch (_err) { /* skip */ }
200
+ }
201
+ }
202
+
165
203
  if (output.length > 0) {
166
204
  process.stdout.write(output.join('\n\n') + '\n');
167
205
  }