oh-my-adhd 0.2.12 → 0.2.14
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/dist/mcp/lib/brain.js
CHANGED
|
@@ -11,6 +11,7 @@ const MANIFEST_LOCK_TTL_MS = 10000;
|
|
|
11
11
|
const LOG_FILE = path.join(BRAIN_DIR, "logs", "brain.log");
|
|
12
12
|
const VERSION_FILE = path.join(BRAIN_DIR, "VERSION");
|
|
13
13
|
export const SCHEMA_VERSION = 1;
|
|
14
|
+
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
15
|
async function appendLog(level, msg) {
|
|
15
16
|
try {
|
|
16
17
|
const entry = `${new Date().toISOString()} [${level}] ${msg}\n`;
|
|
@@ -169,7 +170,7 @@ export async function saveCapture(content, threadId) {
|
|
|
169
170
|
const captureId = randomUUID();
|
|
170
171
|
const timestamp = new Date().toISOString();
|
|
171
172
|
const tid = threadId ?? captureId;
|
|
172
|
-
if (
|
|
173
|
+
if (!UUID_RE.test(tid)) {
|
|
173
174
|
throw new Error(`Invalid threadId: ${tid}`);
|
|
174
175
|
}
|
|
175
176
|
const threadFile = path.join(THREADS_DIR, `${tid}.md`);
|
|
@@ -287,7 +288,7 @@ export async function getThreads() {
|
|
|
287
288
|
return sorted;
|
|
288
289
|
}
|
|
289
290
|
export async function getThread(threadId) {
|
|
290
|
-
if (!threadId ||
|
|
291
|
+
if (!threadId || !UUID_RE.test(threadId))
|
|
291
292
|
return null;
|
|
292
293
|
try {
|
|
293
294
|
return await fs.readFile(path.join(THREADS_DIR, `${threadId}.md`), "utf-8");
|
|
@@ -351,7 +352,6 @@ export async function savePage(slug, content) {
|
|
|
351
352
|
await fs.writeFile(tmpPage, content, "utf-8");
|
|
352
353
|
await fs.rename(tmpPage, pageFile);
|
|
353
354
|
}
|
|
354
|
-
const UUID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
355
355
|
const TRASH_DIR = path.join(BRAIN_DIR, ".trash");
|
|
356
356
|
export async function deleteThread(threadId) {
|
|
357
357
|
if (!UUID_RE.test(threadId)) {
|
|
@@ -23,13 +23,30 @@ export function registerWikiExport(server) {
|
|
|
23
23
|
const date = new Date().toISOString().slice(0, 10);
|
|
24
24
|
const defaultPath = path.join(os.homedir(), `oh-my-adhd-export-${date}.json`);
|
|
25
25
|
const resolved = outputPath ? path.resolve(outputPath) : defaultPath;
|
|
26
|
-
// Require .json extension — prevents LLM-controlled path from clobbering
|
|
26
|
+
// Require .json extension — prevents LLM-controlled path from clobbering non-JSON config files
|
|
27
27
|
if (!resolved.endsWith(".json")) {
|
|
28
28
|
return {
|
|
29
29
|
content: [{ type: "text", text: "오류: outputPath는 .json 확장자로 끝나야 합니다." }],
|
|
30
30
|
isError: true,
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
+
// Block writes into known sensitive dirs — use realpath for symlink safety
|
|
34
|
+
const SENSITIVE_DIRS = [".ssh", ".aws", ".gnupg", ".kube", ".docker",
|
|
35
|
+
path.join(".config", "git"), path.join(".config", "gh")];
|
|
36
|
+
const homeDir = os.homedir();
|
|
37
|
+
let realResolved = resolved;
|
|
38
|
+
try {
|
|
39
|
+
realResolved = await fs.realpath(path.dirname(resolved));
|
|
40
|
+
}
|
|
41
|
+
catch { /* dir may not exist yet */ }
|
|
42
|
+
const realHome = await fs.realpath(homeDir).catch(() => homeDir);
|
|
43
|
+
const relDir = path.relative(realHome, realResolved).toLowerCase();
|
|
44
|
+
if (SENSITIVE_DIRS.some(d => relDir === d.toLowerCase() || relDir.startsWith(d.toLowerCase() + path.sep))) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text", text: "오류: 보안상 해당 경로에는 내보낼 수 없습니다." }],
|
|
47
|
+
isError: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
33
50
|
const filePath = resolved;
|
|
34
51
|
const tmp = filePath + ".tmp";
|
|
35
52
|
await fs.writeFile(tmp, JSON.stringify(exportData, null, 2), "utf-8");
|
|
@@ -43,7 +60,7 @@ export function registerWikiExport(server) {
|
|
|
43
60
|
`경로: ${filePath}`,
|
|
44
61
|
`스레드: ${threads.length}개 | 페이지: ${pages.length}개 | ${sizeKB}KB`,
|
|
45
62
|
"",
|
|
46
|
-
"복원하려면:
|
|
63
|
+
"복원하려면: wiki_import({ inputPath: \"<이 경로>\" }) 호출",
|
|
47
64
|
].join("\n"),
|
|
48
65
|
}],
|
|
49
66
|
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { ensureBrainDirs, BRAIN_DIR, SCHEMA_VERSION, withBrainLock } from "../../lib/brain.js";
|
|
2
|
+
import { ensureBrainDirs, BRAIN_DIR, SCHEMA_VERSION, UUID_RE, withBrainLock } from "../../lib/brain.js";
|
|
3
3
|
import fs from "fs/promises";
|
|
4
4
|
import path from "path";
|
|
5
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
5
|
const SLUG_RE = /^[a-z0-9가-힣][a-z0-9가-힣_-]{0,127}$/;
|
|
7
6
|
const MAX_CONTENT_BYTES = 5 * 1024 * 1024; // 5MB per thread
|
|
7
|
+
const MAX_ITEMS = 10000; // max threads or pages per import
|
|
8
8
|
export function registerWikiImport(server) {
|
|
9
9
|
server.tool("wiki_import", "wiki_export로 내보낸 JSON 백업 파일을 가져온다. 기존 데이터와 병합(merge)하며 중복 thread ID는 덮어쓴다.", {
|
|
10
10
|
inputPath: z.string().describe("가져올 .json 파일 경로 (wiki_export로 생성된 파일)"),
|
|
@@ -18,9 +18,15 @@ export function registerWikiImport(server) {
|
|
|
18
18
|
isError: true,
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
// Check file size before reading into memory
|
|
22
22
|
try {
|
|
23
|
-
|
|
23
|
+
const stat = await fs.stat(resolved);
|
|
24
|
+
if (stat.size > 100 * 1024 * 1024) {
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: "오류: 파일이 너무 큽니다 (100MB 초과)." }],
|
|
27
|
+
isError: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
24
30
|
}
|
|
25
31
|
catch {
|
|
26
32
|
return {
|
|
@@ -28,9 +34,13 @@ export function registerWikiImport(server) {
|
|
|
28
34
|
isError: true,
|
|
29
35
|
};
|
|
30
36
|
}
|
|
31
|
-
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = await fs.readFile(resolved, "utf-8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
32
42
|
return {
|
|
33
|
-
content: [{ type: "text", text:
|
|
43
|
+
content: [{ type: "text", text: `오류: 파일을 읽을 수 없습니다: ${resolved}` }],
|
|
34
44
|
isError: true,
|
|
35
45
|
};
|
|
36
46
|
}
|
|
@@ -50,6 +60,12 @@ export function registerWikiImport(server) {
|
|
|
50
60
|
isError: true,
|
|
51
61
|
};
|
|
52
62
|
}
|
|
63
|
+
if (exportData.threads.length > MAX_ITEMS || (exportData.pages?.length ?? 0) > MAX_ITEMS) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: `오류: 항목이 너무 많습니다 (최대 ${MAX_ITEMS}개).` }],
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
53
69
|
if (exportData.schemaVersion !== undefined && exportData.schemaVersion !== SCHEMA_VERSION) {
|
|
54
70
|
return {
|
|
55
71
|
content: [{ type: "text", text: `오류: 스키마 버전 불일치 (파일: ${exportData.schemaVersion}, 현재: ${SCHEMA_VERSION})` }],
|
|
@@ -122,24 +138,24 @@ export function registerWikiImport(server) {
|
|
|
122
138
|
const tmp = manifestFile + ".tmp";
|
|
123
139
|
await fs.writeFile(tmp, JSON.stringify(manifest, null, 2), "utf-8");
|
|
124
140
|
await fs.rename(tmp, manifestFile);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
// Import pages inside lock for consistency with concurrent dump+import
|
|
142
|
+
if (exportData.pages && Array.isArray(exportData.pages)) {
|
|
143
|
+
for (const rawPage of exportData.pages) {
|
|
144
|
+
if (typeof rawPage !== "object" || rawPage === null)
|
|
145
|
+
continue;
|
|
146
|
+
const page = rawPage;
|
|
147
|
+
const slug = typeof page.slug === "string" ? page.slug : "";
|
|
148
|
+
const content = typeof page.content === "string" ? page.content : "";
|
|
149
|
+
if (!SLUG_RE.test(slug) || !content)
|
|
150
|
+
continue;
|
|
151
|
+
const pageFile = path.join(pagesDir, `${slug}.md`);
|
|
152
|
+
const pageTmp = pageFile + ".tmp";
|
|
153
|
+
await fs.writeFile(pageTmp, content, "utf-8");
|
|
154
|
+
await fs.rename(pageTmp, pageFile);
|
|
155
|
+
importedPages++;
|
|
156
|
+
}
|
|
141
157
|
}
|
|
142
|
-
}
|
|
158
|
+
});
|
|
143
159
|
return {
|
|
144
160
|
content: [{
|
|
145
161
|
type: "text",
|
package/package.json
CHANGED
|
@@ -8,13 +8,15 @@ const BRAIN_DIR = process.env.OH_MY_ADHD_DIR ?? join(homedir(), ".oh-my-adhd");
|
|
|
8
8
|
const MANIFEST = join(BRAIN_DIR, "threads", ".manifest.json");
|
|
9
9
|
|
|
10
10
|
// Use parent PID (= Claude Code instance) as session discriminator — no singleton file needed
|
|
11
|
-
const ppid = process.ppid
|
|
11
|
+
const ppid = process.ppid;
|
|
12
12
|
|
|
13
|
-
// Write per-session start marker
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
// Write per-session start marker (skip if ppid is unavailable — avoids shared .session-start-0)
|
|
14
|
+
if (ppid) {
|
|
15
|
+
try {
|
|
16
|
+
mkdirSync(BRAIN_DIR, { recursive: true });
|
|
17
|
+
writeFileSync(join(BRAIN_DIR, `.session-start-${ppid}`), String(Date.now()));
|
|
18
|
+
} catch { /* non-fatal */ }
|
|
19
|
+
}
|
|
18
20
|
|
|
19
21
|
// GC stale session files older than 24h (runs on every new session)
|
|
20
22
|
try {
|
|
@@ -46,10 +48,12 @@ try {
|
|
|
46
48
|
const openThreads = manifest.filter(t => t.is_open).slice(0, 4);
|
|
47
49
|
if (openThreads.length === 0) process.exit(0);
|
|
48
50
|
|
|
51
|
+
// Strip control chars only — preserves emoji, Korean, CJK, etc.
|
|
49
52
|
const sanitize = (s, max) => String(s ?? "")
|
|
50
|
-
.replace(/[
|
|
53
|
+
.replace(/[\x00-\x1F\x7F]/g, " ")
|
|
51
54
|
.replace(/[`$<>]/g, "")
|
|
52
|
-
.replace(/\
|
|
55
|
+
.replace(/\b(ignore|disregard)\s+(all|previous|prior)\b/gi, "[redacted]")
|
|
56
|
+
.replace(/(이전|앞의|위의|모든)\s*(지시|명령|규칙)\s*(무시|잊어|버려)/g, "[redacted]")
|
|
53
57
|
.slice(0, max);
|
|
54
58
|
|
|
55
59
|
const lines = [
|
package/scripts/stop-hook.mjs
CHANGED
|
@@ -11,7 +11,9 @@ const BRAIN_DIR = process.env.OH_MY_ADHD_DIR ?? join(homedir(), ".oh-my-adhd");
|
|
|
11
11
|
const MANIFEST = join(BRAIN_DIR, "threads", ".manifest.json");
|
|
12
12
|
|
|
13
13
|
// Use parent PID (= Claude Code instance) as session discriminator
|
|
14
|
-
const ppid = process.ppid
|
|
14
|
+
const ppid = process.ppid;
|
|
15
|
+
// If ppid is unavailable, skip protection rather than risk a shared-file collision
|
|
16
|
+
if (!ppid) process.exit(0);
|
|
15
17
|
const SESSION_START_FILE = join(BRAIN_DIR, `.session-start-${ppid}`);
|
|
16
18
|
const LAST_DUMP_FILE = join(BRAIN_DIR, `.last-dump-${ppid}`);
|
|
17
19
|
|
|
@@ -36,10 +38,12 @@ try {
|
|
|
36
38
|
const openThreads = manifest.filter(t => t.is_open);
|
|
37
39
|
if (openThreads.length > 0) {
|
|
38
40
|
const top = openThreads[0];
|
|
41
|
+
// Strip control chars only — preserves emoji, Korean, CJK, etc.
|
|
39
42
|
const sanitize = (s, max) => String(s ?? "")
|
|
40
|
-
.replace(/[
|
|
43
|
+
.replace(/[\x00-\x1F\x7F]/g, " ")
|
|
41
44
|
.replace(/[`$<>]/g, "")
|
|
42
|
-
.replace(/\
|
|
45
|
+
.replace(/\b(ignore|disregard)\s+(all|previous|prior)\b/gi, "[redacted]")
|
|
46
|
+
.replace(/(이전|앞의|위의|모든)\s*(지시|명령|규칙)\s*(무시|잊어|버려)/g, "[redacted]")
|
|
43
47
|
.slice(0, max);
|
|
44
48
|
const title = sanitize(top.title ?? "진행중인 작업", 40);
|
|
45
49
|
const nextHint = top.next_action ? `\n→ 다음할것: ${sanitize(top.next_action, 60)}` : "";
|