pkm-mcp-server 1.4.1 → 1.4.2
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/README.md +9 -9
- package/hooks/capture-handler.sh +13 -5
- package/hooks/load-context.js +57 -2
- package/hooks/stop-sweep.js +8 -6
- package/package.json +1 -1
- package/hooks/stop-sweep.sh +0 -81
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.2] - 2026-03-21
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- SessionStart hook crash — `load-context.js` imported `extractFrontmatter` and `extractTailSections` from parent-relative paths (`../utils.js`, `../helpers.js`) that don't exist when hooks are copied to `~/.claude/hooks/pkm/`. Inlined all three functions to make hooks fully self-contained.
|
|
13
|
+
|
|
9
14
|
## [1.4.1] - 2026-03-21
|
|
10
15
|
|
|
11
16
|
### Fixed
|
package/hooks/README.md
CHANGED
|
@@ -9,14 +9,14 @@ The hook system consists of three hooks:
|
|
|
9
9
|
| Hook | Event | Purpose |
|
|
10
10
|
|------|-------|---------|
|
|
11
11
|
| `session-start.js` | SessionStart | Loads project context from the vault at the start of each session |
|
|
12
|
-
| `stop-sweep.
|
|
12
|
+
| `stop-sweep.js` | Stop | PKM librarian: creates structured, graph-linked vault notes from the latest exchange |
|
|
13
13
|
| `capture-handler.sh` | PostToolUse | Explicit capture: creates structured vault notes when `vault_capture` is called |
|
|
14
14
|
|
|
15
15
|
### How it works
|
|
16
16
|
|
|
17
17
|
- **SessionStart** (`session-start.js`): Runs synchronously. Resolves the current working directory to a vault project, reads the project index, recent devlog entries, and active tasks, then injects them as context.
|
|
18
|
-
- **Stop** (`stop-sweep.
|
|
19
|
-
- **PostToolUse** (`capture-handler.sh`): Runs asynchronously after `vault_capture` tool use. Spawns a background `claude -p` (
|
|
18
|
+
- **Stop** (`stop-sweep.js`): Runs asynchronously after each assistant response. Resolves the current working directory to a vault project (no project = no captures). Spawns a background `claude -p` (Sonnet, 15 turns) that reads the transcript, classifies PKM-worthy content from the latest exchange, and creates properly structured vault notes in the project directory with wikilinks to related notes.
|
|
19
|
+
- **PostToolUse** (`capture-handler.sh`): Runs asynchronously after `vault_capture` tool use. Spawns a background `claude -p` (Opus, 30 turns) that creates a properly structured vault note (ADR, task, research note, or troubleshooting log) from the capture payload with graph links.
|
|
20
20
|
|
|
21
21
|
## Setup
|
|
22
22
|
|
|
@@ -51,7 +51,7 @@ Add the following to your `~/.claude/settings.json`:
|
|
|
51
51
|
"hooks": [
|
|
52
52
|
{
|
|
53
53
|
"type": "command",
|
|
54
|
-
"command": "VAULT_PATH=\"/path/to/your/vault\" /path/to/Obsidian-MCP/hooks/stop-sweep.
|
|
54
|
+
"command": "VAULT_PATH=\"/path/to/your/vault\" node /path/to/Obsidian-MCP/hooks/stop-sweep.js",
|
|
55
55
|
"async": true,
|
|
56
56
|
"timeout": 10
|
|
57
57
|
}
|
|
@@ -85,19 +85,19 @@ The hook system uses `type: "command"` hooks (not `type: "agent"`). The shell sc
|
|
|
85
85
|
|
|
86
86
|
- The hook script exits immediately, satisfying the timeout constraint
|
|
87
87
|
- The `claude -p` process continues running independently, doing the actual vault work
|
|
88
|
-
- `--max-turns` limits how many tool calls the background process can make (
|
|
88
|
+
- `--max-turns` limits how many tool calls the background process can make (15 for stop-sweep, 30 for capture-handler)
|
|
89
89
|
|
|
90
90
|
### MCP config resolution
|
|
91
91
|
|
|
92
|
-
The
|
|
92
|
+
The hook scripts resolve their own location to construct the path to `index.js`. `stop-sweep.js` uses the ES module pattern (`import.meta.url` → `fileURLToPath` → `path.dirname`), while `capture-handler.sh` uses the POSIX pattern (`cd "$(dirname "$0")" && pwd -P`). Both work regardless of current working directory. The `VAULT_PATH` environment variable must be set in the hook command because hook scripts run outside the MCP server process.
|
|
93
93
|
|
|
94
94
|
### Async behavior
|
|
95
95
|
|
|
96
|
-
Both `stop-sweep.
|
|
96
|
+
Both `stop-sweep.js` and `capture-handler.sh` use `async: true` in the hook config. This means Claude Code does not wait for the hook script to finish before continuing. The script starts, spawns the background `claude -p` process, and exits. The background process runs to completion on its own.
|
|
97
97
|
|
|
98
98
|
### Noise suppression and deduplication
|
|
99
99
|
|
|
100
|
-
The stop-sweep hook is conservative by design. It only looks at the last exchange (one user message + one assistant response) and skips trivial interactions.
|
|
100
|
+
The stop-sweep hook is conservative by design. It only looks at the last exchange (one user message + one assistant response) and skips trivial interactions. Deduplication is item-level: if the exchange contains a `vault_capture` call, the sweep reads its arguments and skips that specific item, but still captures other PKM-worthy content. The sweep also runs `vault_search` before creating notes to catch duplicates from concurrent sweep instances.
|
|
101
101
|
|
|
102
102
|
## Troubleshooting
|
|
103
103
|
|
|
@@ -111,7 +111,7 @@ The stop-sweep hook is conservative by design. It only looks at the last exchang
|
|
|
111
111
|
|
|
112
112
|
- Check that `VAULT_PATH` is set correctly and the directory exists
|
|
113
113
|
- Verify `node` is available in the hook's PATH
|
|
114
|
-
- Test the script manually: `echo '{"cwd":"/tmp","transcript_path":"/tmp/test","session_id":"abc123"}' | VAULT_PATH="/path/to/vault" ./hooks/stop-sweep.
|
|
114
|
+
- Test the script manually: `echo '{"cwd":"/tmp","transcript_path":"/tmp/test","session_id":"abc123"}' | VAULT_PATH="/path/to/vault" node ./hooks/stop-sweep.js`
|
|
115
115
|
|
|
116
116
|
### Background process not running
|
|
117
117
|
|
package/hooks/capture-handler.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# PostToolUse hook: explicit PKM capture via vault_capture tool
|
|
3
|
-
# Runs async after vault_capture returns. Spawns claude -p with
|
|
4
|
-
# to create a properly structured vault note.
|
|
3
|
+
# Runs async after vault_capture returns. Spawns claude -p with Opus
|
|
4
|
+
# to create a properly structured vault note with graph links.
|
|
5
5
|
|
|
6
6
|
set -euo pipefail
|
|
7
7
|
|
|
@@ -37,7 +37,11 @@ fi
|
|
|
37
37
|
|
|
38
38
|
# MCP config for obsidian-pkm server
|
|
39
39
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd -P)
|
|
40
|
-
MCP_CONFIG=$(node -e "
|
|
40
|
+
MCP_CONFIG=$(node -e "
|
|
41
|
+
const env = { VAULT_PATH: process.argv[2] };
|
|
42
|
+
if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
|
43
|
+
console.log(JSON.stringify({ mcpServers: { 'obsidian-pkm': { command: 'node', args: [process.argv[1]], env } } }));
|
|
44
|
+
" "$SCRIPT_DIR/../index.js" "${VAULT_PATH:-$HOME/Documents/PKM}")
|
|
41
45
|
|
|
42
46
|
# Build prompt via Node.js to avoid shell injection from user content
|
|
43
47
|
PROMPT_FILE=$(mktemp)
|
|
@@ -71,11 +75,15 @@ const prompt = \`You are a PKM note creation agent. Your job is NOT done until t
|
|
|
71
75
|
|
|
72
76
|
4. Read the note one final time to confirm no placeholder text remains.
|
|
73
77
|
|
|
74
|
-
|
|
78
|
+
5. Use vault_suggest_links with the note's content to find related notes. If the tool is available, add [[wikilinks]] to 2-3 of the most relevant suggestions in the note body using vault_edit.
|
|
79
|
+
|
|
80
|
+
6. Use vault_search to verify no very similar note already exists. If a near-duplicate is found, consider appending to the existing note instead of creating a new one.
|
|
81
|
+
|
|
82
|
+
CRITICAL: If you stop after step 1 or 2, you have FAILED. The note will contain useless placeholder text like 'Brief description of the technology, tool, or concept.' which is worse than no note at all. You MUST reach step 6.\`;
|
|
75
83
|
require('fs').writeFileSync(process.argv[2], prompt);
|
|
76
84
|
" "$TOOL_INPUT" "$PROMPT_FILE"
|
|
77
85
|
|
|
78
86
|
# Spawn claude -p in background with logging
|
|
79
|
-
nohup claude -p --model
|
|
87
|
+
nohup claude -p --model opus --mcp-config "$MCP_CONFIG" --max-turns 30 --allowedTools "mcp__obsidian-pkm__vault_read mcp__obsidian-pkm__vault_peek mcp__obsidian-pkm__vault_write mcp__obsidian-pkm__vault_append mcp__obsidian-pkm__vault_edit mcp__obsidian-pkm__vault_update_frontmatter mcp__obsidian-pkm__vault_search mcp__obsidian-pkm__vault_semantic_search mcp__obsidian-pkm__vault_suggest_links mcp__obsidian-pkm__vault_list mcp__obsidian-pkm__vault_recent mcp__obsidian-pkm__vault_links mcp__obsidian-pkm__vault_neighborhood mcp__obsidian-pkm__vault_query mcp__obsidian-pkm__vault_tags mcp__obsidian-pkm__vault_activity mcp__obsidian-pkm__vault_move" < "$PROMPT_FILE" >> "$LOG_DIR/capture-$(date +%Y%m%d-%H%M%S).log" 2>&1 &
|
|
80
88
|
|
|
81
89
|
exit 0
|
package/hooks/load-context.js
CHANGED
|
@@ -1,7 +1,62 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract YAML frontmatter as simple key-value pairs.
|
|
6
|
+
* Lightweight parser — only handles scalar values (sufficient for status/priority).
|
|
7
|
+
*/
|
|
8
|
+
function extractFrontmatter(content) {
|
|
9
|
+
if (!content.startsWith("---")) return null;
|
|
10
|
+
const endIndex = content.indexOf("\n---", 3);
|
|
11
|
+
if (endIndex === -1) return null;
|
|
12
|
+
const yamlContent = content.slice(4, endIndex);
|
|
13
|
+
const result = {};
|
|
14
|
+
for (const line of yamlContent.split("\n")) {
|
|
15
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
16
|
+
if (match) result[match[1]] = match[2].trim();
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseHeadingLevel(line) {
|
|
22
|
+
const match = line.match(/^(#{1,6})\s/);
|
|
23
|
+
return match ? match[1].length : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract the last N sections at a given heading level from markdown content.
|
|
28
|
+
*/
|
|
29
|
+
function extractTailSections(content, n, level) {
|
|
30
|
+
let frontmatter = "";
|
|
31
|
+
let body = content;
|
|
32
|
+
if (content.startsWith("---")) {
|
|
33
|
+
const endIndex = content.indexOf("\n---", 3);
|
|
34
|
+
if (endIndex !== -1) {
|
|
35
|
+
frontmatter = content.slice(0, endIndex + 4);
|
|
36
|
+
body = content.slice(endIndex + 4);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const lines = body.split("\n");
|
|
41
|
+
const headingPositions = [];
|
|
42
|
+
let offset = 0;
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (parseHeadingLevel(line) === level) {
|
|
45
|
+
headingPositions.push(offset);
|
|
46
|
+
}
|
|
47
|
+
offset += line.length + 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (headingPositions.length === 0) {
|
|
51
|
+
return content;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const startIdx = Math.max(0, headingPositions.length - n);
|
|
55
|
+
const sliceStart = headingPositions[startIdx];
|
|
56
|
+
const tail = body.slice(sliceStart);
|
|
57
|
+
|
|
58
|
+
return frontmatter + (frontmatter && !frontmatter.endsWith("\n") ? "\n" : "") + tail;
|
|
59
|
+
}
|
|
5
60
|
|
|
6
61
|
export async function loadProjectContext(vaultPath, projectPath) {
|
|
7
62
|
const projectName = path.basename(projectPath);
|
package/hooks/stop-sweep.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { appendFileSync,
|
|
4
|
+
import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
|
|
5
5
|
import { access } from "node:fs/promises";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -147,6 +147,7 @@ If nothing is PKM-worthy, do nothing. Doing nothing is the correct default.`;
|
|
|
147
147
|
// Ensure log directory exists
|
|
148
148
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
149
149
|
const logFile = join(LOG_DIR, `sweep-${new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)}.log`);
|
|
150
|
+
const fd = openSync(logFile, "a");
|
|
150
151
|
|
|
151
152
|
const child = spawn("claude", [
|
|
152
153
|
"-p",
|
|
@@ -156,17 +157,18 @@ If nothing is PKM-worthy, do nothing. Doing nothing is the correct default.`;
|
|
|
156
157
|
"--allowedTools", allowedTools,
|
|
157
158
|
], {
|
|
158
159
|
detached: true,
|
|
159
|
-
stdio: ["pipe",
|
|
160
|
+
stdio: ["pipe", fd, fd],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
child.on("error", (err) => {
|
|
164
|
+
logError(`spawn failed: ${err.message}`);
|
|
160
165
|
});
|
|
161
166
|
|
|
162
167
|
// Pipe prompt to stdin
|
|
163
168
|
child.stdin.write(prompt);
|
|
164
169
|
child.stdin.end();
|
|
165
170
|
|
|
166
|
-
|
|
167
|
-
const logStream = createWriteStream(logFile, { flags: "a" });
|
|
168
|
-
child.stdout.pipe(logStream);
|
|
169
|
-
child.stderr.pipe(logStream);
|
|
171
|
+
closeSync(fd);
|
|
170
172
|
|
|
171
173
|
// Detach — let the background process run independently
|
|
172
174
|
child.unref();
|
package/package.json
CHANGED
package/hooks/stop-sweep.sh
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Stop hook: passive PKM capture sweep
|
|
3
|
-
# Runs async after each Claude response. Spawns claude -p with Haiku
|
|
4
|
-
# to analyze the transcript and capture decisions/tasks/findings.
|
|
5
|
-
|
|
6
|
-
set -euo pipefail
|
|
7
|
-
|
|
8
|
-
LOG_DIR="${VAULT_PATH:?}/.obsidian/hook-logs"
|
|
9
|
-
mkdir -p "$LOG_DIR"
|
|
10
|
-
trap 'echo "stop-sweep: failed at line $LINENO" >> "$LOG_DIR/sweep-errors.log"' ERR
|
|
11
|
-
|
|
12
|
-
# Read hook input from stdin
|
|
13
|
-
INPUT=$(cat)
|
|
14
|
-
|
|
15
|
-
eval "$(echo "$INPUT" | node -e "
|
|
16
|
-
let b='';
|
|
17
|
-
process.stdin.on('data',c=>b+=c);
|
|
18
|
-
process.stdin.on('end',()=>{
|
|
19
|
-
const j=JSON.parse(b);
|
|
20
|
-
console.log('TRANSCRIPT_PATH='+JSON.stringify(j.transcript_path||''));
|
|
21
|
-
console.log('SESSION_ID='+JSON.stringify(j.session_id||''));
|
|
22
|
-
console.log('CWD='+JSON.stringify(j.cwd||''));
|
|
23
|
-
})
|
|
24
|
-
")"
|
|
25
|
-
|
|
26
|
-
# Skip if no transcript
|
|
27
|
-
if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
|
|
28
|
-
echo "stop-sweep: skipping - transcript_path empty or missing ('$TRANSCRIPT_PATH')" >&2
|
|
29
|
-
exit 0
|
|
30
|
-
fi
|
|
31
|
-
|
|
32
|
-
# MCP config for obsidian-pkm server
|
|
33
|
-
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd -P)
|
|
34
|
-
MCP_CONFIG=$(node -e "console.log(JSON.stringify({mcpServers:{'obsidian-pkm':{command:'node',args:[process.argv[1]],env:{VAULT_PATH:process.argv[2]}}}}))" "$SCRIPT_DIR/../index.js" "${VAULT_PATH:-$HOME/Documents/PKM}")
|
|
35
|
-
|
|
36
|
-
PROJECT_NAME=$(basename "$CWD")
|
|
37
|
-
SESSION_SHORT=$(echo "$SESSION_ID" | cut -c1-8)
|
|
38
|
-
TODAY=$(date +%Y-%m-%d)
|
|
39
|
-
NOW=$(date +%H:%M)
|
|
40
|
-
|
|
41
|
-
# Build the prompt
|
|
42
|
-
PROMPT="You are a PKM passive capture agent. Your job is to identify decisions, task changes, and research findings from the most recent conversation exchange and append them to the vault staging inbox.
|
|
43
|
-
|
|
44
|
-
## Transcript
|
|
45
|
-
|
|
46
|
-
Read the file at: $TRANSCRIPT_PATH
|
|
47
|
-
|
|
48
|
-
Find the LAST user message and LAST assistant message at the end of the file — these are the current exchange. One exchange = one contiguous user message + one contiguous assistant response (the last complete turn pair). All prior messages are historical context only. Do NOT capture anything from prior messages.
|
|
49
|
-
|
|
50
|
-
## What to Capture
|
|
51
|
-
|
|
52
|
-
1. **Decisions**: Technical or architectural choices that were AGREED upon (not proposed, not still being discussed)
|
|
53
|
-
2. **Task changes**: New tasks identified, tasks completed, priority changes, blockers discovered
|
|
54
|
-
3. **Research findings**: Patterns discovered, library behaviors documented, gotchas found
|
|
55
|
-
|
|
56
|
-
## Noise Suppression
|
|
57
|
-
|
|
58
|
-
Be conservative. Skip: trivial exchanges, clarification Q&A that hasn't resolved, implementation details obvious from code, anything restating existing project context. When in doubt, don't capture.
|
|
59
|
-
|
|
60
|
-
## Deduplication
|
|
61
|
-
|
|
62
|
-
If the assistant message in the current exchange contains a vault_capture tool call, the content of that capture is already being handled by the explicit capture agent. Do NOT also capture it in the staging inbox — skip it to avoid semantic duplication.
|
|
63
|
-
|
|
64
|
-
## Output
|
|
65
|
-
|
|
66
|
-
If you find PKM-worthy content, use vault_append to add entries to 00-Inbox/captures-${TODAY}.md. If the file doesn't exist, create it first with vault_write (template: fleeting-note, tags: [capture, auto]). Each entry format:
|
|
67
|
-
|
|
68
|
-
## ${NOW} — {Category}: {Title}
|
|
69
|
-
|
|
70
|
-
{1-3 sentence description}
|
|
71
|
-
|
|
72
|
-
**Source:** ${PROJECT_NAME}, session ${SESSION_SHORT}
|
|
73
|
-
|
|
74
|
-
---
|
|
75
|
-
|
|
76
|
-
If nothing is PKM-worthy, do nothing."
|
|
77
|
-
|
|
78
|
-
# Spawn claude -p in background with logging
|
|
79
|
-
echo "$PROMPT" | nohup claude -p --model haiku --mcp-config "$MCP_CONFIG" --max-turns 5 --allowedTools "mcp__obsidian-pkm__vault_write mcp__obsidian-pkm__vault_append mcp__obsidian-pkm__vault_read" >> "$LOG_DIR/sweep-$(date +%Y%m%d-%H%M%S).log" 2>&1 &
|
|
80
|
-
|
|
81
|
-
exit 0
|