pkm-mcp-server 1.4.0 → 1.4.1
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/CHANGELOG.md +5 -0
- package/hooks/resolve-project.js +8 -2
- package/hooks/stop-sweep.js +178 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.4.1] - 2026-03-21
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- SessionStart hook crash when installed via `init` — `resolve-project.js` imported `resolvePath` from `../helpers.js` which doesn't exist at `~/.claude/hooks/pkm/`. Inlined the path security check to remove the external dependency.
|
|
13
|
+
|
|
9
14
|
## [1.4.0] - 2026-03-21
|
|
10
15
|
|
|
11
16
|
### Added
|
package/hooks/resolve-project.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
function assertPathWithinVault(relativePath, vaultPath) {
|
|
5
|
+
const resolved = path.resolve(vaultPath, relativePath);
|
|
6
|
+
if (resolved !== vaultPath && !resolved.startsWith(vaultPath + path.sep)) {
|
|
7
|
+
throw new Error("Path escapes vault directory");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
4
10
|
|
|
5
11
|
export async function resolveProject(cwd, vaultPath) {
|
|
6
12
|
try {
|
|
@@ -35,7 +41,7 @@ export async function resolveProject(cwd, vaultPath) {
|
|
|
35
41
|
if (match) {
|
|
36
42
|
const annotatedPath = match[1].trim();
|
|
37
43
|
try {
|
|
38
|
-
|
|
44
|
+
assertPathWithinVault(annotatedPath, vaultPath);
|
|
39
45
|
} catch (e) {
|
|
40
46
|
if (e.message === "Path escapes vault directory") {
|
|
41
47
|
return { error: `CLAUDE.md annotation escapes vault directory: ${annotatedPath}` };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { appendFileSync, createWriteStream, mkdirSync } from "node:fs";
|
|
5
|
+
import { access } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { resolveProject } from "./resolve-project.js";
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const VAULT_PATH = process.env.VAULT_PATH;
|
|
12
|
+
const LOG_DIR = VAULT_PATH ? join(VAULT_PATH, ".obsidian", "hook-logs") : null;
|
|
13
|
+
|
|
14
|
+
function logError(message) {
|
|
15
|
+
if (!LOG_DIR) {
|
|
16
|
+
console.error(`stop-sweep: ${message}`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
21
|
+
appendFileSync(join(LOG_DIR, "sweep-errors.log"), `${new Date().toISOString()} ${message}\n`);
|
|
22
|
+
} catch {
|
|
23
|
+
console.error(`stop-sweep: ${message}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
// Read hook input from stdin
|
|
29
|
+
let inputJson = "";
|
|
30
|
+
for await (const chunk of process.stdin) {
|
|
31
|
+
inputJson += chunk;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let input;
|
|
35
|
+
try {
|
|
36
|
+
input = JSON.parse(inputJson);
|
|
37
|
+
} catch {
|
|
38
|
+
logError("could not parse hook input JSON");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { transcript_path, session_id, cwd } = input;
|
|
43
|
+
|
|
44
|
+
// Skip if no transcript
|
|
45
|
+
if (!transcript_path) return;
|
|
46
|
+
try {
|
|
47
|
+
await access(transcript_path);
|
|
48
|
+
} catch {
|
|
49
|
+
return; // transcript file missing — normal for non-conversation triggers
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!VAULT_PATH) {
|
|
53
|
+
console.error("stop-sweep: VAULT_PATH not set");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!cwd) {
|
|
58
|
+
logError("hook input missing 'cwd' field");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolve project — no project, no captures
|
|
63
|
+
const { projectPath, error } = await resolveProject(cwd, VAULT_PATH);
|
|
64
|
+
if (error || !projectPath) return;
|
|
65
|
+
|
|
66
|
+
// Build MCP config
|
|
67
|
+
const indexPath = join(__dirname, "..", "index.js");
|
|
68
|
+
const mcpEnv = { VAULT_PATH };
|
|
69
|
+
if (process.env.OPENAI_API_KEY) {
|
|
70
|
+
mcpEnv.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
|
71
|
+
}
|
|
72
|
+
const mcpConfig = JSON.stringify({
|
|
73
|
+
mcpServers: {
|
|
74
|
+
"obsidian-pkm": {
|
|
75
|
+
command: "node",
|
|
76
|
+
args: [indexPath],
|
|
77
|
+
env: mcpEnv,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const prompt = `You are a PKM librarian agent. Your job is to identify PKM-worthy content from the most recent conversation exchange and file it correctly in the vault.
|
|
83
|
+
|
|
84
|
+
## Context
|
|
85
|
+
|
|
86
|
+
- Project: ${projectPath}
|
|
87
|
+
- Session: ${session_id}
|
|
88
|
+
- Transcript: ${transcript_path}
|
|
89
|
+
|
|
90
|
+
## Step 1: Read the transcript
|
|
91
|
+
|
|
92
|
+
Read the file at ${transcript_path}. Find the LAST user message and LAST assistant response — this is the current exchange. All prior messages are historical context only. Do NOT capture anything from prior messages.
|
|
93
|
+
|
|
94
|
+
## Step 2: Classify what's PKM-worthy
|
|
95
|
+
|
|
96
|
+
Be conservative. Most exchanges produce NOTHING worth capturing. Skip:
|
|
97
|
+
- Trivial Q&A, clarifications, implementation details obvious from code
|
|
98
|
+
- Anything restating existing project context
|
|
99
|
+
- Content already handled by a vault_capture call in the same exchange
|
|
100
|
+
(read the vault_capture arguments to see what type/title/content was captured;
|
|
101
|
+
skip that specific item, but still capture other PKM-worthy content)
|
|
102
|
+
|
|
103
|
+
Before creating any note, use vault_search to check if a very similar note already exists — another sweep instance may have captured the same content from a prior exchange in a rapid conversation.
|
|
104
|
+
|
|
105
|
+
If you find something worth capturing, classify it:
|
|
106
|
+
|
|
107
|
+
| Content Type | Action |
|
|
108
|
+
|---|---|
|
|
109
|
+
| New task identified | Create task note: vault_write(template: "task", path: "${projectPath}/tasks/{kebab-title}.md") |
|
|
110
|
+
| Task completed/status changed | Find existing task via vault_query, then vault_update_frontmatter to update status |
|
|
111
|
+
| Significant decision agreed upon | Create ADR: vault_write(template: "adr", path: "${projectPath}/development/decisions/ADR-NNN-{kebab-title}.md") — use vault_list on the decisions directory to find the next ADR number |
|
|
112
|
+
| Research finding / gotcha | Create research note: vault_write(template: "research-note", path: "${projectPath}/research/{kebab-title}.md") |
|
|
113
|
+
| Bug root cause documented | Create troubleshooting log: vault_write(template: "troubleshooting-log", path: "${projectPath}/development/debug/{kebab-title}.md") |
|
|
114
|
+
| Minor implementation detail | SKIP — it's in the code/commits |
|
|
115
|
+
|
|
116
|
+
## Step 3: Create/update notes properly
|
|
117
|
+
|
|
118
|
+
For every note you create:
|
|
119
|
+
1. Use vault_write with the correct template
|
|
120
|
+
2. Use vault_edit to replace ALL template placeholders with real content
|
|
121
|
+
3. Use vault_suggest_links to find related notes (skip gracefully if unavailable)
|
|
122
|
+
4. Add [[wikilinks]] to the related notes in the body
|
|
123
|
+
5. Verify the note has no placeholder text remaining
|
|
124
|
+
|
|
125
|
+
For task updates:
|
|
126
|
+
1. Use vault_query to find the task by title/tags
|
|
127
|
+
2. Use vault_update_frontmatter to update status/priority
|
|
128
|
+
3. Optionally vault_append to add context about what changed
|
|
129
|
+
|
|
130
|
+
## Step 4: Quality check
|
|
131
|
+
|
|
132
|
+
If you created any notes, read each one back to confirm:
|
|
133
|
+
- No template placeholders remain
|
|
134
|
+
- Wikilinks are present to related notes (if vault_suggest_links was available)
|
|
135
|
+
- Frontmatter is complete and valid
|
|
136
|
+
|
|
137
|
+
If nothing is PKM-worthy, do nothing. Doing nothing is the correct default.`;
|
|
138
|
+
|
|
139
|
+
// All vault tools except vault_trash and vault_capture
|
|
140
|
+
const allowedTools = [
|
|
141
|
+
"vault_read", "vault_peek", "vault_write", "vault_append", "vault_edit",
|
|
142
|
+
"vault_update_frontmatter", "vault_search", "vault_semantic_search",
|
|
143
|
+
"vault_suggest_links", "vault_list", "vault_recent", "vault_links",
|
|
144
|
+
"vault_neighborhood", "vault_query", "vault_tags", "vault_activity", "vault_move",
|
|
145
|
+
].map(t => `mcp__obsidian-pkm__${t}`).join(" ");
|
|
146
|
+
|
|
147
|
+
// Ensure log directory exists
|
|
148
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
149
|
+
const logFile = join(LOG_DIR, `sweep-${new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)}.log`);
|
|
150
|
+
|
|
151
|
+
const child = spawn("claude", [
|
|
152
|
+
"-p",
|
|
153
|
+
"--model", "sonnet",
|
|
154
|
+
"--mcp-config", mcpConfig,
|
|
155
|
+
"--max-turns", "15",
|
|
156
|
+
"--allowedTools", allowedTools,
|
|
157
|
+
], {
|
|
158
|
+
detached: true,
|
|
159
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Pipe prompt to stdin
|
|
163
|
+
child.stdin.write(prompt);
|
|
164
|
+
child.stdin.end();
|
|
165
|
+
|
|
166
|
+
// Log stdout and stderr to file
|
|
167
|
+
const logStream = createWriteStream(logFile, { flags: "a" });
|
|
168
|
+
child.stdout.pipe(logStream);
|
|
169
|
+
child.stderr.pipe(logStream);
|
|
170
|
+
|
|
171
|
+
// Detach — let the background process run independently
|
|
172
|
+
child.unref();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
main().catch((err) => {
|
|
176
|
+
logError(`uncaught: ${err.message}`);
|
|
177
|
+
process.exit(0);
|
|
178
|
+
});
|