mindlore 0.6.9 → 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 (152) hide show
  1. package/README.md +31 -4
  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/cc-memory-bulk-sync.d.ts.map +1 -1
  7. package/dist/scripts/cc-memory-bulk-sync.js +2 -1
  8. package/dist/scripts/cc-memory-bulk-sync.js.map +1 -1
  9. package/dist/scripts/cc-session-sync.d.ts.map +1 -1
  10. package/dist/scripts/cc-session-sync.js +3 -2
  11. package/dist/scripts/cc-session-sync.js.map +1 -1
  12. package/dist/scripts/init.js +4 -4
  13. package/dist/scripts/init.js.map +1 -1
  14. package/dist/scripts/lib/constants.d.ts +1 -2
  15. package/dist/scripts/lib/constants.d.ts.map +1 -1
  16. package/dist/scripts/lib/constants.js +2 -22
  17. package/dist/scripts/lib/constants.js.map +1 -1
  18. package/dist/scripts/lib/err-msg.d.ts +2 -0
  19. package/dist/scripts/lib/err-msg.d.ts.map +1 -0
  20. package/dist/scripts/lib/err-msg.js +7 -0
  21. package/dist/scripts/lib/err-msg.js.map +1 -0
  22. package/dist/scripts/lib/mcp-namespace.d.ts +2 -0
  23. package/dist/scripts/lib/mcp-namespace.d.ts.map +1 -0
  24. package/dist/scripts/lib/mcp-namespace.js +21 -0
  25. package/dist/scripts/lib/mcp-namespace.js.map +1 -0
  26. package/dist/scripts/lib/mcp-telemetry.d.ts +11 -0
  27. package/dist/scripts/lib/mcp-telemetry.d.ts.map +1 -0
  28. package/dist/scripts/lib/mcp-telemetry.js +37 -0
  29. package/dist/scripts/lib/mcp-telemetry.js.map +1 -0
  30. package/dist/scripts/lib/mcp-tools.d.ts +10 -0
  31. package/dist/scripts/lib/mcp-tools.d.ts.map +1 -0
  32. package/dist/scripts/lib/mcp-tools.js +121 -0
  33. package/dist/scripts/lib/mcp-tools.js.map +1 -0
  34. package/dist/scripts/lib/rrf.d.ts.map +1 -1
  35. package/dist/scripts/lib/rrf.js +2 -1
  36. package/dist/scripts/lib/rrf.js.map +1 -1
  37. package/dist/scripts/lib/search-engine.d.ts +1 -0
  38. package/dist/scripts/lib/search-engine.d.ts.map +1 -1
  39. package/dist/scripts/lib/search-engine.js +9 -5
  40. package/dist/scripts/lib/search-engine.js.map +1 -1
  41. package/dist/scripts/lib/slugify.d.ts +2 -0
  42. package/dist/scripts/lib/slugify.d.ts.map +1 -0
  43. package/dist/scripts/lib/slugify.js +13 -0
  44. package/dist/scripts/lib/slugify.js.map +1 -0
  45. package/dist/scripts/lib/smart-snippet.d.ts +9 -0
  46. package/dist/scripts/lib/smart-snippet.d.ts.map +1 -0
  47. package/dist/scripts/lib/smart-snippet.js +47 -0
  48. package/dist/scripts/lib/smart-snippet.js.map +1 -0
  49. package/dist/scripts/lib/tool-adapters/brief-adapter.d.ts +15 -0
  50. package/dist/scripts/lib/tool-adapters/brief-adapter.d.ts.map +1 -0
  51. package/dist/scripts/lib/tool-adapters/brief-adapter.js +66 -0
  52. package/dist/scripts/lib/tool-adapters/brief-adapter.js.map +1 -0
  53. package/dist/scripts/lib/tool-adapters/decide-adapter.d.ts +31 -0
  54. package/dist/scripts/lib/tool-adapters/decide-adapter.d.ts.map +1 -0
  55. package/dist/scripts/lib/tool-adapters/decide-adapter.js +71 -0
  56. package/dist/scripts/lib/tool-adapters/decide-adapter.js.map +1 -0
  57. package/dist/scripts/lib/tool-adapters/ingest-adapter.d.ts +16 -0
  58. package/dist/scripts/lib/tool-adapters/ingest-adapter.d.ts.map +1 -0
  59. package/dist/scripts/lib/tool-adapters/ingest-adapter.js +58 -0
  60. package/dist/scripts/lib/tool-adapters/ingest-adapter.js.map +1 -0
  61. package/dist/scripts/lib/tool-adapters/recall-adapter.d.ts +20 -0
  62. package/dist/scripts/lib/tool-adapters/recall-adapter.d.ts.map +1 -0
  63. package/dist/scripts/lib/tool-adapters/recall-adapter.js +69 -0
  64. package/dist/scripts/lib/tool-adapters/recall-adapter.js.map +1 -0
  65. package/dist/scripts/lib/tool-adapters/search-adapter.d.ts +22 -0
  66. package/dist/scripts/lib/tool-adapters/search-adapter.d.ts.map +1 -0
  67. package/dist/scripts/lib/tool-adapters/search-adapter.js +32 -0
  68. package/dist/scripts/lib/tool-adapters/search-adapter.js.map +1 -0
  69. package/dist/scripts/lib/tool-adapters/stats-adapter.d.ts +15 -0
  70. package/dist/scripts/lib/tool-adapters/stats-adapter.d.ts.map +1 -0
  71. package/dist/scripts/lib/tool-adapters/stats-adapter.js +66 -0
  72. package/dist/scripts/lib/tool-adapters/stats-adapter.js.map +1 -0
  73. package/dist/scripts/maintain-cleanup.d.ts.map +1 -1
  74. package/dist/scripts/maintain-cleanup.js +3 -2
  75. package/dist/scripts/maintain-cleanup.js.map +1 -1
  76. package/dist/scripts/mcp-server.d.ts +3 -0
  77. package/dist/scripts/mcp-server.d.ts.map +1 -0
  78. package/dist/scripts/mcp-server.js +85 -0
  79. package/dist/scripts/mcp-server.js.map +1 -0
  80. package/dist/scripts/mindlore-doctor.d.ts.map +1 -1
  81. package/dist/scripts/mindlore-doctor.js +4 -6
  82. package/dist/scripts/mindlore-doctor.js.map +1 -1
  83. package/dist/scripts/mindlore-fts5-index.js +2 -2
  84. package/dist/scripts/mindlore-fts5-index.js.map +1 -1
  85. package/dist/scripts/mindlore-health-check.d.ts.map +1 -1
  86. package/dist/scripts/mindlore-health-check.js +2 -2
  87. package/dist/scripts/mindlore-health-check.js.map +1 -1
  88. package/dist/scripts/validate-manifest-cli.js +2 -2
  89. package/dist/scripts/validate-manifest-cli.js.map +1 -1
  90. package/dist/tests/err-msg.test.d.ts +2 -0
  91. package/dist/tests/err-msg.test.d.ts.map +1 -0
  92. package/dist/tests/err-msg.test.js +24 -0
  93. package/dist/tests/err-msg.test.js.map +1 -0
  94. package/dist/tests/hook-smoke.test.js +1 -1
  95. package/dist/tests/hook-smoke.test.js.map +1 -1
  96. package/dist/tests/manifest-v2.test.js +0 -7
  97. package/dist/tests/manifest-v2.test.js.map +1 -1
  98. package/dist/tests/mcp-server.test.d.ts +2 -0
  99. package/dist/tests/mcp-server.test.d.ts.map +1 -0
  100. package/dist/tests/mcp-server.test.js +118 -0
  101. package/dist/tests/mcp-server.test.js.map +1 -0
  102. package/dist/tests/mcp-tools.test.d.ts +2 -0
  103. package/dist/tests/mcp-tools.test.d.ts.map +1 -0
  104. package/dist/tests/mcp-tools.test.js +243 -0
  105. package/dist/tests/mcp-tools.test.js.map +1 -0
  106. package/dist/tests/search-hook.test.js +1 -1
  107. package/dist/tests/search-hook.test.js.map +1 -1
  108. package/dist/tests/smart-snippet.test.d.ts +2 -0
  109. package/dist/tests/smart-snippet.test.d.ts.map +1 -0
  110. package/dist/tests/smart-snippet.test.js +67 -0
  111. package/dist/tests/smart-snippet.test.js.map +1 -0
  112. package/hooks/cc-memory-bulk-sync.cjs +592 -0
  113. package/hooks/cc-session-sync.cjs +842 -0
  114. package/hooks/hooks.json +149 -0
  115. package/hooks/lib/mindlore-common.cjs +2 -2
  116. package/hooks/lib/secure-io.cjs +17 -0
  117. package/hooks/mindlore-cwd-changed.cjs +19 -34
  118. package/hooks/mindlore-decision-detector.cjs +40 -31
  119. package/hooks/mindlore-dont-repeat.cjs +57 -115
  120. package/hooks/mindlore-fts5-sync.cjs +15 -44
  121. package/hooks/mindlore-index.cjs +100 -101
  122. package/hooks/mindlore-model-router.cjs +20 -32
  123. package/hooks/mindlore-post-compact.cjs +26 -42
  124. package/hooks/mindlore-post-read.cjs +35 -60
  125. package/hooks/mindlore-pre-compact.cjs +55 -73
  126. package/hooks/mindlore-read-guard.cjs +28 -51
  127. package/hooks/mindlore-research-guard.cjs +63 -101
  128. package/hooks/mindlore-search.cjs +1142 -93
  129. package/hooks/mindlore-session-end.cjs +155 -276
  130. package/hooks/mindlore-session-focus.cjs +639 -110
  131. package/hooks/src/lib/constants.cjs +15 -0
  132. package/hooks/src/lib/mindlore-common.cjs +975 -0
  133. package/hooks/src/lib/mindlore-common.d.cts +72 -0
  134. package/hooks/src/lib/secure-io.cjs +17 -0
  135. package/hooks/src/lib/types.d.ts +58 -0
  136. package/hooks/src/mindlore-cwd-changed.cjs +57 -0
  137. package/hooks/src/mindlore-decision-detector.cjs +54 -0
  138. package/hooks/src/mindlore-dont-repeat.cjs +222 -0
  139. package/hooks/src/mindlore-fts5-sync.cjs +98 -0
  140. package/hooks/src/mindlore-index.cjs +230 -0
  141. package/hooks/src/mindlore-model-router.cjs +54 -0
  142. package/hooks/src/mindlore-post-compact.cjs +69 -0
  143. package/hooks/src/mindlore-post-read.cjs +106 -0
  144. package/hooks/src/mindlore-pre-compact.cjs +154 -0
  145. package/hooks/src/mindlore-read-guard.cjs +105 -0
  146. package/hooks/src/mindlore-research-guard.cjs +176 -0
  147. package/hooks/src/mindlore-search.cjs +200 -0
  148. package/hooks/src/mindlore-session-end.cjs +511 -0
  149. package/hooks/src/mindlore-session-focus.cjs +256 -0
  150. package/package.json +8 -3
  151. package/plugin.json +7 -1
  152. package/templates/config.json +1 -1
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * mindlore-session-end — SessionEnd hook
6
+ *
7
+ * Writes a basic delta file to diary/ with session timestamp.
8
+ * v0.1: minimal delta (timestamp + marker)
9
+ * v0.2: structured delta with stats, decisions, learnings
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const { execFileSync, spawn } = require('child_process');
16
+ const { safeWriteFile, safeWriteJson } = require('../dist/scripts/lib/secure-io.js');
17
+ const { findMindloreDir, globalDir, getProjectName, openDatabase, ensureEpisodesTable, hasEpisodesTable, insertBareEpisode, insertFtsRow, hookLog, SHARED_EXPORT_DIRS, resolveWin32Bin, withTelemetry, getUnpromotedRawFiles, cleanupExpiredInjectLog } = require('./lib/mindlore-common.cjs');
18
+
19
+ const EXPORT_DIRS = SHARED_EXPORT_DIRS;
20
+
21
+ // --worker mode: heavy ops run in detached child process (survives parent exit)
22
+ if (process.argv.includes('--worker')) {
23
+ hookLog('session-end', 'info', 'worker started, pid=' + process.pid);
24
+ const dataPath = process.argv[process.argv.indexOf('--worker') + 1];
25
+ let payload;
26
+ try {
27
+ const raw = fs.readFileSync(dataPath, 'utf8');
28
+ fs.unlinkSync(dataPath);
29
+ payload = JSON.parse(raw);
30
+ } catch (_err) {
31
+ hookLog('session-end', 'error', 'payload read failed: ' + (_err?.message ?? _err));
32
+ process.exit(0);
33
+ }
34
+ const { baseDir, project, commits, changedFiles, reads } = payload;
35
+
36
+ async function safeRunAsync(fn, label) {
37
+ try {
38
+ await fn();
39
+ hookLog('session-end', 'info', label + ' OK');
40
+ } catch (e) {
41
+ hookLog('session-end', 'error', label + ' FAIL: ' + e?.message);
42
+ }
43
+ }
44
+
45
+ (async () => {
46
+ // Episode writes share DB — run sequentially first
47
+ await safeRunAsync(() => writeBareEpisode(baseDir, project, commits, changedFiles, reads), 'episode');
48
+ await safeRunAsync(() => writeEpisodeFile(baseDir, project, commits, changedFiles, reads), 'episode-file');
49
+
50
+ const nodeExe = resolveWin32Bin('node') || process.execPath;
51
+ function runSyncScript(scriptName, args, timeoutMs, label) {
52
+ const cjsName = scriptName.replace(/\.js$/, '.cjs');
53
+ const scriptPath = [path.join(__dirname, cjsName), path.join(__dirname, '..', 'dist', 'scripts', scriptName)].find(p => fs.existsSync(p));
54
+ if (!scriptPath) return;
55
+ try {
56
+ execFileSync(nodeExe, [scriptPath, ...args], {
57
+ timeout: timeoutMs,
58
+ env: { ...process.env, MINDLORE_HOME: baseDir },
59
+ windowsHide: true,
60
+ });
61
+ hookLog('session-end', 'info', label + ' completed');
62
+ } catch (err) {
63
+ hookLog('session-end', 'warn', `${label} failed: ${err?.message || err}`);
64
+ }
65
+ }
66
+
67
+ await safeRunAsync(() => runSyncScript('cc-memory-bulk-sync.js', ['--auto'], 10000, 'CC memory sync'), 'cc-memory-sync');
68
+ await safeRunAsync(() => runSyncScript('cc-session-sync.js', [], 30000, 'CC session sync'), 'cc-session-sync');
69
+
70
+ // Raw accumulation warning (moved from main to worker — off hot path)
71
+ await safeRunAsync(() => {
72
+ const unpromoted = getUnpromotedRawFiles(baseDir);
73
+ if (unpromoted.length >= 5) {
74
+ hookLog('session-end', 'info', `${unpromoted.length} raw files unpromoted`);
75
+ }
76
+ }, 'raw-check');
77
+
78
+ // Obsidian + git-sync are independent — run in parallel
79
+ await Promise.allSettled([
80
+ safeRunAsync(() => syncObsidian(baseDir), 'obsidian'),
81
+ safeRunAsync(() => syncGlobalRepo(), 'git-sync'),
82
+ ]);
83
+
84
+ hookLog('session-end', 'info', 'worker done');
85
+ process.exit(0);
86
+ })();
87
+ }
88
+
89
+ function formatDate(date) {
90
+ const y = date.getFullYear();
91
+ const m = String(date.getMonth() + 1).padStart(2, '0');
92
+ const d = String(date.getDate()).padStart(2, '0');
93
+ const h = String(date.getHours()).padStart(2, '0');
94
+ const min = String(date.getMinutes()).padStart(2, '0');
95
+ return `${y}-${m}-${d}-${h}${min}`;
96
+ }
97
+
98
+ /**
99
+ * Get recent commits and changed files in a single git call.
100
+ * Returns { commits: string[], changedFiles: string[] }
101
+ */
102
+ function getRecentGitInfo() {
103
+ try {
104
+ // --name-only includes file names after each commit entry
105
+ const raw = execFileSync('git', ['log', '--oneline', '-5', '--name-only'], {
106
+ encoding: 'utf8',
107
+ timeout: 5000,
108
+ stdio: ['pipe', 'pipe', 'pipe'],
109
+ windowsHide: true,
110
+ }).trim();
111
+ if (!raw) return { commits: [], changedFiles: [] };
112
+
113
+ const commits = [];
114
+ const fileSet = new Set();
115
+ for (const line of raw.split('\n')) {
116
+ if (!line) continue;
117
+ // Commit lines start with a short hash (7+ hex chars)
118
+ if (/^[0-9a-f]{7,}\s/.test(line)) {
119
+ commits.push(line);
120
+ } else {
121
+ fileSet.add(line);
122
+ }
123
+ }
124
+ return { commits, changedFiles: [...fileSet].slice(0, 20) };
125
+ } catch (_err) {
126
+ return { commits: [], changedFiles: [] };
127
+ }
128
+ }
129
+
130
+ function getSessionReads(baseDir) {
131
+ const readsPath = path.join(baseDir, 'diary', `_session-reads-${getProjectName()}.json`);
132
+ if (!fs.existsSync(readsPath)) return null;
133
+ try {
134
+ const data = JSON.parse(fs.readFileSync(readsPath, 'utf8'));
135
+ const count = Object.keys(data).length;
136
+ const repeats = Object.values(data).filter((v) => {
137
+ if (typeof v === 'number') return v > 1;
138
+ if (v && typeof v === 'object') return (v.count || 0) > 1;
139
+ return false;
140
+ }).length;
141
+ // Clean up session file
142
+ fs.unlinkSync(readsPath);
143
+ return { count, repeats };
144
+ } catch (_err) {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function main() {
150
+ const baseDir = findMindloreDir();
151
+ if (!baseDir) return;
152
+
153
+ const diaryDir = path.join(baseDir, 'diary');
154
+ if (!fs.existsSync(diaryDir)) {
155
+ fs.mkdirSync(diaryDir, { recursive: true });
156
+ }
157
+
158
+ const now = new Date();
159
+ const dateStr = formatDate(now);
160
+ const deltaPath = path.join(diaryDir, `delta-${dateStr}.md`);
161
+
162
+ // Don't overwrite existing delta (idempotent)
163
+ if (fs.existsSync(deltaPath)) return;
164
+
165
+ // Gather structured data (single git call)
166
+ const { commits, changedFiles } = getRecentGitInfo();
167
+ const reads = getSessionReads(baseDir);
168
+
169
+ const project = getProjectName();
170
+
171
+ const sections = [
172
+ '---',
173
+ `slug: delta-${dateStr}`,
174
+ 'type: diary',
175
+ `date: ${now.toISOString().slice(0, 10)}`,
176
+ `project: ${project}`,
177
+ '---',
178
+ '',
179
+ `# Session Delta — ${dateStr}`,
180
+ '',
181
+ `Session ended: ${now.toISOString()}`,
182
+ ];
183
+
184
+ // Commits section
185
+ sections.push('', '## Commits');
186
+ if (commits.length > 0) {
187
+ for (const c of commits) sections.push(`- ${c}`);
188
+ } else {
189
+ sections.push('- _(no commits)_');
190
+ }
191
+
192
+ // Changed files section
193
+ sections.push('', '## Changed Files');
194
+ if (changedFiles.length > 0) {
195
+ for (const f of changedFiles) sections.push(`- ${f}`);
196
+ } else {
197
+ sections.push('- _(no file changes)_');
198
+ }
199
+
200
+ // Read stats (from read-guard, if active)
201
+ if (reads) {
202
+ sections.push('', '## Read Stats');
203
+ sections.push(`- ${reads.count} files read, ${reads.repeats} repeated reads`);
204
+ }
205
+
206
+ sections.push('');
207
+
208
+ safeWriteFile(deltaPath, sections.join('\n'));
209
+
210
+ // Append to log.md
211
+ const logPath = path.join(baseDir, 'log.md');
212
+ if (fs.existsSync(logPath)) {
213
+ const logEntry = `| ${now.toISOString().slice(0, 10)} | session-end | delta-${dateStr}.md |\n`;
214
+ fs.appendFileSync(logPath, logEntry, 'utf8');
215
+ }
216
+
217
+ // Heavy ops: detach into child process so CC can exit immediately.
218
+ // Fixes "Hook cancelled" when CC kills the hook before completion.
219
+ // See: https://github.com/anthropics/claude-code/issues/41577
220
+ try {
221
+ const workerData = JSON.stringify({ baseDir, project, commits, changedFiles, reads });
222
+ const tmpFile = path.join(os.tmpdir(), `mindlore-worker-${Date.now()}.json`);
223
+ safeWriteFile(tmpFile, workerData);
224
+ // Use system node instead of process.execPath — CC's embedded Node
225
+ // may not work as a standalone binary for detached worker processes.
226
+ // Resolve full path to avoid shell:true deprecation warning on Windows.
227
+ const nodeBin = resolveWin32Bin('node');
228
+ const child = spawn(nodeBin, [__filename, '--worker', tmpFile], {
229
+ detached: true,
230
+ stdio: 'ignore',
231
+ cwd: process.cwd(),
232
+ windowsHide: true,
233
+ });
234
+ child.unref();
235
+ } catch (_err) {
236
+ // Fallback: run inline if spawn fails
237
+ writeBareEpisode(baseDir, project, commits, changedFiles, reads);
238
+ writeEpisodeFile(baseDir, project, commits, changedFiles, reads);
239
+ syncObsidian(baseDir);
240
+ syncGlobalRepo();
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Write a bare session episode to the episodes table.
246
+ * Deterministic — no LLM needed. Captures commits, files, read stats.
247
+ */
248
+ function writeBareEpisode(baseDir, project, commits, changedFiles, reads) {
249
+ try {
250
+ const dbPath = path.join(baseDir, 'mindlore.db');
251
+ const db = openDatabase(dbPath);
252
+ if (!db) return;
253
+
254
+ if (!hasEpisodesTable(db)) {
255
+ ensureEpisodesTable(db);
256
+ }
257
+
258
+ const commitList = commits.length > 0 ? commits.join(', ') : 'no commits';
259
+ const fileCount = changedFiles.length;
260
+ const summary = `Session: ${commitList} (${fileCount} files)`;
261
+
262
+ const bodyParts = [];
263
+ if (commits.length > 0) {
264
+ bodyParts.push('## Commits\n' + commits.map(c => `- ${c}`).join('\n'));
265
+ }
266
+ if (changedFiles.length > 0) {
267
+ bodyParts.push('## Changed Files\n' + changedFiles.map(f => `- ${f}`).join('\n'));
268
+ }
269
+ if (reads) {
270
+ bodyParts.push(`## Read Stats\n- ${reads.count} files read, ${reads.repeats} repeated`);
271
+ }
272
+
273
+ const entities = changedFiles.slice(0, 10);
274
+ const body = bodyParts.join('\n\n') || null;
275
+ const truncatedSummary = summary.slice(0, 300);
276
+
277
+ // Atomic: episode + FTS5 mirror in single transaction
278
+ const writeBoth = db.transaction(() => {
279
+ const epId = insertBareEpisode(db, {
280
+ kind: 'session',
281
+ scope: 'project',
282
+ project: project,
283
+ summary: truncatedSummary,
284
+ body: body,
285
+ tags: 'session',
286
+ entities: entities.length > 0 ? entities : null,
287
+ source: 'hook',
288
+ });
289
+
290
+ // FTS5 mirror — episode searchable via mindlore-search hook
291
+ try {
292
+ insertFtsRow(db, {
293
+ path: `episodes/${epId}`,
294
+ slug: `ep-${epId}`,
295
+ description: truncatedSummary,
296
+ type: 'episode',
297
+ category: 'episodes',
298
+ title: truncatedSummary,
299
+ content: [truncatedSummary, body ?? ''].join('\n').trim(),
300
+ tags: 'session',
301
+ quality: null,
302
+ dateCaptured: new Date().toISOString().slice(0, 10),
303
+ project: project,
304
+ });
305
+ } catch (_ftsErr) {
306
+ // FTS5 mirror optional — don't break the transaction
307
+ }
308
+ });
309
+ writeBoth();
310
+
311
+ // TTL cleanup for episode_inject_log (R4)
312
+ try { cleanupExpiredInjectLog(db); } catch (_err) { /* cleanup is optional */ }
313
+
314
+ db.close();
315
+ } catch (err) {
316
+ hookLog('session-end', 'error', `episode write failed: ${err?.message ?? err}`);
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Write episode as .md file to diary/{project}/ for human-readable browsing.
322
+ * Complements the DB episode — same content, different medium.
323
+ */
324
+ function writeEpisodeFile(baseDir, project, commits, changedFiles, reads) {
325
+ const projDir = path.join(baseDir, 'diary', project || 'unknown');
326
+ if (!fs.existsSync(projDir)) fs.mkdirSync(projDir, { recursive: true });
327
+
328
+ const now = process.env.MINDLORE_EPISODE_TS ? new Date(process.env.MINDLORE_EPISODE_TS) : new Date();
329
+ const ts = formatDate(now);
330
+ const filePath = path.join(projDir, `episode-${ts}.md`);
331
+ if (fs.existsSync(filePath)) return; // idempotent
332
+
333
+ const lines = [
334
+ '---',
335
+ `slug: episode-${ts}`,
336
+ 'type: episode',
337
+ `date: ${now.toISOString().slice(0, 10)}`,
338
+ `project: ${project || 'unknown'}`,
339
+ '---',
340
+ '',
341
+ `# Episode — ${ts}`,
342
+ '',
343
+ ];
344
+
345
+ if (commits.length > 0) {
346
+ lines.push('## Commits');
347
+ for (const c of commits) lines.push(`- ${c}`);
348
+ lines.push('');
349
+ }
350
+
351
+ if (changedFiles.length > 0) {
352
+ lines.push('## Changed Files');
353
+ for (const f of changedFiles) lines.push(`- ${f}`);
354
+ lines.push('');
355
+ }
356
+
357
+ if (reads) {
358
+ lines.push('## Read Stats');
359
+ lines.push(`- ${reads.count} files read, ${reads.repeats} repeated`);
360
+ lines.push('');
361
+ }
362
+
363
+ if (commits.length === 0 && changedFiles.length === 0) {
364
+ lines.push('_Read-only session — no commits or file changes._');
365
+ lines.push('');
366
+ }
367
+
368
+ safeWriteFile(filePath, lines.join('\n'));
369
+ }
370
+
371
+ let _obsidianHelpersCache = undefined; // undefined = not yet attempted
372
+ /**
373
+ * Load obsidian-helpers from compiled dist (single source of truth for wikilink conversion).
374
+ * Returns null if helpers not available (e.g. dev environment without build).
375
+ * Result is cached — require() runs at most once per process.
376
+ */
377
+ function getObsidianHelpers() {
378
+ if (_obsidianHelpersCache !== undefined) return _obsidianHelpersCache;
379
+ try {
380
+ const hookDir = __dirname;
381
+ const pkgRoot = path.dirname(hookDir);
382
+ const helpersPath = path.join(pkgRoot, 'dist', 'scripts', 'lib', 'obsidian-helpers.js');
383
+ _obsidianHelpersCache = require(helpersPath);
384
+ return _obsidianHelpersCache;
385
+ } catch (err) {
386
+ if (process.env.MINDLORE_DEBUG === '1') {
387
+ process.stderr.write(`[mindlore] obsidian-helpers not available: ${err.message}\n`);
388
+ }
389
+ _obsidianHelpersCache = null;
390
+ return null;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Export a single .md file to Obsidian vault with wikilink conversion.
396
+ * Uses obsidian-helpers.convertToWikilinks for consistent behavior.
397
+ * Returns true if file was exported.
398
+ */
399
+ function exportMdFile(srcPath, destPath, convertFn) {
400
+ try {
401
+ const destStat = fs.statSync(destPath);
402
+ const srcStat = fs.statSync(srcPath);
403
+ if (srcStat.mtimeMs <= destStat.mtimeMs) return false;
404
+ } catch (_err) {
405
+ // dest doesn't exist — proceed with export
406
+ }
407
+ let content = fs.readFileSync(srcPath, 'utf8');
408
+ content = convertFn(content);
409
+ fs.writeFileSync(destPath, content, 'utf8');
410
+ return true;
411
+ }
412
+
413
+ /**
414
+ * Auto-export .md files to Obsidian vault if configured.
415
+ * Skips if no vault configured, vault missing, or nothing changed since last export.
416
+ */
417
+ function syncObsidian(baseDir) {
418
+ try {
419
+ const configPath = path.join(baseDir, 'config.json');
420
+ if (!fs.existsSync(configPath)) return;
421
+
422
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
423
+ const vaultPath = config?.obsidian?.vault;
424
+ if (!vaultPath || typeof vaultPath !== 'string') return;
425
+ if (!fs.existsSync(vaultPath)) return;
426
+
427
+ const helpers = getObsidianHelpers();
428
+ // Fallback regex if helpers unavailable (strips path prefixes like the canonical version)
429
+ const convertFn = helpers?.convertToWikilinks
430
+ ?? ((c) => c.replace(/\[([^\]]+)\]\((?:\.\.?\/)?(?:[\w-]+\/)*([^/)]+)\.md\)/g, '[[$2]]'));
431
+
432
+ const destBase = path.join(vaultPath, 'mindlore');
433
+ let exported = 0;
434
+
435
+ function walkAndExport(srcDir, destDir) {
436
+ if (!fs.existsSync(srcDir)) return;
437
+ fs.mkdirSync(destDir, { recursive: true });
438
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
439
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
440
+ const srcPath = path.join(srcDir, entry.name);
441
+ const destPath = path.join(destDir, entry.name);
442
+ if (entry.isDirectory()) {
443
+ walkAndExport(srcPath, destPath);
444
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
445
+ if (exportMdFile(srcPath, destPath, convertFn)) exported++;
446
+ }
447
+ }
448
+ }
449
+
450
+ for (const dir of EXPORT_DIRS) {
451
+ walkAndExport(path.join(baseDir, dir), path.join(destBase, dir));
452
+ }
453
+
454
+ for (const rootFile of ['INDEX.md', 'log.md']) {
455
+ const srcPath = path.join(baseDir, rootFile);
456
+ if (!fs.existsSync(srcPath)) continue;
457
+ fs.mkdirSync(destBase, { recursive: true });
458
+ if (exportMdFile(srcPath, path.join(destBase, rootFile), convertFn)) exported++;
459
+ }
460
+
461
+ hookLog('session-end', 'info', `obsidian exported=${exported}, dirs=${EXPORT_DIRS.length}, vault=${vaultPath}`);
462
+ if (exported > 0) {
463
+ config.obsidian.lastExport = new Date().toISOString();
464
+ config.obsidian.lastExportCount = exported;
465
+ safeWriteJson(configPath, config);
466
+ }
467
+ } catch (err) {
468
+ hookLog('session-end', 'error', `obsidian internal: ${err?.message ?? err}`);
469
+ throw err; // re-throw so safeRun logs FAIL
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Auto-commit and push ~/.mindlore/ if it has a .git directory.
475
+ * Only runs for the global scope — project .mindlore/ is in the project's own git.
476
+ * Push failure is graceful (offline support).
477
+ */
478
+ function resolveGitBin() {
479
+ return resolveWin32Bin('git');
480
+ }
481
+
482
+ function syncGlobalRepo() {
483
+ const gDir = globalDir();
484
+ const gitDir = path.join(gDir, '.git');
485
+ if (!fs.existsSync(gitDir)) return;
486
+
487
+ const git = resolveGitBin();
488
+ const execOpts = (timeout) => ({ cwd: gDir, encoding: 'utf8', timeout, stdio: 'pipe', windowsHide: true });
489
+
490
+ // Check for changes
491
+ const status = execFileSync(git, ['status', '--porcelain'], execOpts(5000)).trim();
492
+ if (!status) return; // nothing to commit
493
+
494
+ execFileSync(git, ['add', '*.md', 'mindlore.db', 'diary/', 'sources/', 'domains/', 'analyses/', 'decisions/', 'raw/', 'connections/', 'insights/', 'learnings/'], execOpts(10000));
495
+ const now = new Date().toISOString().slice(0, 19);
496
+ execFileSync(git, ['commit', '-m', `mindlore auto-sync ${now}`], execOpts(15000));
497
+
498
+ // Push — graceful fail if no remote or offline
499
+ try {
500
+ execFileSync(git, ['push'], execOpts(30000));
501
+ } catch (_pushErr) {
502
+ hookLog('session-end', 'warn', 'git push failed (offline?): ' + (_pushErr?.message ?? '').slice(0, 100));
503
+ }
504
+ }
505
+
506
+ if (!process.argv.includes('--worker')) {
507
+ withTelemetry('mindlore-session-end', main).catch(err => {
508
+ hookLog('mindlore-session-end', 'error', err?.message ?? String(err));
509
+ process.exit(0);
510
+ });
511
+ }