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,99 @@
|
|
|
1
|
+
import MiniSearch from "minisearch";
|
|
2
|
+
// Korean-aware tokenizer: whitespace split + Korean bigrams for partial matching
|
|
3
|
+
function tokenize(text) {
|
|
4
|
+
const normalized = text.normalize("NFC").toLowerCase();
|
|
5
|
+
const words = normalized
|
|
6
|
+
.replace(/[^가-힣a-zA-Z0-9\s]/g, " ")
|
|
7
|
+
.split(/\s+/)
|
|
8
|
+
.filter((w) => w.length > 0);
|
|
9
|
+
const tokens = [];
|
|
10
|
+
for (const word of words) {
|
|
11
|
+
tokens.push(word);
|
|
12
|
+
// Korean bigrams: "운동했다" → ["운동", "동했", "했다"] — partial stem matching
|
|
13
|
+
if (/[가-힣]/.test(word) && word.length >= 2) {
|
|
14
|
+
for (let i = 0; i < word.length - 1; i++) {
|
|
15
|
+
tokens.push(word.slice(i, i + 2));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Do NOT dedup — preserving duplicates keeps TF signal intact for BM25
|
|
20
|
+
return tokens;
|
|
21
|
+
}
|
|
22
|
+
// Parse `_aliases: ADHD, 주의력결핍_` from page content
|
|
23
|
+
export function extractAliases(content) {
|
|
24
|
+
const match = content.match(/_aliases:\s*([^_\n]+)_/i);
|
|
25
|
+
return match ? match[1] : "";
|
|
26
|
+
}
|
|
27
|
+
// Cache keyed by slug:updatedAt:aliasHash — invalidates on alias edits
|
|
28
|
+
function cacheKey(pages) {
|
|
29
|
+
return pages
|
|
30
|
+
.map((p) => `${p.slug}:${p.updatedAt}:${extractAliases(p.content).length}`)
|
|
31
|
+
.join("|");
|
|
32
|
+
}
|
|
33
|
+
let _indexCache = null;
|
|
34
|
+
export function buildSearchIndex(pages) {
|
|
35
|
+
const key = cacheKey(pages);
|
|
36
|
+
if (_indexCache && _indexCache.key === key)
|
|
37
|
+
return _indexCache.index;
|
|
38
|
+
const index = new MiniSearch({
|
|
39
|
+
fields: ["title", "aliases", "content"],
|
|
40
|
+
storeFields: ["slug", "title"],
|
|
41
|
+
tokenize,
|
|
42
|
+
searchOptions: {
|
|
43
|
+
boost: { title: 3, aliases: 2, content: 1 },
|
|
44
|
+
// Only prefix-match tokens of 3+ chars — prevents bigram over-matching
|
|
45
|
+
prefix: (term) => term.length >= 3,
|
|
46
|
+
// Fuzzy only on longer tokens — 2-char bigrams need exact match
|
|
47
|
+
fuzzy: (term) => (term.length > 4 ? 0.15 : 0),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
index.addAll(pages.map((p) => ({
|
|
51
|
+
id: p.slug,
|
|
52
|
+
slug: p.slug,
|
|
53
|
+
title: p.title,
|
|
54
|
+
aliases: extractAliases(p.content),
|
|
55
|
+
content: p.content,
|
|
56
|
+
})));
|
|
57
|
+
_indexCache = { key, index };
|
|
58
|
+
return index;
|
|
59
|
+
}
|
|
60
|
+
export function searchInDocs(docs, query) {
|
|
61
|
+
if (!query.trim() || docs.length === 0)
|
|
62
|
+
return [];
|
|
63
|
+
const ms = new MiniSearch({
|
|
64
|
+
fields: ["title", "content"],
|
|
65
|
+
storeFields: ["id"],
|
|
66
|
+
tokenize: (text) => {
|
|
67
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
68
|
+
const bigrams = [];
|
|
69
|
+
for (const w of words) {
|
|
70
|
+
if (/[가-힣]/.test(w)) {
|
|
71
|
+
for (let i = 0; i < w.length - 1; i++)
|
|
72
|
+
bigrams.push(w.slice(i, i + 2));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
bigrams.push(w.toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return bigrams.length > 0 ? bigrams : words;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
ms.addAll(docs);
|
|
82
|
+
try {
|
|
83
|
+
return ms.search(query, { prefix: true, fuzzy: 0.15 }).map(r => ({ id: r.id, score: r.score }));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function searchPages(pages, query, limit = 5) {
|
|
90
|
+
if (!query.trim() || pages.length === 0)
|
|
91
|
+
return [];
|
|
92
|
+
const index = buildSearchIndex(pages);
|
|
93
|
+
try {
|
|
94
|
+
return index.search(query).slice(0, limit).map((r) => r.slug);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
// stdout is the JSON-RPC channel — redirect any accidental console.log to stderr
|
|
5
|
+
console.log = console.error;
|
|
6
|
+
import { registerWikiDump } from "./tools/wiki-dump.js";
|
|
7
|
+
import { registerWikiRecall } from "./tools/wiki-recall.js";
|
|
8
|
+
import { registerWikiSetup } from "./tools/wiki-setup.js";
|
|
9
|
+
import { registerWikiUnstick } from "./tools/wiki-unstick.js";
|
|
10
|
+
import { registerWikiQuery } from "./tools/wiki-query.js";
|
|
11
|
+
import { registerWikiPages } from "./tools/wiki-pages.js";
|
|
12
|
+
import { registerWikiLink } from "./tools/wiki-link.js";
|
|
13
|
+
import { registerWikiGraph } from "./tools/wiki-graph.js";
|
|
14
|
+
import { registerWikiStructure } from "./tools/wiki-structure.js";
|
|
15
|
+
import { registerWikiSave } from "./tools/wiki-save.js";
|
|
16
|
+
import { registerWikiDelete } from "./tools/wiki-delete.js";
|
|
17
|
+
const server = new McpServer({
|
|
18
|
+
name: "oh-my-adhd",
|
|
19
|
+
version: "0.2.0",
|
|
20
|
+
});
|
|
21
|
+
registerWikiDump(server);
|
|
22
|
+
registerWikiRecall(server);
|
|
23
|
+
registerWikiSetup(server);
|
|
24
|
+
registerWikiUnstick(server);
|
|
25
|
+
registerWikiQuery(server);
|
|
26
|
+
registerWikiPages(server);
|
|
27
|
+
registerWikiLink(server);
|
|
28
|
+
registerWikiGraph(server);
|
|
29
|
+
registerWikiStructure(server);
|
|
30
|
+
registerWikiSave(server);
|
|
31
|
+
registerWikiDelete(server);
|
|
32
|
+
async function main() {
|
|
33
|
+
const transport = new StdioServerTransport();
|
|
34
|
+
await server.connect(transport);
|
|
35
|
+
}
|
|
36
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { deleteThread, deletePage } from "../../lib/brain.js";
|
|
3
|
+
export function registerWikiDelete(server) {
|
|
4
|
+
server.tool("wiki_delete", "스레드 또는 위키 페이지를 삭제한다. 실수로 저장한 민감한 내용 제거용.", {
|
|
5
|
+
target: z.enum(["thread", "page"]),
|
|
6
|
+
id: z.string().max(200).describe("스레드 ID (UUID) 또는 페이지 슬러그"),
|
|
7
|
+
}, async ({ target, id }) => {
|
|
8
|
+
try {
|
|
9
|
+
if (target === "thread") {
|
|
10
|
+
await deleteThread(id);
|
|
11
|
+
return { content: [{ type: "text", text: `스레드 삭제됨: ${id}` }] };
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
await deletePage(id);
|
|
15
|
+
return { content: [{ type: "text", text: `페이지 삭제됨: ${id}` }] };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
return { content: [{ type: "text", text: `오류: ${e.message}` }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { saveCapture, getThread, getThreads } from "../../lib/brain.js";
|
|
3
|
+
import { findRelatedPages, upsertPageFromCapture } from "../../lib/linker.js";
|
|
4
|
+
import { captureGitContext } from "../utils.js";
|
|
5
|
+
export function registerWikiDump(server) {
|
|
6
|
+
server.tool("wiki_dump", "생각이나 메모를 세컨드 브레인에 저장한다. 같은 주제면 반드시 threadId를 재사용해서 이어 붙인다. 새 주제일 때만 threadId 없이 호출. 저장 후 반환된 threadId를 기억해 다음 캡처에 활용.\n\ncontent 형식 (구조화 권장):\n결정: [이번 대화에서 확정된 것]\n가설: [현재 시도 중인 방향]\n막힌것: [이미 시도해서 안 된 것 — 다음 세션 반복 방지]\n다음할것: [멈춘 시점의 다음 액션. 구체적으로]\n블로커: [해결 안 된 장애물]\n요약: [한 줄 컨텍스트]", {
|
|
7
|
+
content: z.string().max(64000).describe("저장할 내용"),
|
|
8
|
+
threadId: z.string().uuid().optional().describe("이어 붙일 스레드 ID (없으면 새 스레드)"),
|
|
9
|
+
}, async ({ content, threadId }) => {
|
|
10
|
+
try {
|
|
11
|
+
if (!content.trim()) {
|
|
12
|
+
return { content: [{ type: "text", text: "오류: 내용이 비어 있습니다. 저장할 내용을 입력해 주세요." }], isError: true };
|
|
13
|
+
}
|
|
14
|
+
const gitCtx = await captureGitContext();
|
|
15
|
+
const related = await findRelatedPages(content); // upsert 이전에 — self-link 방지
|
|
16
|
+
const result = await saveCapture(content + gitCtx, threadId);
|
|
17
|
+
if (result.skipped) {
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text", text: `저장됨 ✓\n(중복 캡처 — 이미 저장된 내용)\nthread: ${result.threadId}` }],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const fullContent = await getThread(result.threadId);
|
|
23
|
+
if (fullContent)
|
|
24
|
+
await upsertPageFromCapture(result.title, fullContent).catch(() => { });
|
|
25
|
+
const sizeKB = Math.round((fullContent?.length ?? 0) / 1024);
|
|
26
|
+
const sizeWarn = sizeKB > 500 ? `\n⚠️ 스레드 크기 ${sizeKB}KB — 새 주제는 새 스레드(threadId 없이)로 분리하는 걸 권장` : "";
|
|
27
|
+
const respLines = ["저장됨 ✓"];
|
|
28
|
+
const nextMatch = content.match(/(?:^|\n)\s*다음할것\s*:\s*(.+)/im);
|
|
29
|
+
const blockerMatch = content.match(/(?:^|\n)\s*막힌것\s*:\s*(.+)/im);
|
|
30
|
+
if (nextMatch?.[1]?.trim())
|
|
31
|
+
respLines.push(`→ 다음 액션: ${nextMatch[1].trim().slice(0, 100)}`);
|
|
32
|
+
if (blockerMatch?.[1]?.trim())
|
|
33
|
+
respLines.push(`⛔ 막힌것 기록: ${blockerMatch[1].trim().slice(0, 100)}`);
|
|
34
|
+
respLines.push(`thread: ${result.threadId} (캡처 #${result.capture.id.slice(0, 8)})`);
|
|
35
|
+
if (related.length > 0)
|
|
36
|
+
respLines.push(`연결된 페이지: ${related.join(", ")}`);
|
|
37
|
+
if (sizeWarn)
|
|
38
|
+
respLines.push(sizeWarn.trim());
|
|
39
|
+
// Detect if content uses the structured schema
|
|
40
|
+
const FIELD_PATTERN = /(?:^|\n)\s*(?:결정|가설|막힌것|다음할것|블로커|요약)\s*:/i;
|
|
41
|
+
const isStructured = FIELD_PATTERN.test(content);
|
|
42
|
+
// Check if new content looks like a repeat of a known dead-end; also used for nag suppression
|
|
43
|
+
let allThreads = [];
|
|
44
|
+
if (!result.skipped) {
|
|
45
|
+
try {
|
|
46
|
+
allThreads = await getThreads();
|
|
47
|
+
const contentLower = content.toLowerCase().replace(/\s+/g, " ");
|
|
48
|
+
for (const t of allThreads) {
|
|
49
|
+
if (!t.is_open || t.id === result.threadId || !t.blocker)
|
|
50
|
+
continue;
|
|
51
|
+
const blockerWords = t.blocker.toLowerCase().replace(/\s+/g, " ").split(/\s+/).filter(w => w.length > 2);
|
|
52
|
+
if (blockerWords.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
const matchCount = blockerWords.filter(w => contentLower.includes(w)).length;
|
|
55
|
+
if (matchCount >= 3 || matchCount / blockerWords.length > 0.5) {
|
|
56
|
+
respLines.unshift("");
|
|
57
|
+
respLines.unshift(` 같은 길 다시 가는 거 맞아? 막혔으면 \`wiki_unstick\` 해볼래?`);
|
|
58
|
+
respLines.unshift(` → "${t.blocker.slice(0, 80)}" (${(t.title ?? "").slice(0, 20)}, thread \`${t.id.slice(0, 8)}...\`)`);
|
|
59
|
+
respLines.unshift(`⚠️ 이거 전에 막혔던 거랑 비슷해:`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* dead-end check is best-effort, never crash */ }
|
|
65
|
+
}
|
|
66
|
+
// Only nag if user has NEVER successfully used structured schema — avoids repeated noise
|
|
67
|
+
const hasEverStructured = allThreads.some(t => t.next_action || t.blocker);
|
|
68
|
+
if (!isStructured && !result.skipped && !hasEverStructured) {
|
|
69
|
+
respLines.push("");
|
|
70
|
+
respLines.push("💡 다음번엔 이 형식으로 쓰면 다음 세션에서 더 잘 복원돼:");
|
|
71
|
+
respLines.push("```");
|
|
72
|
+
respLines.push("다음할것: [지금 멈춘 시점의 다음 액션]");
|
|
73
|
+
respLines.push("막힌것: [이미 시도해서 안 된 것]");
|
|
74
|
+
respLines.push("요약: [한 줄 컨텍스트]");
|
|
75
|
+
respLines.push("```");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: respLines.join("\n") }],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
return { content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }], isError: true };
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { buildGraphData } from "../../lib/linker.js";
|
|
2
|
+
export function registerWikiGraph(server) {
|
|
3
|
+
server.tool("wiki_graph", "세컨드 브레인의 노드/엣지 그래프 데이터를 반환한다.", {}, async () => {
|
|
4
|
+
try {
|
|
5
|
+
const data = await buildGraphData();
|
|
6
|
+
return {
|
|
7
|
+
content: [
|
|
8
|
+
{
|
|
9
|
+
type: "text",
|
|
10
|
+
text: `nodes: ${data.nodes.length}, edges: ${data.edges.length}\n${JSON.stringify(data, null, 2)}`,
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
return { content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }], isError: true };
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { findRelatedPages, upsertPageFromCapture } from "../../lib/linker.js";
|
|
3
|
+
export function registerWikiLink(server) {
|
|
4
|
+
server.tool("wiki_link", "내용에서 관련 위키 페이지를 찾고, 필요하면 새 페이지를 생성한다.", {
|
|
5
|
+
content: z.string().max(64000).describe("분석할 내용"),
|
|
6
|
+
createPage: z.boolean().optional().describe("위키 페이지로 저장할지 여부"),
|
|
7
|
+
title: z.string().max(200).optional().describe("페이지 제목 (createPage가 true일 때 필요)"),
|
|
8
|
+
}, async ({ content, createPage, title }) => {
|
|
9
|
+
if (createPage && !title) {
|
|
10
|
+
return { content: [{ type: "text", text: "오류: createPage가 true이면 title이 필요합니다" }], isError: true };
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const related = await findRelatedPages(content);
|
|
14
|
+
if (createPage && title) {
|
|
15
|
+
await upsertPageFromCapture(title, content).catch(() => { });
|
|
16
|
+
}
|
|
17
|
+
const lines = [];
|
|
18
|
+
if (related.length > 0) {
|
|
19
|
+
lines.push(`관련 페이지: ${related.map((s) => `[[${s}]]`).join(", ")}`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
lines.push("관련 페이지 없음");
|
|
23
|
+
}
|
|
24
|
+
if (createPage && title) {
|
|
25
|
+
lines.push(`페이지 생성됨: [[${title}]]`);
|
|
26
|
+
}
|
|
27
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
return { content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }], isError: true };
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getPages } from "../../lib/brain.js";
|
|
2
|
+
export function registerWikiPages(server) {
|
|
3
|
+
server.tool("wiki_pages", "생성된 위키 페이지 목록을 반환한다.", {}, async () => {
|
|
4
|
+
try {
|
|
5
|
+
const pages = await getPages();
|
|
6
|
+
const list = pages.map((p) => `[[${p.title}]] — ${p.updatedAt.slice(0, 10)}`);
|
|
7
|
+
return {
|
|
8
|
+
content: [
|
|
9
|
+
{
|
|
10
|
+
type: "text",
|
|
11
|
+
text: list.length > 0 ? list.join("\n") : "페이지 없음",
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return { content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }], isError: true };
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getThreads, getPages, getThread } from "../../lib/brain.js";
|
|
3
|
+
import { searchPages, searchInDocs } from "../../lib/search.js";
|
|
4
|
+
export function registerWikiQuery(server) {
|
|
5
|
+
server.tool("wiki_query", "세컨드 브레인에서 키워드로 검색한다. '전에 이 문제 겪어본 적 있나?', '비슷한 결정 했던 게 기억나는데' 같은 상황에서 호출. 과거의 나를 찾는 도구.", {
|
|
6
|
+
query: z.string().max(1000).trim().describe("검색할 키워드"),
|
|
7
|
+
}, async ({ query }) => {
|
|
8
|
+
try {
|
|
9
|
+
if (!query.trim()) {
|
|
10
|
+
return { content: [{ type: "text", text: "검색어를 입력하세요. 예: wiki_query({ query: \"React\" })" }], isError: true };
|
|
11
|
+
}
|
|
12
|
+
const [allThreads, pages] = await Promise.all([getThreads(), getPages()]);
|
|
13
|
+
const lq = query.toLowerCase();
|
|
14
|
+
const results = [];
|
|
15
|
+
// BM25 page search
|
|
16
|
+
const slugs = searchPages(pages, query, 5);
|
|
17
|
+
for (const slug of slugs) {
|
|
18
|
+
const p = pages.find((x) => x.slug === slug);
|
|
19
|
+
if (!p)
|
|
20
|
+
continue;
|
|
21
|
+
const snippet = p.content.split("\n").find((line) => line.toLowerCase().includes(lq))?.slice(0, 200) ??
|
|
22
|
+
p.content.slice(0, 200);
|
|
23
|
+
results.push(`[PAGE] [[${p.title}]]\n${snippet}`);
|
|
24
|
+
}
|
|
25
|
+
// Thread search: BM25 on manifest title + last_action (fast, no file I/O per thread)
|
|
26
|
+
const threadDocs = allThreads.slice(0, 200).map(t => ({
|
|
27
|
+
id: t.id,
|
|
28
|
+
title: t.title,
|
|
29
|
+
content: [t.last_action, t.next_action, t.blocker].filter(Boolean).join(" "),
|
|
30
|
+
}));
|
|
31
|
+
const threadScores = searchInDocs(threadDocs, query);
|
|
32
|
+
const threadResults = threadScores
|
|
33
|
+
.slice(0, 10)
|
|
34
|
+
.map(r => allThreads.find(t => t.id === r.id))
|
|
35
|
+
.filter((t) => t !== undefined);
|
|
36
|
+
for (const t of threadResults.slice(0, 5)) {
|
|
37
|
+
const content = await getThread(t.id);
|
|
38
|
+
const titleMatch = content?.match(/^#\s+(.+)$/m);
|
|
39
|
+
const title = titleMatch?.[1] ?? t.id;
|
|
40
|
+
const blocks = content?.split(/\n---\n/) ?? [];
|
|
41
|
+
const matched = blocks.find((b) => b.toLowerCase().includes(lq));
|
|
42
|
+
const snippet = matched
|
|
43
|
+
?.trim()
|
|
44
|
+
.replace(/^(?:_[^_\n]+_|\*\*[^*\n]+\*\*)\s*/m, "")
|
|
45
|
+
.slice(0, 200) ??
|
|
46
|
+
(t.last_action?.slice(0, 200) ?? "");
|
|
47
|
+
results.push(`[THREAD] ${title} (id: ${t.id})\n${snippet}`);
|
|
48
|
+
}
|
|
49
|
+
if (allThreads.length > 200) {
|
|
50
|
+
results.push(`\n(최근 200개 스레드만 검색됨 — 전체 ${allThreads.length}개 중 ${allThreads.length - 200}개 미검색)`);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: "text",
|
|
56
|
+
text: results.length > 0
|
|
57
|
+
? results.join("\n\n---\n\n")
|
|
58
|
+
: `"${query}"에 해당하는 내용 없음${allThreads.length > 200 ? ` (최근 200개 스레드만 검색됨)` : ""}`,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
return { content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }], isError: true };
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getThreads, getThread, OPEN_SIGNAL, DONE_SIGNAL, extractFieldBrain } from "../../lib/brain.js";
|
|
3
|
+
import { runConsolidationIfDue } from "../../lib/consolidate.js";
|
|
4
|
+
import { git } from "../utils.js";
|
|
5
|
+
export function registerWikiRecall(server) {
|
|
6
|
+
server.tool("wiki_recall", "새 대화가 시작되면 반드시 첫 번째로 호출. 미완료 스레드를 우선 표면화해 '어제 X 작업 중이었는데 이어서 할까요?' 형태로 컨텍스트를 복원한다. 마크다운 텍스트 반환.", {
|
|
7
|
+
limit: z.number().int().min(1).max(20).optional().default(5).describe("반환할 스레드 수"),
|
|
8
|
+
}, async ({ limit }) => {
|
|
9
|
+
try {
|
|
10
|
+
const threads = await getThreads();
|
|
11
|
+
runConsolidationIfDue(threads).catch(() => { }); // fire-and-forget, unhandled rejection 방지
|
|
12
|
+
const candidates = threads.slice(0, Math.max(limit * 10, 50));
|
|
13
|
+
// Use manifest-cached signal fields where available; fall back to file reads only for uncached
|
|
14
|
+
const uncachedIdx = candidates
|
|
15
|
+
.map((t, i) => (t.is_open === undefined ? i : -1))
|
|
16
|
+
.filter((i) => i >= 0);
|
|
17
|
+
const fileContents = new Map();
|
|
18
|
+
if (uncachedIdx.length > 0) {
|
|
19
|
+
const reads = await Promise.all(uncachedIdx.map((i) => getThread(candidates[i].id)));
|
|
20
|
+
uncachedIdx.forEach((candIdx, readIdx) => {
|
|
21
|
+
const c = reads[readIdx];
|
|
22
|
+
if (c)
|
|
23
|
+
fileContents.set(candidates[candIdx].id, c);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const enriched = candidates
|
|
27
|
+
.map((t) => {
|
|
28
|
+
let is_open;
|
|
29
|
+
let last_action;
|
|
30
|
+
let capture_count;
|
|
31
|
+
let next_action;
|
|
32
|
+
let blocker;
|
|
33
|
+
if (t.is_open !== undefined && t.last_action !== undefined && t.capture_count !== undefined) {
|
|
34
|
+
// Fast path: use manifest cache
|
|
35
|
+
is_open = t.is_open;
|
|
36
|
+
last_action = t.last_action;
|
|
37
|
+
capture_count = t.capture_count;
|
|
38
|
+
// Use stored next_action/blocker from manifest if available
|
|
39
|
+
next_action = t.next_action ?? "";
|
|
40
|
+
blocker = t.blocker ?? "";
|
|
41
|
+
const is_done = t.is_done !== undefined ? t.is_done : (DONE_SIGNAL.test(last_action) && !is_open);
|
|
42
|
+
const gapHours = isNaN(new Date(t.updatedAt).getTime())
|
|
43
|
+
? null
|
|
44
|
+
: Math.round((Date.now() - new Date(t.updatedAt).getTime()) / 3600000);
|
|
45
|
+
return {
|
|
46
|
+
threadId: t.id,
|
|
47
|
+
title: t.title,
|
|
48
|
+
status: (is_done ? "done" : (gapHours === null || gapHours > 48) ? "stale" : "active"),
|
|
49
|
+
gap_hours: gapHours,
|
|
50
|
+
capture_count,
|
|
51
|
+
last_action,
|
|
52
|
+
is_open,
|
|
53
|
+
next_action,
|
|
54
|
+
blocker,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Slow path: file read fallback (legacy manifest entries)
|
|
58
|
+
const content = fileContents.get(t.id);
|
|
59
|
+
if (!content)
|
|
60
|
+
return null;
|
|
61
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
62
|
+
const title = (titleMatch?.[1] ?? t.id).trim();
|
|
63
|
+
const captures = content.split(/\n---\n/).slice(1).filter((p) => p.trim());
|
|
64
|
+
const lastCapture = captures.at(-1) ?? "";
|
|
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;
|
|
68
|
+
last_action = fullText.replace(/\n+/g, " ").slice(0, 160);
|
|
69
|
+
next_action = extractFieldBrain(fullText, "다음할것").slice(0, 120);
|
|
70
|
+
blocker = extractFieldBrain(fullText, "막힌것").slice(0, 120);
|
|
71
|
+
capture_count = captures.length;
|
|
72
|
+
const gapHours = isNaN(new Date(t.updatedAt).getTime())
|
|
73
|
+
? null
|
|
74
|
+
: Math.round((Date.now() - new Date(t.updatedAt).getTime()) / 3600000);
|
|
75
|
+
return {
|
|
76
|
+
threadId: t.id,
|
|
77
|
+
title,
|
|
78
|
+
status: (isDone ? "done" : (gapHours === null || gapHours > 48) ? "stale" : "active"),
|
|
79
|
+
gap_hours: gapHours,
|
|
80
|
+
capture_count,
|
|
81
|
+
last_action,
|
|
82
|
+
is_open,
|
|
83
|
+
next_action,
|
|
84
|
+
blocker,
|
|
85
|
+
};
|
|
86
|
+
})
|
|
87
|
+
.filter((x) => x !== null)
|
|
88
|
+
.filter((x) => x.capture_count > 0);
|
|
89
|
+
// 순차 게이트: 중복 없이 정렬 (Set으로 추적)
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const addUniq = (entries) => entries.filter((t) => { if (seen.has(t.threadId))
|
|
92
|
+
return false; seen.add(t.threadId); return true; });
|
|
93
|
+
// open이 stale보다 중요 — 잊고 있던 미완료 태스크가 최우선
|
|
94
|
+
const sorted = [
|
|
95
|
+
...addUniq(enriched.filter((t) => t.is_open && t.status === "active")),
|
|
96
|
+
...addUniq(enriched.filter((t) => t.is_open && t.status === "stale")),
|
|
97
|
+
...addUniq(enriched.filter((t) => !t.is_open && t.status === "active")),
|
|
98
|
+
...addUniq(enriched.filter((t) => !t.is_open && t.status === "stale")),
|
|
99
|
+
...addUniq(enriched.filter((t) => t.status === "done")),
|
|
100
|
+
].slice(0, limit);
|
|
101
|
+
// 콜드 스타트: 스레드 없으면 git 히스토리에서 컨텍스트 시드
|
|
102
|
+
if (sorted.length === 0) {
|
|
103
|
+
try {
|
|
104
|
+
const [log, status, branch] = await Promise.all([
|
|
105
|
+
git("log", "--oneline", "-8", "--format=%h %s (%ar)"),
|
|
106
|
+
git("status", "--short"),
|
|
107
|
+
git("branch", "--show-current"),
|
|
108
|
+
]);
|
|
109
|
+
const gitContext = [
|
|
110
|
+
branch ? `브랜치: ${branch}` : "",
|
|
111
|
+
log ? `최근 커밋:\n${log}` : "",
|
|
112
|
+
status ? `미완성 파일:\n${status}` : "",
|
|
113
|
+
].filter(Boolean).join("\n\n");
|
|
114
|
+
if (gitContext) {
|
|
115
|
+
return {
|
|
116
|
+
content: [{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: `## Second Brain 복원\n저장된 스레드 없음. Git 히스토리에서 컨텍스트를 가져왔어.\n\n${gitContext}\n\n---\nwiki_setup으로 지금 작업을 등록하거나, wiki_dump로 바로 던져봐.`,
|
|
119
|
+
}],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// git 없는 환경
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (sorted.length === 0) {
|
|
128
|
+
const hint = threads.length === 0
|
|
129
|
+
? "저장된 스레드 없음. wiki_setup이나 wiki_dump로 첫 생각을 던져봐."
|
|
130
|
+
: "최근 활동 없음. 모든 스레드가 완료되었거나 오래됨.";
|
|
131
|
+
return { content: [{ type: "text", text: hint }] };
|
|
132
|
+
}
|
|
133
|
+
const statusIcon = (t) => t.status === "done" ? "✅" : t.is_open ? "🔴" : t.status === "active" ? "🟡" : "⬜";
|
|
134
|
+
const gapLabel = (h) => h === null ? "" : h < 1 ? "방금 전" : h < 18 ? `${h}시간 전` : h < 36 ? "어제" : `${Math.floor(h / 24)}일 전`;
|
|
135
|
+
const lines = [];
|
|
136
|
+
// Lead entry — the most important thread, formatted as a direct question
|
|
137
|
+
const top = sorted[0];
|
|
138
|
+
const others = sorted.slice(1);
|
|
139
|
+
// Lead section header
|
|
140
|
+
if (top.is_open) {
|
|
141
|
+
lines.push("## 어제 멈춘 곳\n");
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
lines.push("## Second Brain 복원\n");
|
|
145
|
+
}
|
|
146
|
+
// Top thread — blockquote style for visual prominence
|
|
147
|
+
const topGap = gapLabel(top.gap_hours);
|
|
148
|
+
lines.push(`> ${statusIcon(top)} **${top.title}**${topGap ? ` — ${topGap}` : ""}`);
|
|
149
|
+
// Show 다음할것 if available, otherwise last_action
|
|
150
|
+
const topNext = top.next_action || (top.last_action ? top.last_action.replace(/^(?:결정|가설|막힌것|다음할것|블로커|요약)\s*:\s*/i, "").slice(0, 100) : "");
|
|
151
|
+
if (topNext)
|
|
152
|
+
lines.push(`> → 다음: ${topNext}`);
|
|
153
|
+
// Show 막힌것 prominently if present
|
|
154
|
+
if (top.blocker)
|
|
155
|
+
lines.push(`> ⛔ 막힌것: ${top.blocker}`);
|
|
156
|
+
// Call to action
|
|
157
|
+
if (top.is_open) {
|
|
158
|
+
lines.push(`>`);
|
|
159
|
+
lines.push(`> 이어서 갈까? (thread: \`${top.threadId}\`)`);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
lines.push(`>`);
|
|
163
|
+
lines.push(`> thread: \`${top.threadId}\``);
|
|
164
|
+
}
|
|
165
|
+
// Stale-open threads the user has forgotten — flag them separately
|
|
166
|
+
const forgottenThreads = others.filter(t => t.is_open && t.status === "stale");
|
|
167
|
+
const activeOthers = others.filter(t => !(t.is_open && t.status === "stale"));
|
|
168
|
+
if (forgottenThreads.length > 0) {
|
|
169
|
+
lines.push("\n---");
|
|
170
|
+
lines.push("## 📌 잊고 있던 거 (열려있는데 오랫동안 못 봄)");
|
|
171
|
+
for (const t of forgottenThreads) {
|
|
172
|
+
const gap = gapLabel(t.gap_hours);
|
|
173
|
+
const preview = t.next_action || t.last_action?.replace(/^(?:결정|가설|막힌것|다음할것|블로커|요약)\s*:\s*/i, "").slice(0, 80) || "";
|
|
174
|
+
const blockerHint = t.blocker ? ` ⛔ ${t.blocker.slice(0, 60)}` : "";
|
|
175
|
+
lines.push(`🔴 **${t.title}**${gap ? ` (${gap})` : ""}${blockerHint}`);
|
|
176
|
+
if (preview)
|
|
177
|
+
lines.push(` → ${preview}`);
|
|
178
|
+
lines.push(` thread: \`${t.threadId}\``);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (activeOthers.length > 0) {
|
|
182
|
+
lines.push("\n---");
|
|
183
|
+
lines.push("다른 작업:");
|
|
184
|
+
for (const t of activeOthers) {
|
|
185
|
+
const gap = gapLabel(t.gap_hours);
|
|
186
|
+
const preview = t.next_action || t.last_action?.replace(/^(?:결정|가설|막힌것|다음할것|블로커|요약)\s*:\s*/i, "").slice(0, 80) || "";
|
|
187
|
+
const blockerHint = t.blocker ? ` ⛔` : "";
|
|
188
|
+
lines.push(`${statusIcon(t)} **${t.title}**${gap ? ` (${gap})` : ""}${blockerHint}${preview ? ` → ${preview}` : ""}`);
|
|
189
|
+
lines.push(` thread: \`${t.threadId}\``);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
lines.push("\n---");
|
|
193
|
+
if (top.is_open) {
|
|
194
|
+
lines.push(`이어서 가려면: "ㅇㅇ" 또는 \`wiki_dump({ threadId: "${top.threadId}", content: "..." })\``);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
lines.push("이어붙이기: `wiki_dump({ threadId: \"...\", content: \"...\" })`");
|
|
198
|
+
}
|
|
199
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
return { content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }], isError: true };
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { savePage } from "../../lib/brain.js";
|
|
3
|
+
export function registerWikiSave(server) {
|
|
4
|
+
server.tool("wiki_save", "구조화된 마크다운 내용을 위키 페이지로 저장한다.", {
|
|
5
|
+
slug: z.string().max(100).regex(/^[a-z0-9가-힣-]+$/).describe("저장할 페이지 슬러그"),
|
|
6
|
+
content: z.string().max(64000).describe("저장할 마크다운 내용 (# 제목\\n\\n## 섹션...)"),
|
|
7
|
+
}, async ({ slug, content }) => {
|
|
8
|
+
const normalSlug = slug.toLowerCase();
|
|
9
|
+
if (!normalSlug || normalSlug.includes("/") || normalSlug.includes("\\") || normalSlug.includes("..") || normalSlug.includes("\0") || !/^[a-z0-9가-힣-]+$/.test(normalSlug)) {
|
|
10
|
+
return { content: [{ type: "text", text: `오류: 유효하지 않은 슬러그 "${slug}"` }], isError: true };
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
await savePage(normalSlug, content);
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
return { content: [{ type: "text", text: `오류: 저장 실패 — ${e.message}` }], isError: true };
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text", text: `저장됨: [[${normalSlug}]]` }],
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { saveCapture, getThread } from "../../lib/brain.js";
|
|
3
|
+
import { upsertPageFromCapture } from "../../lib/linker.js";
|
|
4
|
+
export function registerWikiSetup(server) {
|
|
5
|
+
server.tool("wiki_setup", "선택적 뇌 스냅샷. 지금 작업 중인 것들을 입력하면 첫 스레드를 생성해 wiki_recall이 즉시 의미있는 내용을 반환한다. 설치 직후 또는 컨텍스트를 새로 심고 싶을 때 호출.", {
|
|
6
|
+
tasks: z.array(z.string().max(500)).min(1).max(5).describe("지금 하고 있는 것들 (1-5개)"),
|
|
7
|
+
}, async ({ tasks }) => {
|
|
8
|
+
try {
|
|
9
|
+
const content = `현재 작업 스냅샷:\n${tasks.map((t, i) => `${i + 1}. ${t}`).join("\n")}`;
|
|
10
|
+
const result = await saveCapture(content);
|
|
11
|
+
const fullContent = await getThread(result.threadId);
|
|
12
|
+
if (fullContent)
|
|
13
|
+
await upsertPageFromCapture(result.title, fullContent).catch(() => { });
|
|
14
|
+
return {
|
|
15
|
+
content: [{
|
|
16
|
+
type: "text",
|
|
17
|
+
text: `뇌 스냅샷 저장됨 ✓\nthread: ${result.threadId}\n\n이제 wiki_recall을 호출하면 이 컨텍스트가 복원돼.`,
|
|
18
|
+
}],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
return { content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }], isError: true };
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|