hypomnema 1.0.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.
Files changed (79) hide show
  1. package/.claude-plugin/plugin.json +11 -0
  2. package/LICENSE +21 -0
  3. package/README.ko.md +160 -0
  4. package/README.md +160 -0
  5. package/commands/.gitkeep +0 -0
  6. package/commands/crystallize.md +116 -0
  7. package/commands/doctor.md +66 -0
  8. package/commands/feedback.md +67 -0
  9. package/commands/graph.md +54 -0
  10. package/commands/ingest.md +85 -0
  11. package/commands/init.md +101 -0
  12. package/commands/lint.md +55 -0
  13. package/commands/query.md +55 -0
  14. package/commands/resume.md +48 -0
  15. package/commands/stats.md +39 -0
  16. package/commands/uninstall.md +52 -0
  17. package/commands/upgrade.md +63 -0
  18. package/commands/verify.md +60 -0
  19. package/docs/.gitkeep +0 -0
  20. package/docs/ARCHITECTURE.md +183 -0
  21. package/docs/CONTRIBUTING.md +115 -0
  22. package/docs/TEST-CASES.md +580 -0
  23. package/hooks/.gitkeep +0 -0
  24. package/hooks/hooks.json +109 -0
  25. package/hooks/hypo-auto-commit.mjs +36 -0
  26. package/hooks/hypo-auto-stage.mjs +30 -0
  27. package/hooks/hypo-compact-guard.mjs +71 -0
  28. package/hooks/hypo-cwd-change.mjs +91 -0
  29. package/hooks/hypo-file-watch.mjs +47 -0
  30. package/hooks/hypo-first-prompt.mjs +59 -0
  31. package/hooks/hypo-hot-rebuild.mjs +95 -0
  32. package/hooks/hypo-lookup.mjs +178 -0
  33. package/hooks/hypo-personal-check.mjs +195 -0
  34. package/hooks/hypo-session-start.mjs +141 -0
  35. package/hooks/hypo-shared.mjs +213 -0
  36. package/package.json +37 -0
  37. package/scripts/.gitkeep +0 -0
  38. package/scripts/bump-version.mjs +53 -0
  39. package/scripts/crystallize.mjs +153 -0
  40. package/scripts/doctor.mjs +361 -0
  41. package/scripts/feedback.mjs +130 -0
  42. package/scripts/graph.mjs +183 -0
  43. package/scripts/ingest.mjs +130 -0
  44. package/scripts/init.mjs +515 -0
  45. package/scripts/lib/frontmatter.mjs +11 -0
  46. package/scripts/lib/hypo-ignore.mjs +54 -0
  47. package/scripts/lib/hypo-root.mjs +53 -0
  48. package/scripts/lint.mjs +210 -0
  49. package/scripts/query.mjs +124 -0
  50. package/scripts/resume.mjs +115 -0
  51. package/scripts/stats.mjs +132 -0
  52. package/scripts/uninstall.mjs +188 -0
  53. package/scripts/upgrade.mjs +538 -0
  54. package/scripts/verify.mjs +172 -0
  55. package/skills/.gitkeep +0 -0
  56. package/skills/crystallize/SKILL.md +85 -0
  57. package/skills/graph/SKILL.md +54 -0
  58. package/skills/ingest/SKILL.md +83 -0
  59. package/skills/lint/SKILL.md +55 -0
  60. package/skills/query/SKILL.md +58 -0
  61. package/skills/verify/SKILL.md +92 -0
  62. package/templates/.gitkeep +0 -0
  63. package/templates/.hypoignore +18 -0
  64. package/templates/Home.md +34 -0
  65. package/templates/Overview.md +50 -0
  66. package/templates/SCHEMA.md +106 -0
  67. package/templates/hot.md +22 -0
  68. package/templates/hypo-automation.md +69 -0
  69. package/templates/hypo-config.md +41 -0
  70. package/templates/hypo-guide.md +146 -0
  71. package/templates/hypo-help.md +53 -0
  72. package/templates/index.md +44 -0
  73. package/templates/log.md +25 -0
  74. package/templates/pages/_index.md +61 -0
  75. package/templates/projects/_template/hot.md +28 -0
  76. package/templates/projects/_template/index.md +39 -0
  77. package/templates/projects/_template/prd.md +29 -0
  78. package/templates/projects/_template/session-state.md +9 -0
  79. package/templates/session-state.md +12 -0
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-auto-commit.mjs — Stop hook
4
+ *
5
+ * At session end: stage all changes, commit if any, then pull+push to sync remote.
6
+ */
7
+
8
+ import { spawnSync } from 'child_process';
9
+ import { HYPO_DIR } from './hypo-shared.mjs';
10
+
11
+ function git(...args) {
12
+ return spawnSync('git', ['-C', HYPO_DIR, ...args], { encoding: 'utf-8', timeout: 30000 });
13
+ }
14
+
15
+ function hasRemote() {
16
+ const r = git('remote');
17
+ return (r.stdout || '').trim().length > 0;
18
+ }
19
+
20
+ git('add', '-A');
21
+ const staged = git('diff', '--cached', '--name-only').stdout?.trim() || '';
22
+ if (staged) {
23
+ const today = new Date().toISOString().slice(0, 10);
24
+ const commit = git('commit', '-m', `auto: ${today} wiki update`);
25
+ if (commit.status !== 0) {
26
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
27
+ process.exit(0);
28
+ }
29
+ }
30
+
31
+ if (hasRemote()) {
32
+ git('pull', '--no-rebase', '-q');
33
+ git('push');
34
+ }
35
+
36
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-auto-stage.mjs — PostToolUse hook
4
+ *
5
+ * When a file inside the wiki directory is written, stage it automatically.
6
+ */
7
+
8
+ import { spawnSync } from 'child_process';
9
+ import { HYPO_DIR } from './hypo-shared.mjs';
10
+
11
+ let input = {};
12
+ try {
13
+ const raw = await new Promise(r => {
14
+ let d = '';
15
+ process.stdin.on('data', c => d += c);
16
+ process.stdin.on('end', () => r(d));
17
+ });
18
+ input = JSON.parse(raw);
19
+ } catch {
20
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
21
+ process.exit(0);
22
+ }
23
+
24
+ const filePath = input.tool_input?.file_path ?? '';
25
+
26
+ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
27
+ spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
28
+ }
29
+
30
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-compact-guard.mjs — UserPromptSubmit hook
4
+ *
5
+ * Scope: detects "/compact" typed in chat only.
6
+ * The CLI built-in /compact does NOT fire UserPromptSubmit — use personal-wiki-check.mjs
7
+ * (PreCompact hook) as the hard gate for that path.
8
+ *
9
+ * Behavior: if session close is incomplete → instruct Claude to run session close
10
+ * immediately before /compact.
11
+ */
12
+
13
+ import {
14
+ lastSubstantialOpIsSession,
15
+ hypoIsClean,
16
+ hotMdIsClean,
17
+ readChecklist,
18
+ isCompactCommand,
19
+ isGateSkipped,
20
+ } from './hypo-shared.mjs';
21
+
22
+ let input = '';
23
+ process.stdin.setEncoding('utf-8');
24
+ process.stdin.on('data', chunk => { input += chunk; });
25
+ process.stdin.on('end', () => {
26
+ try {
27
+ const data = JSON.parse(input);
28
+ const prompt = (data.prompt || '').trim();
29
+
30
+ if (!isCompactCommand(prompt) || isGateSkipped()) {
31
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
32
+ return;
33
+ }
34
+
35
+ const hasSession = lastSubstantialOpIsSession();
36
+ const gitStatus = hypoIsClean();
37
+ const hotStatus = hotMdIsClean();
38
+
39
+ if (hasSession && gitStatus.clean && hotStatus.clean) {
40
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
41
+ return;
42
+ }
43
+
44
+ const reasons = [
45
+ !hasSession ? 'session log entry missing' : '',
46
+ !gitStatus.clean ? gitStatus.reason : '',
47
+ !hotStatus.clean ? hotStatus.reason : '',
48
+ ].filter(Boolean);
49
+
50
+ const today = new Date().toISOString().slice(0, 10);
51
+ const checklist = readChecklist(today);
52
+ const body = checklist
53
+ ? `Checklist:\n${checklist}`
54
+ : 'See hypo-guide.md for the session-close checklist.';
55
+
56
+ console.log(JSON.stringify({
57
+ continue: true,
58
+ additionalContext: [
59
+ `[WIKI_AUTOCLOSE] /compact detected — session close incomplete (${reasons.join(', ')}).`,
60
+ `Do NOT wait for user input. Run wiki session close NOW, then retry /compact.`,
61
+ ``,
62
+ body,
63
+ ``,
64
+ `To bypass: set HYPO_SKIP_GATE=1`,
65
+ ].join('\n'),
66
+ }));
67
+ } catch {
68
+ // Fail-open: any parse/runtime error must not block the user's prompt.
69
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
70
+ }
71
+ });
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-cwd-change.mjs — CwdChanged hook
4
+ *
5
+ * When the working directory changes mid-session, re-inject the matching
6
+ * project hot.md. Skips if still within the same project subtree.
7
+ */
8
+
9
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
10
+ import { homedir } from 'os';
11
+ import { join } from 'path';
12
+ import { HYPO_DIR, buildOutput } from './hypo-shared.mjs';
13
+
14
+ const PROJECTS_DIR = join(HYPO_DIR, 'projects');
15
+ const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
16
+ const MAX_CHARS = 3000;
17
+
18
+ function parseFrontmatterField(content, key) {
19
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
20
+ if (!match) return null;
21
+ const line = match[1].split('\n').find(l => l.startsWith(`${key}:`));
22
+ if (!line) return null;
23
+ return line.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, '');
24
+ }
25
+
26
+ function findProjectHot(cwd) {
27
+ if (!existsSync(PROJECTS_DIR)) return null;
28
+ for (const proj of readdirSync(PROJECTS_DIR)) {
29
+ const projDir = join(PROJECTS_DIR, proj);
30
+ if (!statSync(projDir).isDirectory()) continue;
31
+ const indexPath = join(projDir, 'index.md');
32
+ if (!existsSync(indexPath)) continue;
33
+ const content = readFileSync(indexPath, 'utf-8');
34
+ const workingDir = parseFrontmatterField(content, 'working_dir');
35
+ if (!workingDir) continue;
36
+ const resolved = workingDir.startsWith('~/')
37
+ ? join(homedir(), workingDir.slice(2))
38
+ : workingDir;
39
+ if (cwd === resolved || cwd.startsWith(resolved + '/')) {
40
+ const hotPath = join(projDir, 'hot.md');
41
+ return { proj, hotPath: existsSync(hotPath) ? hotPath : null, resolved };
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ let raw = '';
48
+ process.stdin.setEncoding('utf-8');
49
+ process.stdin.on('data', chunk => raw += chunk);
50
+ process.stdin.on('end', () => {
51
+ try {
52
+ let data = {};
53
+ try { data = JSON.parse(raw); } catch {}
54
+
55
+ const newCwd = data.new_cwd || data.new_directory || data.cwd || process.cwd();
56
+ const oldCwd = data.old_cwd || data.old_directory || data.previous_cwd || '';
57
+
58
+ // Skip re-injection if still in the same project
59
+ const oldHit = oldCwd ? findProjectHot(oldCwd) : null;
60
+ const newHit = findProjectHot(newCwd);
61
+
62
+ if (oldHit && newHit && oldHit.proj === newHit.proj) {
63
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
64
+ return;
65
+ }
66
+
67
+ if (newHit) {
68
+ const content = newHit.hotPath
69
+ ? readFileSync(newHit.hotPath, 'utf-8').slice(0, MAX_CHARS)
70
+ : '(no hot.md yet — will be created at session close)';
71
+ console.log(JSON.stringify(
72
+ buildOutput(`[WIKI: cwd changed → project=${newHit.proj}]\n\n${content}`, { continue: true, suppressOutput: true })
73
+ ));
74
+ return;
75
+ }
76
+
77
+ if (!existsSync(GLOBAL_HOT)) {
78
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
79
+ return;
80
+ }
81
+
82
+ const globalContent = readFileSync(GLOBAL_HOT, 'utf-8').slice(0, MAX_CHARS);
83
+ console.log(JSON.stringify(
84
+ buildOutput(`[WIKI: cwd changed → no project match, injecting global hot]\n\n${globalContent}`, { continue: true, suppressOutput: true })
85
+ ));
86
+
87
+ } catch (err) {
88
+ process.stderr.write(`[wiki-cwd-change] error: ${err.message}\n`);
89
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
90
+ }
91
+ });
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-file-watch.mjs — FileChanged hook
4
+ *
5
+ * When a hot.md inside the wiki is modified externally (e.g. by a remote
6
+ * agent or another Claude Code session), re-inject its contents.
7
+ */
8
+
9
+ import { readFileSync, existsSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { HYPO_DIR } from './hypo-shared.mjs';
12
+
13
+ const MAX_CHARS = 2000;
14
+
15
+ let raw = '';
16
+ process.stdin.setEncoding('utf-8');
17
+ process.stdin.on('data', chunk => raw += chunk);
18
+ process.stdin.on('end', () => {
19
+ try {
20
+ let data = {};
21
+ try { data = JSON.parse(raw); } catch {}
22
+
23
+ const filePath = data.file_path || data.path || '';
24
+
25
+ if (!filePath.startsWith(HYPO_DIR + '/') && filePath !== HYPO_DIR) {
26
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
27
+ return;
28
+ }
29
+
30
+ if (!existsSync(filePath)) {
31
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
32
+ return;
33
+ }
34
+
35
+ const content = readFileSync(filePath, 'utf-8').slice(0, MAX_CHARS);
36
+ const relPath = filePath.replace(HYPO_DIR + '/', '');
37
+
38
+ console.log(JSON.stringify({
39
+ continue: true,
40
+ suppressOutput: true,
41
+ additionalContext: `[WIKI FILE UPDATED: ${relPath}]\n\n${content}`,
42
+ }));
43
+
44
+ } catch {
45
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
46
+ }
47
+ });
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-first-prompt.mjs — UserPromptSubmit hook
4
+ *
5
+ * Consumes the marker written by wiki-session-start.mjs.
6
+ * On the FIRST user prompt of a new session, injects a lightweight decision
7
+ * instruction so the LLM can decide whether to announce the resume context.
8
+ *
9
+ * hot.md content is NOT re-injected here — wiki-session-start.mjs already
10
+ * injected it on SessionStart. Only the decision hint is added.
11
+ * Marker expires after 10 minutes.
12
+ */
13
+
14
+ import { readFileSync, unlinkSync, existsSync } from 'fs';
15
+ import { tmpdir } from 'os';
16
+ import { join } from 'path';
17
+ import { buildOutput } from './hypo-shared.mjs';
18
+
19
+ const MARKER_TTL = 10 * 60 * 1000; // 10 min
20
+
21
+ let raw = '';
22
+ process.stdin.setEncoding('utf-8');
23
+ process.stdin.on('data', chunk => raw += chunk);
24
+ process.stdin.on('end', () => {
25
+ try {
26
+ let data = {};
27
+ try { data = JSON.parse(raw); } catch {}
28
+ const sessionId = data.session_id || 'default';
29
+ const MARKER_FILE = join(tmpdir(), `hypo-session-marker-${sessionId}.json`);
30
+
31
+ if (!existsSync(MARKER_FILE)) {
32
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
33
+ return;
34
+ }
35
+
36
+ const marker = JSON.parse(readFileSync(MARKER_FILE, 'utf-8'));
37
+ const age = Date.now() - (marker.ts || 0);
38
+
39
+ try { unlinkSync(MARKER_FILE); } catch {}
40
+
41
+ if (age > MARKER_TTL) {
42
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
43
+ return;
44
+ }
45
+
46
+ const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
47
+ const snapshotNote = hasSnapshot ? '' : ' (no snapshot yet — first session)';
48
+
49
+ console.log(JSON.stringify(
50
+ buildOutput(
51
+ `[WIKI SESSION START: project=${marker.proj}${snapshotNote}]\nDecision hint: if the first message relates to this project → answer first, then add one line "Previously working on ${marker.proj} — continue?" If unrelated / simple Q&A → answer only, no mention.`,
52
+ { continue: true, suppressOutput: true }
53
+ )
54
+ ));
55
+
56
+ } catch {
57
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
58
+ }
59
+ });
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-hot-rebuild.mjs — Stop hook
4
+ *
5
+ * Rebuilds root hot.md in canonical format on every session end.
6
+ * Preserves the project pointer table rows while refreshing dates
7
+ * from each project's hot.md frontmatter `updated:` field.
8
+ *
9
+ * Claude manages: adding/removing project rows in the pointer table.
10
+ * This script manages: frontmatter, H2 structure, date fields.
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { HYPO_DIR } from './hypo-shared.mjs';
16
+
17
+ const HOT_PATH = join(HYPO_DIR, 'hot.md');
18
+
19
+ function parseFrontmatter(content) {
20
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
21
+ if (!m) return {};
22
+ const result = {};
23
+ for (const line of m[1].split('\n')) {
24
+ const kv = line.match(/^(\w[\w_-]*):\s*(.*)$/);
25
+ if (kv) result[kv[1]] = kv[2].trim();
26
+ }
27
+ return result;
28
+ }
29
+
30
+ function getProjectDate(slug) {
31
+ const hotPath = join(HYPO_DIR, 'projects', slug, 'hot.md');
32
+ if (!existsSync(hotPath)) return null;
33
+ try {
34
+ return parseFrontmatter(readFileSync(hotPath, 'utf-8')).updated || null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function parsePointerRows(content) {
41
+ const rows = [];
42
+ for (const line of content.split('\n')) {
43
+ const m = line.match(/^\|\s*(.+?)\s*\|\s*.+?\s*\|\s*\[\[projects\/(.+?)\/hot\]\]\s*\|/);
44
+ if (m) rows.push({ name: m[1].trim(), slug: m[2].trim() });
45
+ }
46
+ return rows;
47
+ }
48
+
49
+ function rebuild() {
50
+ if (!existsSync(HOT_PATH)) return;
51
+
52
+ const current = readFileSync(HOT_PATH, 'utf-8');
53
+ const rows = parsePointerRows(current);
54
+ if (rows.length === 0) return;
55
+
56
+ const today = new Date().toISOString().slice(0, 10);
57
+
58
+ const tableRows = rows.map(({ name, slug }) => {
59
+ const date = getProjectDate(slug) || today;
60
+ return `| ${name} | ${date} | [[projects/${slug}/hot]] |`;
61
+ }).join('\n');
62
+
63
+ const canonical = `---
64
+ title: Hot Cache — Pointer
65
+ type: reference
66
+ updated: ${today}
67
+ tags: [wiki, operations]
68
+ ---
69
+
70
+ # Hot Cache
71
+
72
+ > Read at session start → navigate to the relevant project session-state.md and hot.md.
73
+ > Update at session close: project session-state.md, project hot.md, and this file's "Active Projects" table.
74
+
75
+ ## Active Projects
76
+
77
+ | Project | Last Session | Hot Cache |
78
+ |---|---|---|
79
+ ${tableRows}
80
+
81
+ ## Session Start Checklist
82
+
83
+ 1. Check this file for the relevant project link
84
+ 2. Read \`projects/<name>/session-state.md\` for next tasks if it exists
85
+ 3. Read \`projects/<name>/hot.md\` for project background
86
+ `;
87
+
88
+ if (canonical !== current) writeFileSync(HOT_PATH, canonical);
89
+ }
90
+
91
+ try {
92
+ rebuild();
93
+ } catch {}
94
+
95
+ try { console.log(JSON.stringify({ continue: true, suppressOutput: true })); } catch {}
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-lookup.mjs — UserPromptSubmit hook
4
+ *
5
+ * On every user prompt:
6
+ * 1. Extract keywords from the prompt
7
+ * 2. BM25-score against ~/hypomnema/index.md entries
8
+ * HIT → read matched pages, inject as additionalContext
9
+ * MISS → inject top-3 closest slugs as a research signal
10
+ */
11
+
12
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
13
+ import { join, basename } from 'path';
14
+ import { HYPO_DIR, buildOutput, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
15
+
16
+ const INDEX_PATH = join(HYPO_DIR, 'index.md');
17
+ const MAX_HITS = 3;
18
+ const MAX_CHARS = 2000;
19
+
20
+ // ── helpers ─────────────────────────────────────────────────────────────────
21
+
22
+ function buildPageMap(dir, root = dir, map = {}, ignorePatterns = [], hypoDir = root) {
23
+ if (!existsSync(dir)) return map;
24
+ for (const entry of readdirSync(dir)) {
25
+ const full = join(dir, entry);
26
+ if (isIgnored(full, hypoDir, ignorePatterns)) continue;
27
+ if (statSync(full).isDirectory()) {
28
+ buildPageMap(full, root, map, ignorePatterns, hypoDir);
29
+ } else if (entry.endsWith('.md')) {
30
+ const rel = full.slice(root.length + 1).replace(/\.md$/, '');
31
+ map[rel] = full;
32
+ const bare = basename(entry, '.md');
33
+ if (!map[bare]) map[bare] = full;
34
+ }
35
+ }
36
+ return map;
37
+ }
38
+
39
+ function extractKeywords(prompt) {
40
+ const stop = new Set([
41
+ 'the','and','for','with','this','that','have','from','are','was','were',
42
+ 'what','when','where','how','why','who','can','could','should','would',
43
+ 'does','did','will','not','but','its','also','just','more','any','all',
44
+ '어떤','어떻게','무엇','이런','그런','하는','하고','해서','있어','없어',
45
+ '되는','이거','저거','그거','이건','그건','저건','같은','하면','되면',
46
+ '인지','에서','으로','까지','부터','에게','한테','에도','에만','에는',
47
+ ]);
48
+ return [...new Set(
49
+ prompt.toLowerCase()
50
+ .split(/[\s,,.。??!!()\[\]{}'"\/\\:;=+*&%$#@~`|<>]+/)
51
+ .filter(w => w.length >= 3 && !stop.has(w))
52
+ )];
53
+ }
54
+
55
+ function tokenize(text) {
56
+ return text.toLowerCase()
57
+ .split(/[\s\-_/.,,。??!!()\[\]{}'"\\:;=+*&%$#@~`|<>]+/)
58
+ .filter(w => w.length >= 2);
59
+ }
60
+
61
+ function bm25Score(queryTerms, entries, k1 = 1.5, b = 0.75) {
62
+ const N = entries.length;
63
+ if (N === 0) return [];
64
+ const docTokens = entries.map(e => tokenize(e.slug + ' ' + e.desc));
65
+ const avgdl = docTokens.reduce((s, t) => s + t.length, 0) / N;
66
+ const df = {};
67
+ for (const tokens of docTokens) {
68
+ for (const t of new Set(tokens)) df[t] = (df[t] || 0) + 1;
69
+ }
70
+ const idf = t => Math.log(1 + (N - (df[t] || 0) + 0.5) / ((df[t] || 0) + 0.5));
71
+
72
+ return entries.map((e, i) => {
73
+ const tokens = docTokens[i];
74
+ const dl = tokens.length || 1;
75
+ const tf = {};
76
+ for (const t of tokens) tf[t] = (tf[t] || 0) + 1;
77
+ let score = 0;
78
+ for (const q of queryTerms) {
79
+ const f = tf[q] || 0;
80
+ if (f === 0) continue;
81
+ const norm = 1 - b + b * dl / avgdl;
82
+ score += idf(q) * (f * (k1 + 1)) / (f + k1 * norm);
83
+ }
84
+ return { ...e, score };
85
+ }).sort((a, c) => c.score - a.score);
86
+ }
87
+
88
+ function parseIndexEntries(indexContent) {
89
+ const entries = [];
90
+ for (const line of indexContent.split('\n')) {
91
+ if (line.trimStart().startsWith('<!--')) continue;
92
+ if (line.trimStart().startsWith('>')) continue;
93
+ const m = line.match(/\[\[([^\]]+)\]\]\s*[—\-]+\s*(.+)/);
94
+ if (!m) continue;
95
+ const raw = m[1].trim();
96
+ const desc = m[2].trim();
97
+ const slug = raw.includes('|') ? raw.split('|')[0].trim() : raw;
98
+ entries.push({ slug, desc });
99
+ }
100
+ return entries;
101
+ }
102
+
103
+ // ── main ─────────────────────────────────────────────────────────────────────
104
+
105
+ let input = '';
106
+ process.stdin.setEncoding('utf-8');
107
+ process.stdin.on('data', chunk => input += chunk);
108
+ process.stdin.on('end', () => {
109
+ try {
110
+ const data = JSON.parse(input);
111
+ const prompt = (data.prompt || '').trim();
112
+
113
+ if (!prompt || !existsSync(INDEX_PATH)) {
114
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
115
+ return;
116
+ }
117
+
118
+ const keywords = extractKeywords(prompt);
119
+ if (keywords.length === 0) {
120
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
121
+ return;
122
+ }
123
+
124
+ const entries = parseIndexEntries(readFileSync(INDEX_PATH, 'utf-8'));
125
+ const scored = bm25Score(keywords, entries).filter(e => e.score > 0);
126
+ const topScore = scored[0]?.score ?? 0;
127
+ const matched = scored.filter(e => e.score >= topScore * 0.5);
128
+
129
+ if (matched.length === 0) {
130
+ const topic = keywords.slice(0, 5).join(', ');
131
+ const closest = bm25Score(keywords, entries).slice(0, 3).map(e => `[[${e.slug}]]`).join(', ');
132
+ console.log(JSON.stringify(
133
+ buildOutput(
134
+ `[WIKI LOOKUP: miss] "${topic}" — no match. Closest: ${closest || 'none'}`,
135
+ { continue: true, suppressOutput: true }
136
+ )
137
+ ));
138
+ return;
139
+ }
140
+
141
+ const ignorePatterns = loadHypoIgnore(HYPO_DIR);
142
+ const pageMap = {
143
+ ...buildPageMap(join(HYPO_DIR, 'pages'), join(HYPO_DIR, 'pages'), {}, ignorePatterns, HYPO_DIR),
144
+ ...buildPageMap(join(HYPO_DIR, 'projects'), join(HYPO_DIR, 'projects'), {}, ignorePatterns, HYPO_DIR),
145
+ };
146
+
147
+ const injected = [];
148
+ for (const { slug } of matched.slice(0, MAX_HITS)) {
149
+ const path = pageMap[slug]
150
+ ?? pageMap[slug.replace(/^(pages|projects)\//, '')]
151
+ ?? pageMap[basename(slug)];
152
+ if (path && existsSync(path)) {
153
+ injected.push(`=== [[${slug}]] ===\n${readFileSync(path, 'utf-8').slice(0, MAX_CHARS)}`);
154
+ }
155
+ }
156
+
157
+ if (injected.length === 0) {
158
+ const slugs = matched.slice(0, MAX_HITS).map(e => e.slug).join(', ');
159
+ console.log(JSON.stringify(
160
+ buildOutput(`[WIKI LOOKUP: index hit but files missing] ${slugs}`, { continue: true, suppressOutput: true })
161
+ ));
162
+ return;
163
+ }
164
+
165
+ const overflow = matched.length > MAX_HITS
166
+ ? `\n(+${matched.length - MAX_HITS} more matches — search wiki index for more)` : '';
167
+
168
+ console.log(JSON.stringify(
169
+ buildOutput(
170
+ `[WIKI LOOKUP: ${injected.length} page(s) matched]\n\n` + injected.join('\n\n') + overflow,
171
+ { continue: true, suppressOutput: true }
172
+ )
173
+ ));
174
+
175
+ } catch {
176
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
177
+ }
178
+ });