pkm-mcp-server 1.4.0 → 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 +10 -0
- package/hooks/README.md +9 -9
- package/hooks/capture-handler.sh +13 -5
- package/hooks/load-context.js +57 -2
- package/hooks/resolve-project.js +8 -2
- package/hooks/stop-sweep.js +180 -0
- package/package.json +1 -1
- package/hooks/stop-sweep.sh +0 -81
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@ 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
|
+
|
|
14
|
+
## [1.4.1] - 2026-03-21
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
9
19
|
## [1.4.0] - 2026-03-21
|
|
10
20
|
|
|
11
21
|
### Added
|
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/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,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { appendFileSync, closeSync, mkdirSync, openSync } 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
|
+
const fd = openSync(logFile, "a");
|
|
151
|
+
|
|
152
|
+
const child = spawn("claude", [
|
|
153
|
+
"-p",
|
|
154
|
+
"--model", "sonnet",
|
|
155
|
+
"--mcp-config", mcpConfig,
|
|
156
|
+
"--max-turns", "15",
|
|
157
|
+
"--allowedTools", allowedTools,
|
|
158
|
+
], {
|
|
159
|
+
detached: true,
|
|
160
|
+
stdio: ["pipe", fd, fd],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
child.on("error", (err) => {
|
|
164
|
+
logError(`spawn failed: ${err.message}`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Pipe prompt to stdin
|
|
168
|
+
child.stdin.write(prompt);
|
|
169
|
+
child.stdin.end();
|
|
170
|
+
|
|
171
|
+
closeSync(fd);
|
|
172
|
+
|
|
173
|
+
// Detach — let the background process run independently
|
|
174
|
+
child.unref();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().catch((err) => {
|
|
178
|
+
logError(`uncaught: ${err.message}`);
|
|
179
|
+
process.exit(0);
|
|
180
|
+
});
|
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
|