oh-my-adhd 0.2.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.
@@ -0,0 +1,396 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { randomUUID } from "crypto";
5
+ export const BRAIN_DIR = process.env.OH_MY_ADHD_DIR ?? path.join(os.homedir(), ".oh-my-adhd");
6
+ const THREADS_DIR = path.join(BRAIN_DIR, "threads");
7
+ const PAGES_DIR = path.join(BRAIN_DIR, "pages");
8
+ const MANIFEST_FILE = path.join(THREADS_DIR, ".manifest.json");
9
+ const MANIFEST_LOCK_FILE = path.join(THREADS_DIR, ".manifest.lock");
10
+ const MANIFEST_LOCK_TTL_MS = 10000;
11
+ const LOG_FILE = path.join(BRAIN_DIR, "logs", "brain.log");
12
+ const VERSION_FILE = path.join(BRAIN_DIR, "VERSION");
13
+ export const SCHEMA_VERSION = 1;
14
+ async function appendLog(level, msg) {
15
+ try {
16
+ const entry = `${new Date().toISOString()} [${level}] ${msg}\n`;
17
+ await fs.appendFile(LOG_FILE, entry, "utf-8");
18
+ }
19
+ catch { /* logging failures must never crash the server */ }
20
+ }
21
+ export async function ensureBrainDirs() {
22
+ await fs.mkdir(THREADS_DIR, { recursive: true });
23
+ await fs.mkdir(PAGES_DIR, { recursive: true });
24
+ await fs.mkdir(path.join(BRAIN_DIR, "logs"), { recursive: true });
25
+ try {
26
+ await fs.access(VERSION_FILE);
27
+ }
28
+ catch {
29
+ await fs.writeFile(VERSION_FILE, String(SCHEMA_VERSION), "utf-8");
30
+ }
31
+ }
32
+ async function acquireManifestFileLock() {
33
+ const content = JSON.stringify({ pid: process.pid, ts: Date.now() });
34
+ for (let attempt = 0; attempt < 500; attempt++) {
35
+ try {
36
+ const fh = await fs.open(MANIFEST_LOCK_FILE, "wx");
37
+ await fh.writeFile(content, "utf-8");
38
+ await fh.close();
39
+ return true;
40
+ }
41
+ catch (e) {
42
+ const code = e?.code;
43
+ if (code === "ENOENT") {
44
+ await fs.mkdir(path.dirname(MANIFEST_LOCK_FILE), { recursive: true }).catch(() => { });
45
+ continue;
46
+ }
47
+ if (code !== "EEXIST")
48
+ throw e;
49
+ // Lock exists — check if stale
50
+ try {
51
+ const raw = await fs.readFile(MANIFEST_LOCK_FILE, "utf-8");
52
+ const { ts } = JSON.parse(raw);
53
+ if (Date.now() - ts > MANIFEST_LOCK_TTL_MS) {
54
+ await fs.unlink(MANIFEST_LOCK_FILE).catch(() => { });
55
+ continue; // retry immediately
56
+ }
57
+ }
58
+ catch {
59
+ // Lock file disappeared or is unreadable — retry
60
+ continue;
61
+ }
62
+ await new Promise((r) => setTimeout(r, 25));
63
+ }
64
+ }
65
+ return false; // timed out
66
+ }
67
+ async function releaseManifestFileLock() {
68
+ await fs.unlink(MANIFEST_LOCK_FILE).catch(() => { });
69
+ }
70
+ // Two-tier lock: in-process promise chain (fast) + cross-process file lock (safe)
71
+ let manifestLock = Promise.resolve();
72
+ function withManifestLock(fn) {
73
+ const run = async () => {
74
+ const acquired = await acquireManifestFileLock();
75
+ if (!acquired) {
76
+ await appendLog("WARN", "withManifestLock: cross-process lock timeout, proceeding with in-process lock only");
77
+ // Proceed with in-process serialization only — better than losing the capture
78
+ }
79
+ try {
80
+ return await fn();
81
+ }
82
+ finally {
83
+ if (acquired)
84
+ await releaseManifestFileLock();
85
+ }
86
+ };
87
+ const result = manifestLock.then(run, run);
88
+ manifestLock = result.then(() => undefined, () => undefined);
89
+ return result;
90
+ }
91
+ export function withBrainLock(fn) {
92
+ return withManifestLock(fn);
93
+ }
94
+ async function readManifest() {
95
+ try {
96
+ const raw = await fs.readFile(MANIFEST_FILE, "utf-8");
97
+ return JSON.parse(raw);
98
+ }
99
+ catch (e) {
100
+ if (e?.code !== "ENOENT") {
101
+ process.stderr.write(`[oh-my-adhd] manifest parse error — rebuilding: ${e}\n`);
102
+ try {
103
+ await fs.copyFile(MANIFEST_FILE, `${MANIFEST_FILE}.corrupt.${Date.now()}`);
104
+ }
105
+ catch { /* ignore backup failure */ }
106
+ }
107
+ return [];
108
+ }
109
+ }
110
+ async function writeManifest(threads) {
111
+ const tmp = path.join(THREADS_DIR, `.tmp-manifest-${randomUUID()}`);
112
+ await fs.writeFile(tmp, JSON.stringify(threads, null, 2), "utf-8");
113
+ await fs.rename(tmp, MANIFEST_FILE);
114
+ }
115
+ export function extractFieldBrain(text, field) {
116
+ const lines = text.split("\n");
117
+ const fieldRe = new RegExp(`^\\s*${field}\\s*:`, "i");
118
+ const delimRe = /^\s*(?:결정|가설|막힌것|다음할것|블로커|요약|상태)\s*:/i;
119
+ let capturing = false;
120
+ const parts = [];
121
+ for (const line of lines) {
122
+ if (!capturing) {
123
+ if (fieldRe.test(line)) {
124
+ capturing = true;
125
+ const val = line.replace(fieldRe, "").trim();
126
+ if (val)
127
+ parts.push(val);
128
+ }
129
+ }
130
+ else {
131
+ if (delimRe.test(line) || /^\[git:/i.test(line.trim()))
132
+ break;
133
+ const trimmed = line.trim();
134
+ if (trimmed)
135
+ parts.push(trimmed);
136
+ else if (parts.length > 0)
137
+ break;
138
+ }
139
+ }
140
+ return parts.join(" ").trim();
141
+ }
142
+ // Strip git suffix appended by wiki_dump before title/dedup operations
143
+ export function stripGitSuffix(content) {
144
+ return content.trimEnd().replace(/\n\[git:.*\]$/, "").trimEnd();
145
+ }
146
+ export const OPEN_SIGNAL = /(?:^|\n)\s*(?:다음할것|블로커|막힌것|가설|next|blocked|todo|wip)\s*:\s*\S/i;
147
+ export const DONE_SIGNAL = /(?:^|\n)\s*상태:\s*[^\n]*(완료|해결됨|배포됨|종료|done|shipped|closed)/i;
148
+ export function extractTitle(content) {
149
+ const raw = stripGitSuffix(content);
150
+ const lines = raw.split("\n").map(l => l.trim()).filter(l => l.length > 0);
151
+ // 구조화 dump: 요약 필드를 제목으로 — "결정: ..." 같은 첫 줄보다 의미있음
152
+ const summary = lines.find(l => /^요약\s*:/i.test(l));
153
+ const pick = summary ?? lines[0] ?? "";
154
+ const clean = pick
155
+ .replace(/^#+\s*/, "")
156
+ .replace(/^(?:요약|결정|가설|막힌것|다음할것|블로커)\s*:\s*/i, "");
157
+ return clean.trim().slice(0, 40) || raw.trim().slice(0, 40);
158
+ }
159
+ export async function saveCapture(content, threadId) {
160
+ await ensureBrainDirs();
161
+ const captureId = randomUUID();
162
+ const timestamp = new Date().toISOString();
163
+ const tid = threadId ?? captureId;
164
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(tid)) {
165
+ throw new Error(`Invalid threadId: ${tid}`);
166
+ }
167
+ const threadFile = path.join(THREADS_DIR, `${tid}.md`);
168
+ const capture = { id: captureId, content, timestamp, threadId: tid };
169
+ // Thread read→write AND manifest update serialized together
170
+ // 같은 threadId로 동시 dump가 와도 append가 유실되지 않음
171
+ let title = "";
172
+ let skipped = false;
173
+ await withManifestLock(async () => {
174
+ let existingContent = "";
175
+ try {
176
+ existingContent = await fs.readFile(threadFile, "utf-8");
177
+ }
178
+ catch (e) {
179
+ if (e?.code !== "ENOENT")
180
+ throw e;
181
+ }
182
+ title = existingContent
183
+ ? (existingContent.match(/^# (.+)/m)?.[1]?.trim() || extractTitle(content))
184
+ : extractTitle(content);
185
+ if (existingContent) {
186
+ const blocks = existingContent.split(/\n---\n/);
187
+ const lastBlock = blocks[blocks.length - 1] ?? "";
188
+ const lastText = stripGitSuffix(lastBlock.trim().replace(/^\*\*[^*\n]+\*\*\s*\n*/, "").trim());
189
+ if (stripGitSuffix(content.trim()) === lastText) {
190
+ skipped = true;
191
+ return;
192
+ }
193
+ }
194
+ const entry = `\n---\n**${timestamp}**\n\n${content}\n`;
195
+ const header = existingContent
196
+ ? existingContent
197
+ : `# ${title}\n\n_created: ${timestamp}_\n`;
198
+ const tmpThread = path.join(THREADS_DIR, `.tmp-${randomUUID()}`);
199
+ await fs.writeFile(tmpThread, header + entry, "utf-8");
200
+ await fs.rename(tmpThread, threadFile);
201
+ const manifest = await readManifest();
202
+ const idx = manifest.findIndex((m) => m.id === tid);
203
+ // Compute signal cache fields from new content
204
+ const stripped = stripGitSuffix(content).trim();
205
+ const is_open = OPEN_SIGNAL.test(stripped);
206
+ const is_done = DONE_SIGNAL.test(stripped) && !is_open;
207
+ const last_action = stripped.replace(/\n+/g, " ").slice(0, 160);
208
+ const next_action = extractFieldBrain(stripped, "다음할것").slice(0, 120);
209
+ const blocker = extractFieldBrain(stripped, "막힌것").slice(0, 120);
210
+ const existingCount = existingContent
211
+ ? existingContent.split(/\n---\n/).slice(1).filter((p) => p.trim()).length
212
+ : 0;
213
+ const capture_count = existingCount + 1;
214
+ const meta = { id: tid, title, updatedAt: timestamp, is_open, last_action, capture_count, is_done, next_action, blocker };
215
+ if (idx >= 0)
216
+ manifest[idx] = meta;
217
+ else
218
+ manifest.push(meta);
219
+ await writeManifest(manifest.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
220
+ });
221
+ return { capture, threadId: tid, title, skipped };
222
+ }
223
+ export async function getThreads() {
224
+ await ensureBrainDirs();
225
+ const manifest = await readManifest();
226
+ if (manifest.length > 0) {
227
+ return manifest.map((m) => ({ ...m, captures: [] }));
228
+ }
229
+ // Fallback: directory scan (first run or manifest missing)
230
+ const files = await fs.readdir(THREADS_DIR);
231
+ const mdFiles = files.filter((f) => f.endsWith(".md") && !f.startsWith(".") && !f.endsWith(".summary.md"));
232
+ const results = await Promise.allSettled(mdFiles.map(async (file) => {
233
+ const filePath = path.join(THREADS_DIR, file);
234
+ const [content, stat] = await Promise.all([
235
+ fs.readFile(filePath, "utf-8"),
236
+ fs.stat(filePath),
237
+ ]);
238
+ const tid = file.replace(".md", "");
239
+ const title = content.match(/^# (.+)/m)?.[1]?.trim() || tid;
240
+ const scanCaptures = content.split(/\n---\n/).slice(1).filter((p) => p.trim());
241
+ const lastCapture = scanCaptures.at(-1) ?? "";
242
+ const fullText = lastCapture.replace(/^(?:_[^_\n]+_|\*\*[^*\n]+\*\*)\s*/m, "").trim();
243
+ const is_open = OPEN_SIGNAL.test(fullText);
244
+ return {
245
+ id: tid,
246
+ title,
247
+ captures: [],
248
+ updatedAt: stat.mtime.toISOString(),
249
+ is_open,
250
+ last_action: fullText.replace(/\n+/g, " ").slice(0, 160),
251
+ next_action: extractFieldBrain(fullText, "다음할것").slice(0, 120),
252
+ blocker: extractFieldBrain(fullText, "막힌것").slice(0, 120),
253
+ capture_count: scanCaptures.length,
254
+ is_done: DONE_SIGNAL.test(fullText) && !OPEN_SIGNAL.test(fullText),
255
+ };
256
+ }));
257
+ results
258
+ .filter((r) => r.status === "rejected")
259
+ .forEach((r, i) => appendLog("WARN", `getThreads: failed to read ${mdFiles[i]}: ${r.reason}`));
260
+ const threads = results
261
+ .filter((r) => r.status === "fulfilled")
262
+ .map((r) => r.value);
263
+ const sorted = threads.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
264
+ // 락 안에서 재확인 — 동시 saveCapture가 이미 manifest를 썼을 경우 merge
265
+ await withManifestLock(async () => {
266
+ const current = await readManifest();
267
+ const currentIds = new Set(current.map(m => m.id));
268
+ const toAdd = sorted
269
+ .filter(t => !currentIds.has(t.id))
270
+ .map(({ id, title, updatedAt, is_open, last_action, next_action, blocker, capture_count, is_done }) => ({ id, title, updatedAt, is_open, last_action, next_action, blocker, capture_count, is_done }));
271
+ if (toAdd.length === 0)
272
+ return;
273
+ await writeManifest([...current, ...toAdd].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
274
+ });
275
+ return sorted;
276
+ }
277
+ export async function getThread(threadId) {
278
+ if (!threadId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(threadId))
279
+ return null;
280
+ try {
281
+ return await fs.readFile(path.join(THREADS_DIR, `${threadId}.md`), "utf-8");
282
+ }
283
+ catch {
284
+ return null;
285
+ }
286
+ }
287
+ export async function getPages() {
288
+ await ensureBrainDirs();
289
+ const files = await fs.readdir(PAGES_DIR);
290
+ const mdFiles = files.filter((f) => f.endsWith(".md") && !f.startsWith("."));
291
+ const results = await Promise.allSettled(mdFiles.map(async (file) => {
292
+ const filePath = path.join(PAGES_DIR, file);
293
+ const [content, stat] = await Promise.all([
294
+ fs.readFile(filePath, "utf-8"),
295
+ fs.stat(filePath),
296
+ ]);
297
+ const slug = file.replace(".md", "");
298
+ const titleMatch = content.match(/^# (.+)/m);
299
+ const title = titleMatch?.[1] ?? slug;
300
+ const linkMatches = [...content.matchAll(/\[\[([^\]]+)\]\]/g)];
301
+ const links = linkMatches.map((m) => m[1]);
302
+ return { slug, title, content, links, updatedAt: stat.mtime.toISOString() };
303
+ }));
304
+ results
305
+ .filter((r) => r.status === "rejected")
306
+ .forEach((r, i) => appendLog("WARN", `getPages: failed to read ${mdFiles[i]}: ${r.reason}`));
307
+ return results
308
+ .filter((r) => r.status === "fulfilled")
309
+ .map((r) => r.value);
310
+ }
311
+ export async function getPage(slug) {
312
+ const s = slug?.toLowerCase() ?? "";
313
+ if (!s || s.includes("/") || s.includes("\\") || s.includes("..") || s.includes("\0") || !/^[a-z0-9가-힣-]+$/.test(s))
314
+ return null;
315
+ try {
316
+ const filePath = path.join(PAGES_DIR, `${s}.md`);
317
+ const [content, stat] = await Promise.all([
318
+ fs.readFile(filePath, "utf-8"),
319
+ fs.stat(filePath),
320
+ ]);
321
+ const titleMatch = content.match(/^# (.+)/m);
322
+ const title = titleMatch?.[1] ?? slug;
323
+ const linkMatches = [...content.matchAll(/\[\[([^\]]+)\]\]/g)];
324
+ const links = linkMatches.map((m) => m[1]);
325
+ return { slug, title, content, links, updatedAt: stat.mtime.toISOString() };
326
+ }
327
+ catch {
328
+ return null;
329
+ }
330
+ }
331
+ export async function savePage(slug, content) {
332
+ const s = slug?.toLowerCase() ?? "";
333
+ if (!s || s.includes("/") || s.includes("\\") || s.includes("..") || s.includes("\0") || !/^[a-z0-9가-힣-]+$/.test(s) || !/[a-z0-9가-힣]/.test(s)) {
334
+ throw new Error(`Invalid slug: ${slug}`);
335
+ }
336
+ await ensureBrainDirs();
337
+ const pageFile = path.join(PAGES_DIR, `${s}.md`);
338
+ const tmpPage = path.join(PAGES_DIR, `.tmp-${randomUUID()}`);
339
+ await fs.writeFile(tmpPage, content, "utf-8");
340
+ await fs.rename(tmpPage, pageFile);
341
+ }
342
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
343
+ const TRASH_DIR = path.join(BRAIN_DIR, ".trash");
344
+ export async function deleteThread(threadId) {
345
+ if (!UUID_RE.test(threadId)) {
346
+ throw new Error(`Invalid threadId format: ${threadId}`);
347
+ }
348
+ await ensureBrainDirs();
349
+ await fs.mkdir(TRASH_DIR, { recursive: true });
350
+ const threadFile = path.join(THREADS_DIR, `${threadId}.md`);
351
+ // Move to trash before deleting (backup)
352
+ const trashFile = path.join(TRASH_DIR, `${threadId}-${Date.now()}.md`);
353
+ try {
354
+ await fs.rename(threadFile, trashFile);
355
+ }
356
+ catch (e) {
357
+ if (e?.code !== "ENOENT")
358
+ throw e;
359
+ // File didn't exist — still remove from manifest
360
+ }
361
+ await withManifestLock(async () => {
362
+ const manifest = await readManifest();
363
+ const filtered = manifest.filter((m) => m.id !== threadId);
364
+ if (filtered.length !== manifest.length) {
365
+ await writeManifest(filtered);
366
+ }
367
+ });
368
+ }
369
+ export async function deletePage(slug) {
370
+ const s = slug?.toLowerCase() ?? "";
371
+ if (!s || s.includes("/") || s.includes("\\") || s.includes("..") || s.includes("\0") || !/^[a-z0-9가-힣-]+$/.test(s)) {
372
+ throw new Error(`Invalid slug: ${slug}`);
373
+ }
374
+ await ensureBrainDirs();
375
+ const pageFile = path.join(PAGES_DIR, `${s}.md`);
376
+ try {
377
+ await fs.unlink(pageFile);
378
+ }
379
+ catch (e) {
380
+ if (e?.code !== "ENOENT")
381
+ throw e;
382
+ throw new Error(`Page not found: ${slug}`);
383
+ }
384
+ }
385
+ // Exported for consolidate.ts to update manifest entries after trimming
386
+ export async function updateManifestEntry(id, fields) {
387
+ await ensureBrainDirs();
388
+ await withManifestLock(async () => {
389
+ const manifest = await readManifest();
390
+ const idx = manifest.findIndex((m) => m.id === id);
391
+ if (idx >= 0) {
392
+ manifest[idx] = { ...manifest[idx], ...fields };
393
+ await writeManifest(manifest.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
394
+ }
395
+ });
396
+ }
@@ -0,0 +1,190 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { randomUUID } from "crypto";
4
+ import { BRAIN_DIR, updateManifestEntry, getThreads, withBrainLock, extractFieldBrain } from "./brain.js";
5
+ const THREADS_DIR = path.join(BRAIN_DIR, "threads");
6
+ const CONSOLIDATION_STATE = path.join(THREADS_DIR, ".consolidation.json");
7
+ const LOCK_FILE = path.join(THREADS_DIR, ".consolidation.lock");
8
+ const LOCK_TTL_MS = 10 * 60 * 1000; // 10 min — stale lock threshold
9
+ async function acquireFileLock() {
10
+ try {
11
+ await fs.writeFile(LOCK_FILE, String(process.pid), { flag: "wx" });
12
+ return true;
13
+ }
14
+ catch (e) {
15
+ if (e?.code !== "EEXIST")
16
+ return false;
17
+ // Stale lock check
18
+ try {
19
+ const stat = await fs.stat(LOCK_FILE);
20
+ if (Date.now() - stat.mtimeMs > LOCK_TTL_MS) {
21
+ await fs.unlink(LOCK_FILE);
22
+ await fs.writeFile(LOCK_FILE, String(process.pid), { flag: "wx" });
23
+ return true;
24
+ }
25
+ }
26
+ catch { /* ignore */ }
27
+ return false;
28
+ }
29
+ }
30
+ async function releaseFileLock() {
31
+ try {
32
+ const content = await fs.readFile(LOCK_FILE, "utf-8");
33
+ if (content.trim() === String(process.pid)) {
34
+ await fs.unlink(LOCK_FILE);
35
+ }
36
+ }
37
+ catch { /* ignore — lock already gone or unreadable */ }
38
+ }
39
+ // Korean/English stopwords that add no meaning to keyword extraction
40
+ const STOPWORDS = new Set([
41
+ "이", "의", "가", "을", "를", "은", "는", "에", "도", "로", "과", "와",
42
+ "이다", "있다", "없다", "하다", "된다", "한다", "그", "그리고", "그래서",
43
+ "the", "a", "an", "is", "are", "was", "were", "and", "or", "to", "of",
44
+ "in", "on", "at", "for", "with", "it", "this", "that",
45
+ ]);
46
+ async function readState() {
47
+ try {
48
+ return JSON.parse(await fs.readFile(CONSOLIDATION_STATE, "utf-8"));
49
+ }
50
+ catch {
51
+ return { lastRun: new Date(0).toISOString(), archivedCount: 0 };
52
+ }
53
+ }
54
+ function extractKeywords(text) {
55
+ // Strip markdown header, timestamps, git context, separators before tokenizing
56
+ const cleaned = text
57
+ .replace(/^#[^\n]*/gm, "") // headings
58
+ .replace(/_[^_\n]+_/g, "") // _italic_
59
+ .replace(/\*\*[^*\n]+\*\*/g, "") // **bold** (timestamps)
60
+ .replace(/\[git:[^\]]*\]/g, "") // git context
61
+ .replace(/^---+$/gm, "") // separators
62
+ .replace(/\[consolidated:[^\]]*\]/g, "") // already-compressed marker
63
+ .replace(/\d{4}-\d{2}-\d{2}T[^\s]*/g, "") // ISO timestamps
64
+ .replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/gi, "") // UUIDs
65
+ .toLowerCase();
66
+ const freq = new Map();
67
+ cleaned
68
+ .replace(/[^\w가-힣\s]/g, " ")
69
+ .split(/\s+/)
70
+ .filter(w => w.length > 1 && !STOPWORDS.has(w))
71
+ .forEach(w => freq.set(w, (freq.get(w) ?? 0) + 1));
72
+ // Top-20 by frequency
73
+ return new Set([...freq.entries()]
74
+ .sort((a, b) => b[1] - a[1])
75
+ .slice(0, 20)
76
+ .map(([w]) => w));
77
+ }
78
+ const STALE_DAYS = 30;
79
+ async function consolidateThread(threadId, threadFile, cutoff) {
80
+ return withBrainLock(async () => {
81
+ let content;
82
+ try {
83
+ content = await fs.readFile(threadFile, "utf-8");
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ // Already consolidated — check for the marker at end of file
89
+ if (/\[consolidated:/i.test(content)) {
90
+ return false;
91
+ }
92
+ // Extract key structured fields from the last few captures for sidecar
93
+ const captures = content.split(/\n---\n/).slice(1).filter(p => p.trim());
94
+ const lastCapture = captures.at(-1) ?? "";
95
+ const fullText = lastCapture.replace(/^(?:_[^_\n]+_|\*\*[^*\n]+\*\*)\s*/m, "").trim();
96
+ const fields = ["결정", "가설", "막힌것", "다음할것", "블로커", "요약"];
97
+ const extracted = fields
98
+ .map(f => {
99
+ const val = extractFieldBrain(fullText, f).slice(0, 200);
100
+ return val ? `${f}: ${val}` : "";
101
+ })
102
+ .filter(Boolean);
103
+ const ts = new Date().toISOString();
104
+ const marker = `\n\n[consolidated: ${ts}]\n`;
105
+ const tmpFile = path.join(path.dirname(threadFile), `.tmp-${randomUUID()}`);
106
+ await fs.writeFile(tmpFile, content + marker, "utf-8");
107
+ await fs.rename(tmpFile, threadFile);
108
+ const sidecarFile = threadFile.replace(/\.md$/, ".summary.md");
109
+ const titleMatch = content.match(/^#\s+(.+)$/m);
110
+ const title = titleMatch?.[1]?.trim() ?? threadId;
111
+ const sidecarContent = [
112
+ `# Summary: ${title}`,
113
+ `_consolidated: ${ts}_`,
114
+ `_captures: ${captures.length}_`,
115
+ ``,
116
+ extracted.length > 0 ? extracted.join("\n") : "(구조화된 필드 없음)",
117
+ ``,
118
+ `→ 원문: ${path.basename(threadFile)}`,
119
+ ].join("\n");
120
+ const tmpSidecar = path.join(path.dirname(sidecarFile), `.tmp-${randomUUID()}`);
121
+ await fs.writeFile(tmpSidecar, sidecarContent, "utf-8");
122
+ await fs.rename(tmpSidecar, sidecarFile);
123
+ return true;
124
+ });
125
+ }
126
+ // Stage A: non-destructively consolidate threads not accessed in 30+ days
127
+ // 매번 최신 manifest를 읽어서 직전 wiki_dump가 updatedAt을 갱신했는지 재확인
128
+ async function ageBasedTrim() {
129
+ const threads = await getThreads();
130
+ const cutoff = Date.now() - STALE_DAYS * 24 * 3600 * 1000;
131
+ const stale = threads.filter(t => new Date(t.updatedAt).getTime() < cutoff);
132
+ let trimmed = 0;
133
+ await Promise.allSettled(stale.map(async (t) => {
134
+ const threadFile = path.join(THREADS_DIR, `${t.id}.md`);
135
+ const consolidated = await consolidateThread(t.id, threadFile, cutoff);
136
+ if (consolidated) {
137
+ // Update manifest: mark as consolidated, preserve updatedAt
138
+ const originalMs = new Date(t.updatedAt).getTime();
139
+ await updateManifestEntry(t.id, {
140
+ updatedAt: new Date(Math.min(originalMs, cutoff - 1)).toISOString(),
141
+ });
142
+ trimmed++;
143
+ }
144
+ }));
145
+ return trimmed;
146
+ }
147
+ // 동시 consolidation 방지 — parallel wiki_recall 호출이 두 번 트리거하는 것 방지
148
+ // flag은 첫 await 이전에 set — TOCTOU 방지
149
+ let _consolidating = false;
150
+ export async function runConsolidationIfDue(threads) {
151
+ if (_consolidating)
152
+ return;
153
+ _consolidating = true;
154
+ let state;
155
+ try {
156
+ state = await readState();
157
+ const hoursSince = (Date.now() - new Date(state.lastRun).getTime()) / 3600000;
158
+ if (hoursSince < 24 || threads.length < 50) {
159
+ _consolidating = false;
160
+ return;
161
+ }
162
+ }
163
+ catch {
164
+ _consolidating = false;
165
+ return;
166
+ }
167
+ setImmediate(async () => {
168
+ const locked = await acquireFileLock();
169
+ if (!locked) {
170
+ _consolidating = false;
171
+ return;
172
+ }
173
+ try {
174
+ const trimmed = await ageBasedTrim();
175
+ const tmp = path.join(THREADS_DIR, `.tmp-consolidation-${randomUUID()}`);
176
+ await fs.writeFile(tmp, JSON.stringify({
177
+ lastRun: new Date().toISOString(),
178
+ archivedCount: state.archivedCount + trimmed,
179
+ }, null, 2));
180
+ await fs.rename(tmp, CONSOLIDATION_STATE);
181
+ }
182
+ catch (e) {
183
+ process.stderr.write(`[oh-my-adhd consolidation error] ${e}\n`);
184
+ }
185
+ finally {
186
+ await releaseFileLock();
187
+ _consolidating = false;
188
+ }
189
+ });
190
+ }
@@ -0,0 +1,98 @@
1
+ import { getPages, savePage, getThreads, getThread } from "./brain.js";
2
+ import { buildSearchIndex, searchPages } from "./search.js";
3
+ function escapeRegex(str) {
4
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
6
+ // 기존 페이지와 키워드 매칭으로 자동 링크 생성
7
+ export async function autoLinkContent(content) {
8
+ const pages = await getPages();
9
+ // 긴 제목 먼저 매칭 — "Next" 페이지가 "Next.js 라우팅" 내부를 오염시키는 것 방지
10
+ const sortedPages = [...pages].sort((a, b) => b.title.length - a.title.length);
11
+ // Protect fenced code blocks and inline code from link injection
12
+ const placeholders = [];
13
+ let linked = content.replace(/```[\s\S]*?```|`[^`\n]+`/g, (m) => {
14
+ const ph = `\x00C${placeholders.length}\x00`;
15
+ placeholders.push(m);
16
+ return ph;
17
+ });
18
+ for (const page of sortedPages) {
19
+ const title = page.title;
20
+ // Case-insensitive check to prevent double-wrapping [[[[title]]]]
21
+ if (linked.toLowerCase().includes(`[[${title.toLowerCase()}]]`))
22
+ continue;
23
+ const boundary = "(?<![\\p{L}\\p{N}])";
24
+ const regex = new RegExp(`${boundary}${escapeRegex(title)}(?![\\p{L}\\p{N}])`, "giu");
25
+ linked = linked.replace(regex, `[[${title}]]`);
26
+ }
27
+ // Restore protected code segments
28
+ return linked.replace(/\x00C(\d+)\x00/g, (_, i) => placeholders[parseInt(i, 10)]);
29
+ }
30
+ // BM25 기반 관련 페이지 탐색 — 첫 의미있는 줄만 쿼리로 사용 (full content는 노이즈)
31
+ export async function findRelatedPages(content) {
32
+ const pages = await getPages();
33
+ if (pages.length === 0)
34
+ return [];
35
+ const queryHint = content
36
+ .split("\n")
37
+ .find(l => l.trim().length > 3 && !l.startsWith("[") && !l.startsWith("#") && !l.startsWith("_"))
38
+ ?.slice(0, 120) ?? content.slice(0, 120);
39
+ return searchPages(pages, queryHint, 5);
40
+ }
41
+ // 캡처에서 위키 페이지 자동 생성/업데이트
42
+ export async function upsertPageFromCapture(title, content) {
43
+ const slug = title
44
+ .toLowerCase()
45
+ .replace(/[^가-힣a-zA-Z0-9]/g, "-")
46
+ .replace(/-+/g, "-")
47
+ .replace(/^-|-$/g, "");
48
+ if (!slug)
49
+ return; // title이 전부 특수문자면 slug가 비어 있음 — 저장 불가
50
+ const capturesOnly = content.includes("\n---\n")
51
+ ? content.slice(content.indexOf("\n---\n"))
52
+ : content;
53
+ const linkedContent = await autoLinkContent(capturesOnly);
54
+ const pageContent = `# ${title}\n\n_updated: ${new Date().toISOString()}_\n${linkedContent}\n`;
55
+ await savePage(slug, pageContent);
56
+ }
57
+ // BM25 기반 그래프 엣지 생성
58
+ export async function buildGraphData() {
59
+ const MAX_NODES = 200;
60
+ const [threads, pages] = await Promise.all([getThreads(), getPages()]);
61
+ const nodes = [];
62
+ const edges = [];
63
+ const edgeSet = new Set();
64
+ const addEdge = (source, target) => {
65
+ const key = `${source}→${target}`;
66
+ if (!edgeSet.has(key) && source !== target) {
67
+ edgeSet.add(key);
68
+ edges.push({ source, target });
69
+ }
70
+ };
71
+ const index = buildSearchIndex(pages);
72
+ const pageSlugSet = new Set(pages.map((p) => p.slug));
73
+ const threadContents = await Promise.all(threads.slice(0, MAX_NODES).map((t) => getThread(t.id)));
74
+ for (let i = 0; i < Math.min(threads.length, MAX_NODES); i++) {
75
+ const t = threads[i];
76
+ const content = threadContents[i];
77
+ nodes.push({ id: `thread-${t.id}`, label: t.title, type: "thread" });
78
+ if (!content)
79
+ continue;
80
+ // Use only meaningful body content, not frontmatter/timestamps
81
+ const body = content.split(/\n---\n/).slice(1).join(" ").slice(0, 300);
82
+ const related = index.search(body).slice(0, 3);
83
+ for (const r of related) {
84
+ if (pageSlugSet.has(r.slug)) {
85
+ addEdge(`thread-${t.id}`, `page-${r.slug}`);
86
+ }
87
+ }
88
+ }
89
+ for (const p of pages.slice(0, MAX_NODES)) {
90
+ nodes.push({ id: `page-${p.slug}`, label: p.title, type: "page" });
91
+ for (const link of p.links) {
92
+ const targetSlug = pages.find((pg) => pg.title.toLowerCase() === link.toLowerCase())?.slug;
93
+ if (targetSlug)
94
+ addEdge(`page-${p.slug}`, `page-${targetSlug}`);
95
+ }
96
+ }
97
+ return { nodes, edges };
98
+ }