oh-my-adhd 0.2.7 → 0.2.9

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.
@@ -202,8 +202,8 @@ export async function saveCapture(content, threadId) {
202
202
  const idx = manifest.findIndex((m) => m.id === tid);
203
203
  // Compute signal cache fields from new content
204
204
  const stripped = stripGitSuffix(content).trim();
205
- const is_open = OPEN_SIGNAL.test(stripped);
206
- const is_done = DONE_SIGNAL.test(stripped) && !is_open;
205
+ const is_open = OPEN_SIGNAL.test(stripped) && !DONE_SIGNAL.test(stripped);
206
+ const is_done = DONE_SIGNAL.test(stripped);
207
207
  const last_action = stripped.replace(/\n+/g, " ").slice(0, 160);
208
208
  const next_action = extractFieldBrain(stripped, "다음할것").slice(0, 120);
209
209
  const blocker = extractFieldBrain(stripped, "막힌것").slice(0, 120);
@@ -217,6 +217,8 @@ export async function saveCapture(content, threadId) {
217
217
  else
218
218
  manifest.push(meta);
219
219
  await writeManifest(manifest.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()));
220
+ // Session-scoped dump marker for stop-hook
221
+ await fs.writeFile(path.join(BRAIN_DIR, ".last-dump"), String(Date.now()), "utf-8").catch(() => { });
220
222
  });
221
223
  return { capture, threadId: tid, title, skipped };
222
224
  }
@@ -240,7 +242,7 @@ export async function getThreads() {
240
242
  const scanCaptures = content.split(/\n---\n/).slice(1).filter((p) => p.trim());
241
243
  const lastCapture = scanCaptures.at(-1) ?? "";
242
244
  const fullText = lastCapture.replace(/^(?:_[^_\n]+_|\*\*[^*\n]+\*\*)\s*/m, "").trim();
243
- const is_open = OPEN_SIGNAL.test(fullText);
245
+ const is_open = OPEN_SIGNAL.test(fullText) && !DONE_SIGNAL.test(fullText);
244
246
  return {
245
247
  id: tid,
246
248
  title,
@@ -251,7 +253,7 @@ export async function getThreads() {
251
253
  next_action: extractFieldBrain(fullText, "다음할것").slice(0, 120),
252
254
  blocker: extractFieldBrain(fullText, "막힌것").slice(0, 120),
253
255
  capture_count: scanCaptures.length,
254
- is_done: DONE_SIGNAL.test(fullText) && !OPEN_SIGNAL.test(fullText),
256
+ is_done: DONE_SIGNAL.test(fullText),
255
257
  };
256
258
  }));
257
259
  results
@@ -14,6 +14,7 @@ import { registerWikiGraph } from "./tools/wiki-graph.js";
14
14
  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
