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.
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/bin/oh-my-adhd.mjs +382 -0
- package/dist/mcp/lib/brain.js +396 -0
- package/dist/mcp/lib/consolidate.js +190 -0
- package/dist/mcp/lib/linker.js +98 -0
- package/dist/mcp/lib/search.js +99 -0
- package/dist/mcp/mcp/server.js +36 -0
- package/dist/mcp/mcp/tools/wiki-delete.js +22 -0
- package/dist/mcp/mcp/tools/wiki-dump.js +85 -0
- package/dist/mcp/mcp/tools/wiki-graph.js +19 -0
- package/dist/mcp/mcp/tools/wiki-link.js +33 -0
- package/dist/mcp/mcp/tools/wiki-pages.js +20 -0
- package/dist/mcp/mcp/tools/wiki-query.js +67 -0
- package/dist/mcp/mcp/tools/wiki-recall.js +205 -0
- package/dist/mcp/mcp/tools/wiki-save.js +22 -0
- package/dist/mcp/mcp/tools/wiki-setup.js +25 -0
- package/dist/mcp/mcp/tools/wiki-structure.js +28 -0
- package/dist/mcp/mcp/tools/wiki-unstick.js +110 -0
- package/dist/mcp/mcp/utils.js +31 -0
- package/package.json +54 -0
- package/scripts/capture.sh +31 -0
- package/scripts/com.oh-my-adhd.server.plist +28 -0
- package/scripts/demo.sh +43 -0
- package/scripts/install-launchagent.sh +35 -0
- package/scripts/stop-hook.mjs +42 -0
|
@@ -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
|
+
}
|