oh-my-adhd 0.2.9 → 0.2.11
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 +15 -1
- package/dist/mcp/mcp/server.js +3 -1
- package/dist/mcp/mcp/tools/wiki-dump.js +11 -4
- package/dist/mcp/mcp/tools/wiki-export.js +10 -1
- package/dist/mcp/mcp/tools/wiki-import.js +122 -0
- package/package.json +1 -1
- package/scripts/session-recall.mjs +24 -11
- package/scripts/stop-hook.mjs +30 -7
package/dist/mcp/lib/brain.js
CHANGED
|
@@ -14,6 +14,14 @@ export const SCHEMA_VERSION = 1;
|
|
|
14
14
|
async function appendLog(level, msg) {
|
|
15
15
|
try {
|
|
16
16
|
const entry = `${new Date().toISOString()} [${level}] ${msg}\n`;
|
|
17
|
+
// Rotate log at 10MB
|
|
18
|
+
try {
|
|
19
|
+
const stat = await fs.stat(LOG_FILE);
|
|
20
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
21
|
+
await fs.rename(LOG_FILE, LOG_FILE + ".1").catch(() => { });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch { /* file may not exist yet */ }
|
|
17
25
|
await fs.appendFile(LOG_FILE, entry, "utf-8");
|
|
18
26
|
}
|
|
19
27
|
catch { /* logging failures must never crash the server */ }
|
|
@@ -218,7 +226,13 @@ export async function saveCapture(content, threadId) {
|
|
|
218
226
|
manifest.push(meta);
|
|
219
227
|
await writeManifest(manifest.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
|
|
220
228
|
// Session-scoped dump marker for stop-hook
|
|
221
|
-
|
|
229
|
+
let sid = "";
|
|
230
|
+
try {
|
|
231
|
+
sid = (await fs.readFile(path.join(BRAIN_DIR, ".session-current"), "utf-8")).trim();
|
|
232
|
+
}
|
|
233
|
+
catch { }
|
|
234
|
+
const lastDumpFile = path.join(BRAIN_DIR, sid ? `.last-dump-${sid}` : ".last-dump");
|
|
235
|
+
await fs.writeFile(lastDumpFile, String(Date.now()), "utf-8").catch(() => { });
|
|
222
236
|
});
|
|
223
237
|
return { capture, threadId: tid, title, skipped };
|
|
224
238
|
}
|
package/dist/mcp/mcp/server.js
CHANGED
|
@@ -15,9 +15,10 @@ import { registerWikiStructure } from "./tools/wiki-structure.js";
|
|
|
15
15
|
import { registerWikiSave } from "./tools/wiki-save.js";
|
|
16
16
|
import { registerWikiDelete } from "./tools/wiki-delete.js";
|
|
17
17
|
import { registerWikiExport } from "./tools/wiki-export.js";
|
|
18
|
+
import { registerWikiImport } from "./tools/wiki-import.js";
|
|
18
19
|
const server = new McpServer({
|
|
19
20
|
name: "oh-my-adhd",
|
|
20
|
-
version: "0.2.
|
|
21
|
+
version: "0.2.11",
|
|
21
22
|
});
|
|
22
23
|
registerWikiDump(server);
|
|
23
24
|
registerWikiRecall(server);
|
|
@@ -31,6 +32,7 @@ registerWikiStructure(server);
|
|
|
31
32
|
registerWikiSave(server);
|
|
32
33
|
registerWikiDelete(server);
|
|
33
34
|
registerWikiExport(server);
|
|
35
|
+
registerWikiImport(server);
|
|
34
36
|
async function main() {
|
|
35
37
|
const transport = new StdioServerTransport();
|
|
36
38
|
await server.connect(transport);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { saveCapture, getThread, getThreads, BRAIN_DIR } from "../../lib/brain.js";
|
|
3
5
|
import { findRelatedPages, upsertPageFromCapture } from "../../lib/linker.js";
|
|
4
6
|
import { captureGitContext } from "../utils.js";
|
|
5
7
|
export function registerWikiDump(server) {
|
|
@@ -70,9 +72,14 @@ export function registerWikiDump(server) {
|
|
|
70
72
|
if (todayCount >= 2)
|
|
71
73
|
respLines[0] = `저장됨 ✓ (오늘 ${todayCount}번째 🔥)`;
|
|
72
74
|
}
|
|
73
|
-
// Only nag
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
// Only nag once — user can dismiss permanently by touching .nag-dismissed
|
|
76
|
+
let nagDismissed = false;
|
|
77
|
+
try {
|
|
78
|
+
await fs.access(path.join(BRAIN_DIR, ".nag-dismissed"));
|
|
79
|
+
nagDismissed = true;
|
|
80
|
+
}
|
|
81
|
+
catch { }
|
|
82
|
+
if (!isStructured && !result.skipped && !nagDismissed) {
|
|
76
83
|
respLines.push("");
|
|
77
84
|
respLines.push("💡 다음번엔 이 형식으로 쓰면 다음 세션에서 더 잘 복원돼:");
|
|
78
85
|
respLines.push("```");
|
|
@@ -21,7 +21,16 @@ export function registerWikiExport(server) {
|
|
|
21
21
|
pages,
|
|
22
22
|
};
|
|
23
23
|
const date = new Date().toISOString().slice(0, 10);
|
|
24
|
-
const
|
|
24
|
+
const defaultPath = path.join(os.homedir(), `oh-my-adhd-export-${date}.json`);
|
|
25
|
+
const resolved = outputPath ? path.resolve(outputPath) : defaultPath;
|
|
26
|
+
// Require .json extension — prevents LLM-controlled path from clobbering arbitrary config files
|
|
27
|
+
if (!resolved.endsWith(".json")) {
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text", text: "오류: outputPath는 .json 확장자로 끝나야 합니다." }],
|
|
30
|
+
isError: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const filePath = resolved;
|
|
25
34
|
const tmp = filePath + ".tmp";
|
|
26
35
|
await fs.writeFile(tmp, JSON.stringify(exportData, null, 2), "utf-8");
|
|
27
36
|
await fs.rename(tmp, filePath);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ensureBrainDirs, BRAIN_DIR, SCHEMA_VERSION } from "../../lib/brain.js";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
export function registerWikiImport(server) {
|
|
6
|
+
server.tool("wiki_import", "wiki_export로 내보낸 JSON 백업 파일을 가져온다. 기존 데이터와 병합(merge)하며 중복 thread ID는 덮어쓴다.", {
|
|
7
|
+
inputPath: z.string().describe("가져올 .json 파일 경로 (wiki_export로 생성된 파일)"),
|
|
8
|
+
overwrite: z.boolean().optional().describe("true이면 같은 ID의 스레드를 덮어씀 (기본값: true)"),
|
|
9
|
+
}, async ({ inputPath, overwrite = true }) => {
|
|
10
|
+
try {
|
|
11
|
+
const resolved = path.resolve(inputPath);
|
|
12
|
+
if (!resolved.endsWith(".json")) {
|
|
13
|
+
return {
|
|
14
|
+
content: [{ type: "text", text: "오류: inputPath는 .json 확장자로 끝나야 합니다." }],
|
|
15
|
+
isError: true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = await fs.readFile(resolved, "utf-8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text", text: `오류: 파일을 읽을 수 없습니다: ${resolved}` }],
|
|
25
|
+
isError: true,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let exportData;
|
|
29
|
+
try {
|
|
30
|
+
exportData = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: "text", text: "오류: JSON 파싱 실패. wiki_export로 생성된 파일인지 확인하세요." }],
|
|
35
|
+
isError: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (!exportData.threads || !Array.isArray(exportData.threads)) {
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: "text", text: "오류: 유효하지 않은 내보내기 파일 형식입니다." }],
|
|
41
|
+
isError: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (exportData.schemaVersion && exportData.schemaVersion !== SCHEMA_VERSION) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text", text: `오류: 스키마 버전 불일치 (파일: ${exportData.schemaVersion}, 현재: ${SCHEMA_VERSION})` }],
|
|
47
|
+
isError: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
await ensureBrainDirs();
|
|
51
|
+
const threadsDir = path.join(BRAIN_DIR, "threads");
|
|
52
|
+
const pagesDir = path.join(BRAIN_DIR, "pages");
|
|
53
|
+
const manifestFile = path.join(threadsDir, ".manifest.json");
|
|
54
|
+
// Load existing manifest
|
|
55
|
+
let manifest = [];
|
|
56
|
+
try {
|
|
57
|
+
manifest = JSON.parse(await fs.readFile(manifestFile, "utf-8"));
|
|
58
|
+
}
|
|
59
|
+
catch { /* start fresh if missing */ }
|
|
60
|
+
const existingIds = new Set(manifest.map(m => m.id));
|
|
61
|
+
let importedThreads = 0;
|
|
62
|
+
let skippedThreads = 0;
|
|
63
|
+
for (const thread of exportData.threads) {
|
|
64
|
+
if (!thread.id || !thread.title)
|
|
65
|
+
continue;
|
|
66
|
+
if (!overwrite && existingIds.has(thread.id)) {
|
|
67
|
+
skippedThreads++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Write thread file
|
|
71
|
+
if (thread.content) {
|
|
72
|
+
const threadFile = path.join(threadsDir, `${thread.id}.md`);
|
|
73
|
+
const tmp = threadFile + ".tmp";
|
|
74
|
+
await fs.writeFile(tmp, thread.content, "utf-8");
|
|
75
|
+
await fs.rename(tmp, threadFile);
|
|
76
|
+
}
|
|
77
|
+
// Update manifest entry
|
|
78
|
+
const { content: _c, ...meta } = thread;
|
|
79
|
+
const idx = manifest.findIndex(m => m.id === thread.id);
|
|
80
|
+
if (idx >= 0)
|
|
81
|
+
manifest[idx] = meta;
|
|
82
|
+
else
|
|
83
|
+
manifest.push(meta);
|
|
84
|
+
importedThreads++;
|
|
85
|
+
}
|
|
86
|
+
// Write updated manifest atomically
|
|
87
|
+
const tmp = manifestFile + ".tmp";
|
|
88
|
+
await fs.writeFile(tmp, JSON.stringify(manifest, null, 2), "utf-8");
|
|
89
|
+
await fs.rename(tmp, manifestFile);
|
|
90
|
+
// Import pages if present
|
|
91
|
+
let importedPages = 0;
|
|
92
|
+
if (exportData.pages && Array.isArray(exportData.pages)) {
|
|
93
|
+
for (const page of exportData.pages) {
|
|
94
|
+
if (!page.slug || !page.content)
|
|
95
|
+
continue;
|
|
96
|
+
const pageFile = path.join(pagesDir, `${page.slug}.md`);
|
|
97
|
+
const tmp2 = pageFile + ".tmp";
|
|
98
|
+
await fs.writeFile(tmp2, page.content, "utf-8");
|
|
99
|
+
await fs.rename(tmp2, pageFile);
|
|
100
|
+
importedPages++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
content: [{
|
|
105
|
+
type: "text",
|
|
106
|
+
text: [
|
|
107
|
+
"가져오기 완료 ✓",
|
|
108
|
+
`스레드: ${importedThreads}개 가져옴${skippedThreads > 0 ? ` (${skippedThreads}개 건너뜀)` : ""}`,
|
|
109
|
+
`페이지: ${importedPages}개 가져옴`,
|
|
110
|
+
`원본 파일: ${resolved}`,
|
|
111
|
+
].join("\n"),
|
|
112
|
+
}],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }],
|
|
118
|
+
isError: true,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// SessionStart hook — writes
|
|
2
|
+
// SessionStart hook — writes session marker + injects recall context as additionalContext
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
|
|
7
7
|
const BRAIN_DIR = process.env.OH_MY_ADHD_DIR ?? join(homedir(), ".oh-my-adhd");
|
|
8
|
-
const SESSION_START_FILE = join(BRAIN_DIR, ".session-start");
|
|
9
8
|
const MANIFEST = join(BRAIN_DIR, "threads", ".manifest.json");
|
|
10
9
|
|
|
11
|
-
//
|
|
10
|
+
// Generate unique session ID and write per-session start marker
|
|
12
11
|
try {
|
|
13
12
|
mkdirSync(BRAIN_DIR, { recursive: true });
|
|
14
|
-
|
|
13
|
+
const sid = Math.random().toString(36).slice(2, 14) + Date.now().toString(36);
|
|
14
|
+
writeFileSync(join(BRAIN_DIR, `.session-start-${sid}`), String(Date.now()));
|
|
15
|
+
writeFileSync(join(BRAIN_DIR, ".session-current"), sid);
|
|
15
16
|
} catch { /* non-fatal */ }
|
|
16
17
|
|
|
17
18
|
// Build recall context from manifest
|
|
@@ -31,25 +32,37 @@ try {
|
|
|
31
32
|
const openThreads = manifest.filter(t => t.is_open).slice(0, 4);
|
|
32
33
|
if (openThreads.length === 0) process.exit(0);
|
|
33
34
|
|
|
35
|
+
const sanitize = (s, max) => String(s ?? "")
|
|
36
|
+
.replace(/[\x00-\x1F\x7F]/g, " ")
|
|
37
|
+
.replace(/[`$<>]/g, "")
|
|
38
|
+
.replace(/\bignore (all|previous)\b/gi, "[redacted]")
|
|
39
|
+
.slice(0, max);
|
|
40
|
+
|
|
34
41
|
const lines = ["[Second Brain 복원]", ""];
|
|
35
42
|
const top = openThreads[0];
|
|
36
|
-
lines.push(`🔴 **${top.title}** (${gapLabel(top.updatedAt)})`);
|
|
37
|
-
if (top.next_action) lines.push(`→ 다음: ${top.next_action
|
|
38
|
-
if (top.blocker) lines.push(`⛔ 막힌것: ${top.blocker
|
|
43
|
+
lines.push(`🔴 **${sanitize(top.title, 40)}** (${gapLabel(top.updatedAt)})`);
|
|
44
|
+
if (top.next_action) lines.push(`→ 다음: ${sanitize(top.next_action, 100)}`);
|
|
45
|
+
if (top.blocker) lines.push(`⛔ 막힌것: ${sanitize(top.blocker, 80)}`);
|
|
39
46
|
|
|
40
47
|
if (openThreads.length > 1) {
|
|
41
48
|
lines.push("");
|
|
42
49
|
for (const t of openThreads.slice(1)) {
|
|
43
|
-
const hint = t.next_action ? ` → ${t.next_action
|
|
44
|
-
lines.push(`• ${t.title} (${gapLabel(t.updatedAt)})${hint}`);
|
|
50
|
+
const hint = t.next_action ? ` → ${sanitize(t.next_action, 60)}` : "";
|
|
51
|
+
lines.push(`• ${sanitize(t.title, 30)} (${gapLabel(t.updatedAt)})${hint}`);
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
lines.push("");
|
|
49
56
|
lines.push(`이어서 갈까? thread: \`${top.id}\``);
|
|
50
57
|
|
|
51
|
-
|
|
58
|
+
// Cap additionalContext to prevent context bloat
|
|
59
|
+
const MAX_CHARS = 3500;
|
|
60
|
+
const context = lines.join("\n");
|
|
61
|
+
const capped = context.length > MAX_CHARS
|
|
62
|
+
? context.slice(0, MAX_CHARS) + "\n...[더 보려면 wiki_query 사용]"
|
|
63
|
+
: context;
|
|
64
|
+
|
|
65
|
+
process.stdout.write(JSON.stringify({ additionalContext: capped }));
|
|
52
66
|
} catch {
|
|
53
|
-
// No manifest / parse error — graceful degradation, never block session start
|
|
54
67
|
process.exit(0);
|
|
55
68
|
}
|
package/scripts/stop-hook.mjs
CHANGED
|
@@ -1,23 +1,47 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// oh-my-adhd Stop hook — blocks session end if no wiki_dump happened this session
|
|
3
|
-
import { readFileSync } from "fs";
|
|
3
|
+
import { readFileSync, readdirSync, unlinkSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
|
|
7
|
+
// Escape hatch: OMC_FORCE_EXIT=1 bypasses the block
|
|
8
|
+
if (process.env.OMC_FORCE_EXIT === "1") process.exit(0);
|
|
9
|
+
|
|
7
10
|
const BRAIN_DIR = process.env.OH_MY_ADHD_DIR ?? join(homedir(), ".oh-my-adhd");
|
|
8
11
|
const MANIFEST = join(BRAIN_DIR, "threads", ".manifest.json");
|
|
9
|
-
const
|
|
10
|
-
|
|
12
|
+
const SESSION_CURRENT = join(BRAIN_DIR, ".session-current");
|
|
13
|
+
|
|
14
|
+
// Clean up stale per-session files (>24h old)
|
|
15
|
+
try {
|
|
16
|
+
const files = readdirSync(BRAIN_DIR);
|
|
17
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
18
|
+
for (const f of files) {
|
|
19
|
+
if (/^\.(session-start-|last-dump-)/.test(f)) {
|
|
20
|
+
try {
|
|
21
|
+
const content = readFileSync(join(BRAIN_DIR, f), "utf-8").trim();
|
|
22
|
+
const ts = parseInt(content, 10);
|
|
23
|
+
if (!isNaN(ts) && ts < cutoff) unlinkSync(join(BRAIN_DIR, f));
|
|
24
|
+
} catch { /* best-effort */ }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} catch { /* never block on cleanup */ }
|
|
11
28
|
|
|
12
29
|
try {
|
|
30
|
+
// Read current session ID written by session-recall.mjs
|
|
31
|
+
let sid = "";
|
|
32
|
+
try { sid = readFileSync(SESSION_CURRENT, "utf-8").trim(); } catch {}
|
|
33
|
+
|
|
34
|
+
const SESSION_START_FILE = join(BRAIN_DIR, sid ? `.session-start-${sid}` : ".session-start");
|
|
35
|
+
const LAST_DUMP_FILE = join(BRAIN_DIR, sid ? `.last-dump-${sid}` : ".last-dump");
|
|
36
|
+
|
|
13
37
|
const sessionStartMs = parseInt(readFileSync(SESSION_START_FILE, "utf-8").trim(), 10);
|
|
14
38
|
if (isNaN(sessionStartMs)) process.exit(0); // no marker — don't block
|
|
15
39
|
|
|
16
|
-
//
|
|
40
|
+
// Allow stop if wiki_dump was called this session
|
|
17
41
|
try {
|
|
18
42
|
const lastDumpMs = parseInt(readFileSync(LAST_DUMP_FILE, "utf-8").trim(), 10);
|
|
19
43
|
if (!isNaN(lastDumpMs) && lastDumpMs > sessionStartMs) process.exit(0);
|
|
20
|
-
} catch { /*
|
|
44
|
+
} catch { /* no dump file = no dump this session */ }
|
|
21
45
|
|
|
22
46
|
let manifest;
|
|
23
47
|
try {
|
|
@@ -30,7 +54,6 @@ try {
|
|
|
30
54
|
const openThreads = manifest.filter(t => t.is_open);
|
|
31
55
|
if (openThreads.length > 0) {
|
|
32
56
|
const top = openThreads[0];
|
|
33
|
-
// Sanitize: strip control chars and injection-shaped strings before embedding in model-facing text
|
|
34
57
|
const sanitize = (s, max) => String(s ?? "")
|
|
35
58
|
.replace(/[\x00-\x1F\x7F]/g, " ")
|
|
36
59
|
.replace(/[`$<>]/g, "")
|
|
@@ -40,7 +63,7 @@ try {
|
|
|
40
63
|
const nextHint = top.next_action ? `\n→ 다음할것: ${sanitize(top.next_action, 60)}` : "";
|
|
41
64
|
process.stdout.write(JSON.stringify({
|
|
42
65
|
decision: "block",
|
|
43
|
-
reason: `저장 없이 끝내려고? "${title}" 스레드가 열려있어.${nextHint}\nwiki_dump로 결정/막힌것/다음할것 저장하고
|
|
66
|
+
reason: `저장 없이 끝내려고? "${title}" 스레드가 열려있어.${nextHint}\nwiki_dump로 결정/막힌것/다음할것 저장하고 끝내자.\n(강제 종료: OMC_FORCE_EXIT=1 설정)`,
|
|
44
67
|
}));
|
|
45
68
|
}
|
|
46
69
|
} catch {
|