+ import { registerWikiExport } from "./tools/wiki-export.js";
17
18
  const server = new McpServer({
18
19
  name: "oh-my-adhd",
19
20
  version: "0.2.0",
@@ -29,6 +30,7 @@ registerWikiGraph(server);
29
30
  registerWikiStructure(server);
30
31
  registerWikiSave(server);
31
32
  registerWikiDelete(server);
33
+ registerWikiExport(server);
32
34
  async function main() {
33
35
  const transport = new StdioServerTransport();
34
36
  await server.connect(transport);
@@ -63,6 +63,13 @@ export function registerWikiDump(server) {
63
63
  }
64
64
  catch { /* dead-end check is best-effort, never crash */ }
65
65
  }
66
+ // Dopamine streak: show today's save count when ≥2
67
+ if (!result.skipped && allThreads.length > 0) {
68
+ const today = new Date().toISOString().slice(0, 10);
69
+ const todayCount = allThreads.filter(t => t.updatedAt?.startsWith(today)).length;
70
+ if (todayCount >= 2)
71
+ respLines[0] = `저장됨 ✓ (오늘 ${todayCount}번째 🔥)`;
72
+ }
66
73
  // Only nag if user has NEVER successfully used structured schema — avoids repeated noise
67
74
  const hasEverStructured = allThreads.some(t => t.id === result.threadId && (t.next_action || t.blocker));
68
75
  if (!isStructured && !result.skipped && !hasEverStructured) {
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { getThreads, getThread, getPages } from "../../lib/brain.js";
3
+ import fs from "fs/promises";
4
+ import path from "path";
5
+ import os from "os";
6
+ export function registerWikiExport(server) {
7
+ server.tool("wiki_export", "모든 스레드와 페이지를 JSON 파일로 내보낸다. 백업/이전 용도. 파일 경로를 반환하며 언제든 재실행 가능.", {
8
+ outputPath: z.string().optional().describe("내보낼 파일 경로 (기본값: ~/oh-my-adhd-export-YYYY-MM-DD.json)"),
9
+ }, async ({ outputPath }) => {
10
+ try {
11
+ const [threads, pages] = await Promise.all([getThreads(), getPages()]);
12
+ const threadContents = await Promise.all(threads.map(async (t) => ({
13
+ ...t,
14
+ content: await getThread(t.id),
15
+ })));
16
+ const exportData = {
17
+ exportedAt: new Date().toISOString(),
18
+ schemaVersion: 1,
19
+ stats: { threads: threads.length, pages: pages.length },
20
+ threads: threadContents,
21
+ pages,
22
+ };
23
+ const date = new Date().toISOString().slice(0, 10);
24
+ const filePath = outputPath ?? path.join(os.homedir(), `oh-my-adhd-export-${date}.json`);
25
+ const tmp = filePath + ".tmp";
26
+ await fs.writeFile(tmp, JSON.stringify(exportData, null, 2), "utf-8");
27
+ await fs.rename(tmp, filePath);
28
+ const sizeKB = Math.round((await fs.stat(filePath)).size / 1024);
29
+ return {
30
+ content: [{
31
+ type: "text",
32
+ text: [
33
+ "내보내기 완료 ✓",
34
+ `경로: ${filePath}`,
35
+ `스레드: ${threads.length}개 | 페이지: ${pages.length}개 | ${sizeKB}KB`,
36
+ "",
37
+ "복원하려면: wiki_export 결과 JSON을 ~/.oh-my-adhd/로 수동 복사",
38
+ ].join("\n"),
39
+ }],
40
+ };
41
+ }
42
+ catch (e) {
43
+ return {
44
+ content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }],
45
+ isError: true,
46
+ };
47
+ }
48
+ });
49
+ }
@@ -38,7 +38,7 @@ export function registerWikiRecall(server) {
38
38
  // Use stored next_action/blocker from manifest if available
39
39
  next_action = t.next_action ?? "";
40
40
  blocker = t.blocker ?? "";
41
- const is_done = t.is_done !== undefined ? t.is_done : (DONE_SIGNAL.test(last_action) && !is_open);
41
+ const is_done = t.is_done !== undefined ? t.is_done : DONE_SIGNAL.test(last_action);
42
42
  const gapHours = isNaN(new Date(t.updatedAt).getTime())
43
43
  ? null
44
44
  : Math.max(0, Math.round((Date.now() - new Date(t.updatedAt).getTime()) / 3600000));
@@ -63,8 +63,8 @@ export function registerWikiRecall(server) {
63
63
  const captures = content.split(/\n---\n/).slice(1).filter((p) => p.trim());
64
64
  const lastCapture = captures.at(-1) ?? "";
65
65
  const fullText = lastCapture.replace(/^(?:_[^_\n]+_|\*\*[^*\n]+\*\*)\s*/m, "").trim();
66
- is_open = OPEN_SIGNAL.test(fullText);
67
- const isDone = DONE_SIGNAL.test(fullText) && !is_open;
66
+ is_open = OPEN_SIGNAL.test(fullText) && !DONE_SIGNAL.test(fullText);
67
+ const isDone = DONE_SIGNAL.test(fullText);
68
68
  last_action = fullText.replace(/\n+/g, " ").slice(0, 160);
69
69
  next_action = extractFieldBrain(fullText, "다음할것").slice(0, 120);
70
70
  blocker = extractFieldBrain(fullText, "막힌것").slice(0, 120);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-adhd",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "ADHD second brain — zero-friction capture, auto context restore, unstick. MCP-native Claude Code plugin.",
5
5
  "author": "Yeachan Heo",
6
6
  "repository": {
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook — writes .session-start timestamp + injects recall context as additionalContext
3
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
4
+ import { join } from "path";
5
+ import { homedir } from "os";
6
+
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
+ const MANIFEST = join(BRAIN_DIR, "threads", ".manifest.json");
10
+
11
+ // Always write session start timestamp first
12
+ try {
13
+ mkdirSync(BRAIN_DIR, { recursive: true });
14
+ writeFileSync(SESSION_START_FILE, String(Date.now()));
15
+ } catch { /* non-fatal */ }
16
+
17
+ // Build recall context from manifest
18
+ try {
19
+ const manifest = JSON.parse(readFileSync(MANIFEST, "utf-8"));
20
+ if (!Array.isArray(manifest) || manifest.length === 0) process.exit(0);
21
+
22
+ const now = Date.now();
23
+ const gapLabel = (updatedAt) => {
24
+ const h = Math.max(0, Math.round((now - new Date(updatedAt).getTime()) / 3600000));
25
+ if (h < 1) return "방금 전";
26
+ if (h < 18) return `${h}시간 전`;
27
+ if (h < 36) return "어제";
28
+ return `${Math.floor(h / 24)}일 전`;
29
+ };
30
+
31
+ const openThreads = manifest.filter(t => t.is_open).slice(0, 4);
32
+ if (openThreads.length === 0) process.exit(0);
33
+
34
+ const lines = ["[Second Brain 복원]", ""];
35
+ const top = openThreads[0];
36
+ lines.push(`🔴 **${top.title}** (${gapLabel(top.updatedAt)})`);
37
+ if (top.next_action) lines.push(`→ 다음: ${top.next_action.slice(0, 100)}`);
38
+ if (top.blocker) lines.push(`⛔ 막힌것: ${top.blocker.slice(0, 80)}`);
39
+
40
+ if (openThreads.length > 1) {
41
+ lines.push("");
42
+ for (const t of openThreads.slice(1)) {
43
+ const hint = t.next_action ? ` → ${t.next_action.slice(0, 60)}` : "";
44
+ lines.push(`• ${t.title} (${gapLabel(t.updatedAt)})${hint}`);
45
+ }
46
+ }
47
+
48
+ lines.push("");
49
+ lines.push(`이어서 갈까? thread: \`${top.id}\``);
50
+
51
+ process.stdout.write(JSON.stringify({ additionalContext: lines.join("\n") }));
52
+ } catch {
53
+ // No manifest / parse error — graceful degradation, never block session start
54
+ process.exit(0);
55
+ }
@@ -7,11 +7,18 @@ import { homedir } from "os";
7
7
  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
  const SESSION_START_FILE = join(BRAIN_DIR, ".session-start");
10
+ const LAST_DUMP_FILE = join(BRAIN_DIR, ".last-dump");
10
11
 
11
12
  try {
12
13
  const sessionStartMs = parseInt(readFileSync(SESSION_START_FILE, "utf-8").trim(), 10);
13
14
  if (isNaN(sessionStartMs)) process.exit(0); // no marker — don't block
14
15
 
16
+ // Session-scoped check: did wiki_dump run after this session started?
17
+ try {
18
+ const lastDumpMs = parseInt(readFileSync(LAST_DUMP_FILE, "utf-8").trim(), 10);
19
+ if (!isNaN(lastDumpMs) && lastDumpMs > sessionStartMs) process.exit(0);
20
+ } catch { /* .last-dump missing = no dump this session */ }
21
+
15
22
  let manifest;
16
23
  try {
17
24
  manifest = JSON.parse(readFileSync(MANIFEST, "utf-8"));
@@ -19,13 +26,6 @@ try {
19
26
  process.exit(0); // no manifest — nothing to protect
20
27
  }
21
28
 
22
- // Allow stop if any dump happened after session start
23
- const latestDump = manifest.reduce((max, t) => {
24
- const ts = new Date(t.updatedAt).getTime();
25
- return ts > max ? ts : max;
26
- }, 0);
27
- if (latestDump > sessionStartMs) process.exit(0);
28
-
29
29
  // Block only if there are open threads worth saving
30
30
  const openThreads = manifest.filter(t => t.is_open);
31
31
  if (openThreads.length > 0) {