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