pathnote 0.1.0 → 0.2.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/README.md +29 -18
- package/bin/pathnote.js +19 -13
- package/package.json +4 -2
- package/src/documents.js +98 -0
- package/src/history.js +20 -8
- package/src/hub.js +31 -8
- package/src/mcp.js +57 -35
- package/src/workspace.js +244 -0
- package/viewer/index.html +169 -80
- package/src/vault.js +0 -109
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Pathnote
|
|
2
2
|
|
|
3
|
-
See which
|
|
3
|
+
See which files your AI searched, read and wrote, live on a map of your folders.
|
|
4
4
|
|
|
5
|
-
Pathnote is an MCP server for
|
|
5
|
+
Pathnote is an MCP server for the folders you choose (notes, an Obsidian vault, project folders) plus a local viewer page. The viewer draws the folders as a tree, with `[[wikilinks]]` between markdown notes. While Claude, Cursor or another MCP client works, it draws the path on top: the files it searched, the folders and files it opened in order, and the files it wrote, linked to the files they came from. Every file the AI creates, edits or moves can be undone from the viewer.
|
|
6
6
|
|
|
7
7
|
Free, MIT licensed. Runs entirely on your computer; nothing is sent anywhere by Pathnote.
|
|
8
8
|
|
|
@@ -18,59 +18,70 @@ npx -y pathnote --sample ~/pathnote-sample
|
|
|
18
18
|
|
|
19
19
|
### Claude Desktop
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Quit Claude Desktop first (⌘Q). It rewrites its config file while running, so edits made with it open can be lost. Then add Pathnote to `mcpServers` in `~/Library/Application Support/Claude/claude_desktop_config.json`. List as many folders as you like:
|
|
22
22
|
|
|
23
23
|
```json
|
|
24
24
|
{
|
|
25
25
|
"mcpServers": {
|
|
26
26
|
"pathnote": {
|
|
27
27
|
"command": "npx",
|
|
28
|
-
"args": ["-y", "pathnote", "/Users/you/pathnote-sample"]
|
|
28
|
+
"args": ["-y", "pathnote", "/Users/you/pathnote-sample", "/Users/you/Projects"]
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Open Claude Desktop again and ask Claude something like the line below. The map opens in your browser as soon as Claude starts looking (http://127.0.0.1:4747).
|
|
35
35
|
|
|
36
36
|
> Go through the meeting notes and customer interviews and write next quarter's priorities as a new note.
|
|
37
37
|
|
|
38
38
|
### Claude Code
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
claude mcp add pathnote -- npx -y pathnote ~/pathnote-sample
|
|
41
|
+
claude mcp add pathnote -- npx -y pathnote ~/pathnote-sample ~/Projects
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
### Cursor and other MCP clients
|
|
45
45
|
|
|
46
|
-
Use the same command: `npx -y pathnote <
|
|
46
|
+
Use the same command: `npx -y pathnote <folder> [<folder> ...]`.
|
|
47
47
|
|
|
48
48
|
## The viewer
|
|
49
49
|
|
|
50
50
|
- http://127.0.0.1:4747 (the next free port if 4747 is taken; the address is printed to the client's MCP log).
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
51
|
+
- It opens in your browser by itself the first time the AI uses Pathnote, unless a viewer page is already open. Add `--no-open` (or set `PATHNOTE_NO_OPEN=1`) to turn that off.
|
|
52
|
+
- Drag to move, scroll to zoom. File names appear as you zoom in.
|
|
53
|
+
- Several AI sessions on the same folders all report to one viewer.
|
|
54
|
+
- To keep the viewer open while clients come and go: `npx pathnote <folders> --viewer`.
|
|
55
|
+
- **Undo** puts a file back the way it was before the AI changed it. If you edited it yourself afterwards, Pathnote leaves it alone and says so.
|
|
56
|
+
- **Clear path** clears the drawing, not your files.
|
|
55
57
|
|
|
56
58
|
## Tools the AI gets
|
|
57
59
|
|
|
58
60
|
| Tool | What it does |
|
|
59
61
|
|---|---|
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
62
|
+
| `list_files` | The connected folders, or what is inside one folder |
|
|
63
|
+
| `search_files` | Text files and documents matching some words, with a snippet each |
|
|
64
|
+
| `read_file` | A text file as is (up to 2 MB); PDF, Word, PowerPoint, Excel and Hangul (.hwpx) files as their text; pictures (PNG, JPEG, GIF, WebP, HEIC) as images the AI can see (up to 50 MB) |
|
|
65
|
+
| `write_file` | Create a text file or replace its text (undoable). Documents and pictures are never written |
|
|
66
|
+
| `edit_file` | Replace one exact piece of text in a file (undoable) |
|
|
67
|
+
| `move_file` | Move or rename a file or folder of any type, creating missing folders; never overwrites (undoable) |
|
|
65
68
|
|
|
66
69
|
Each tool takes an optional `reason`, a short sentence the viewer shows next to the step.
|
|
67
70
|
|
|
68
71
|
## What Pathnote touches
|
|
69
72
|
|
|
70
|
-
- Only
|
|
71
|
-
-
|
|
73
|
+
- Only files inside the folders you list. Anything outside them is refused, including through symlinks.
|
|
74
|
+
- Old Hangul `.hwp` files and other binary formats can't be read. Scanned PDFs without a text layer come back empty.
|
|
75
|
+
- Skipped: hidden files and folders, `.git`, `node_modules`, build output, and files that usually hold secrets (`.env`, `*.pem`, `*.p8`, `*.key`, SSH keys, `credentials.json`, `token.json`, `client_secret*.json`, folders named `secrets`). The AI can't read or write them, and the viewer doesn't show them.
|
|
76
|
+
- Whatever the AI reads, including document text and pictures, is sent to the AI service you use, like anything else you share with it.
|
|
77
|
+
- The AI can create and change any other text file in those folders. Connect only folders you are comfortable letting it edit.
|
|
78
|
+
- The text of each file before the AI changed it is kept in `~/.pathnote/` so it can be undone. Delete that folder to clear the history.
|
|
72
79
|
- The viewer only listens on 127.0.0.1 and refuses requests from other websites.
|
|
73
80
|
|
|
81
|
+
## macOS folder permissions
|
|
82
|
+
|
|
83
|
+
Desktop, Documents, Downloads and iCloud Drive are protected by macOS. The first time Pathnote reads one, macOS asks whether the app that runs Pathnote (Claude, Terminal, ...) may access it. Until you allow it, the viewer shows that folder as empty and says so. You can change this later in System Settings → Privacy & Security → Files and Folders.
|
|
84
|
+
|
|
74
85
|
## Development
|
|
75
86
|
|
|
76
87
|
```bash
|
package/bin/pathnote.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// pathnote <
|
|
2
|
+
// pathnote <folder> [<folder> ...] [--port 4747] [--viewer] [--no-open]
|
|
3
3
|
// pathnote --sample <new folder> copies the sample notes there, to try Pathnote safely
|
|
4
4
|
// Started by an MCP client: MCP over stdin/stdout, plus the viewer at http://127.0.0.1:4747
|
|
5
5
|
// --viewer: only the viewer, for keeping it open while MCP clients come and go
|
|
6
|
+
// --no-open (or PATHNOTE_NO_OPEN=1): don't open the viewer in the browser when the AI starts working
|
|
6
7
|
// stdout belongs to MCP, so every message for people goes to stderr.
|
|
7
8
|
import fs from 'node:fs';
|
|
8
9
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
-
import {
|
|
10
|
+
import { Workspace } from '../src/workspace.js';
|
|
10
11
|
import { History } from '../src/history.js';
|
|
11
|
-
import { Hub } from '../src/hub.js';
|
|
12
|
+
import { Hub, openBrowser } from '../src/hub.js';
|
|
12
13
|
import { createServer } from '../src/mcp.js';
|
|
13
14
|
|
|
14
15
|
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url)));
|
|
15
16
|
const args = process.argv.slice(2);
|
|
16
17
|
const flag = name => args.includes(name);
|
|
17
18
|
const opt = name => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
18
|
-
const
|
|
19
|
+
const folders = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--port');
|
|
20
|
+
const folder = folders[0];
|
|
19
21
|
|
|
20
22
|
if (flag('--sample')) {
|
|
21
23
|
if (!folder) { console.error('Usage: pathnote --sample <new folder>'); process.exit(1); }
|
|
@@ -25,22 +27,26 @@ if (flag('--sample')) {
|
|
|
25
27
|
process.exit(0);
|
|
26
28
|
}
|
|
27
29
|
if (!folder || flag('--help')) {
|
|
28
|
-
console.error(`Pathnote ${pkg.version}\n\nUsage: pathnote <
|
|
30
|
+
console.error(`Pathnote ${pkg.version}\n\nUsage: pathnote <folder> [<folder> ...] [--port 4747] [--viewer] [--no-open]\n pathnote --sample <new folder>`);
|
|
29
31
|
process.exit(folder ? 0 : 1);
|
|
30
32
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
for (const f of folders) {
|
|
34
|
+
if (!fs.existsSync(f) || !fs.statSync(f).isDirectory()) {
|
|
35
|
+
console.error(`Pathnote: "${f}" is not a folder.`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
const
|
|
37
|
-
const history = new History(
|
|
38
|
-
const
|
|
40
|
+
const ws = new Workspace(folders);
|
|
41
|
+
const history = new History(ws);
|
|
42
|
+
const noOpen = flag('--no-open') || process.env.PATHNOTE_NO_OPEN === '1';
|
|
43
|
+
const hub = new Hub(ws, history, Number(opt('--port')) || 4747, { open: noOpen ? null : openBrowser });
|
|
39
44
|
const url = await hub.start();
|
|
40
|
-
console.error(`Pathnote ${pkg.version}:
|
|
45
|
+
console.error(`Pathnote ${pkg.version}: ${ws.roots.join(', ')}\nViewer: ${url}`);
|
|
46
|
+
ws.warm().catch(() => {});
|
|
41
47
|
|
|
42
48
|
if (!flag('--viewer')) {
|
|
43
|
-
await createServer({
|
|
49
|
+
await createServer({ ws, history, hub, version: pkg.version }).connect(new StdioServerTransport());
|
|
44
50
|
// The viewer server would keep the process alive after the MCP client quits
|
|
45
51
|
process.stdin.on('close', () => process.exit(0));
|
|
46
52
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pathnote",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "See which
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "See which files your AI searched, read and wrote, live on a map of your folders. MCP server + local viewer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
7
7
|
"model-context-protocol",
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
37
|
+
"fflate": "0.8.3",
|
|
38
|
+
"unpdf": "1.9.1",
|
|
37
39
|
"zod": "^4.6.7"
|
|
38
40
|
}
|
|
39
41
|
}
|
package/src/documents.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Reads files that aren't plain text: text out of PDF, Word, PowerPoint, Excel and Hangul (.hwpx)
|
|
2
|
+
// files, and pictures as images the AI can look at. Read-only: Pathnote never writes these.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { unzipSync, strFromU8 } from 'fflate';
|
|
9
|
+
import { getDocumentProxy, extractText } from 'unpdf';
|
|
10
|
+
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const DOCS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.hwpx']);
|
|
13
|
+
const IMAGES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic' };
|
|
14
|
+
export const MAX_DOC = 50 * 1024 * 1024;
|
|
15
|
+
const MAX_TEXT = 200_000;
|
|
16
|
+
// Claude accepts images up to about 5 MB once base64-encoded
|
|
17
|
+
const MAX_IMAGE = 3.5 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
export const isDocument = p => DOCS.has(path.extname(p).toLowerCase());
|
|
20
|
+
export const isImage = p => path.extname(p).toLowerCase() in IMAGES;
|
|
21
|
+
|
|
22
|
+
const decode = s => s
|
|
23
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
24
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(+d))
|
|
25
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
26
|
+
|
|
27
|
+
// XML -> text, with a line break wherever the format ends a paragraph
|
|
28
|
+
const xmlText = (xml, paragraph) => decode(xml
|
|
29
|
+
.replace(new RegExp(`</${paragraph}>`, 'g'), '\n')
|
|
30
|
+
.replace(/<(w:tab|a:tab|hp:tab)\b[^>]*\/>/g, '\t')
|
|
31
|
+
.replace(/<[^>]+>/g, ''))
|
|
32
|
+
.replace(/\n{3,}/g, '\n\n').trim();
|
|
33
|
+
|
|
34
|
+
// "ppt/slides/slide10.xml" sorts after "slide9.xml"
|
|
35
|
+
const byNumber = (a, b) => (+a.match(/(\d+)\.xml$/)?.[1] || 0) - (+b.match(/(\d+)\.xml$/)?.[1] || 0);
|
|
36
|
+
|
|
37
|
+
function unzip(buf, keep) {
|
|
38
|
+
const files = unzipSync(new Uint8Array(buf), { filter: f => keep(f.name) });
|
|
39
|
+
return Object.fromEntries(Object.entries(files).map(([k, v]) => [k, strFromU8(v)]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function xlsx(buf) {
|
|
43
|
+
const z = unzip(buf, n => n === 'xl/sharedStrings.xml' || n === 'xl/workbook.xml' || /^xl\/worksheets\/sheet\d+\.xml$/.test(n));
|
|
44
|
+
const shared = [...(z['xl/sharedStrings.xml'] || '').matchAll(/<si>([\s\S]*?)<\/si>/g)].map(m => decode(m[1].replace(/<[^>]+>/g, '')));
|
|
45
|
+
const names = [...(z['xl/workbook.xml'] || '').matchAll(/<sheet [^>]*name="([^"]*)"/g)].map(m => decode(m[1]));
|
|
46
|
+
return Object.keys(z).filter(k => k.startsWith('xl/worksheets/')).sort(byNumber).map((k, i) => {
|
|
47
|
+
const rows = [...z[k].matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)].map(r =>
|
|
48
|
+
[...r[1].matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(([, attrs, body = '']) => {
|
|
49
|
+
const v = body.match(/<v>([\s\S]*?)<\/v>/)?.[1];
|
|
50
|
+
if (/t="s"/.test(attrs)) return shared[+v] ?? '';
|
|
51
|
+
if (/t="inlineStr"/.test(attrs)) return decode(body.replace(/<[^>]+>/g, ''));
|
|
52
|
+
return v === undefined ? '' : decode(v);
|
|
53
|
+
}).join('\t'));
|
|
54
|
+
return `--- sheet: ${names[i] ?? i + 1} ---\n${rows.join('\n')}`;
|
|
55
|
+
}).join('\n\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Text of a document. `buf` is the file's bytes.
|
|
59
|
+
export async function documentText(file, buf) {
|
|
60
|
+
const ext = path.extname(file).toLowerCase();
|
|
61
|
+
let text;
|
|
62
|
+
if (ext === '.pdf') {
|
|
63
|
+
const pdf = await getDocumentProxy(new Uint8Array(buf));
|
|
64
|
+
const { text: pages } = await extractText(pdf, { mergePages: false });
|
|
65
|
+
text = pages.map((t, i) => `--- page ${i + 1} ---\n${t.trim()}`).join('\n\n');
|
|
66
|
+
} else if (ext === '.docx') {
|
|
67
|
+
text = xmlText(unzip(buf, n => n === 'word/document.xml')['word/document.xml'] || '', 'w:p');
|
|
68
|
+
} else if (ext === '.pptx') {
|
|
69
|
+
const z = unzip(buf, n => /^ppt\/slides\/slide\d+\.xml$/.test(n));
|
|
70
|
+
text = Object.keys(z).sort(byNumber).map((k, i) => `--- slide ${i + 1} ---\n${xmlText(z[k], 'a:p')}`).join('\n\n');
|
|
71
|
+
} else if (ext === '.xlsx') {
|
|
72
|
+
text = xlsx(buf);
|
|
73
|
+
} else if (ext === '.hwpx') {
|
|
74
|
+
const z = unzip(buf, n => /^Contents\/section\d+\.xml$/.test(n));
|
|
75
|
+
text = Object.keys(z).sort(byNumber).map(k => xmlText(z[k], 'hp:p')).join('\n\n');
|
|
76
|
+
} else {
|
|
77
|
+
throw new Error(`Pathnote can't read ${ext} files.`);
|
|
78
|
+
}
|
|
79
|
+
if (!text.trim()) return '(No text found. It may be a scanned document made of images.)';
|
|
80
|
+
return text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) + `\n\n(cut off at ${MAX_TEXT} characters)` : text;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// An image the AI can look at. HEIC (iPhone photos) and large pictures go through macOS `sips`
|
|
84
|
+
// to become a JPEG small enough to send.
|
|
85
|
+
export async function imageContent(file, buf) {
|
|
86
|
+
const ext = path.extname(file).toLowerCase();
|
|
87
|
+
if (ext !== '.heic' && buf.length <= MAX_IMAGE) return { data: buf.toString('base64'), mimeType: IMAGES[ext] };
|
|
88
|
+
if (process.platform !== 'darwin') throw new Error(`"${file}" is too large to send as an image (over 3.5 MB).`);
|
|
89
|
+
const out = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'pathnote-')), 'image.jpg');
|
|
90
|
+
try {
|
|
91
|
+
await run('sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '80', '-Z', '1600', file, '--out', out]);
|
|
92
|
+
const small = await fs.readFile(out);
|
|
93
|
+
if (small.length > MAX_IMAGE) throw new Error(`"${file}" is still too large after shrinking.`);
|
|
94
|
+
return { data: small.toString('base64'), mimeType: 'image/jpeg' };
|
|
95
|
+
} finally {
|
|
96
|
+
await fs.rm(path.dirname(out), { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/history.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Keeps the text a note had before the AI changed it, so each change can be undone.
|
|
2
|
-
// Stored outside the
|
|
2
|
+
// Stored outside the connected folders (~/.pathnote/<id>/history.jsonl) to keep them clean.
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
|
|
8
8
|
export class History {
|
|
9
|
-
constructor(
|
|
10
|
-
this.
|
|
11
|
-
const id = crypto.createHash('sha1').update(
|
|
9
|
+
constructor(ws) {
|
|
10
|
+
this.ws = ws;
|
|
11
|
+
const id = crypto.createHash('sha1').update(ws.id).digest('hex').slice(0, 12);
|
|
12
12
|
this.file = path.join(os.homedir(), '.pathnote', id, 'history.jsonl');
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -26,13 +26,19 @@ export class History {
|
|
|
26
26
|
await fs.appendFile(this.file, JSON.stringify(entry) + '\n');
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Call before changing a
|
|
29
|
+
// Call before changing a file. `before` is null for a file that didn't exist.
|
|
30
30
|
async record(rel, before, after, kind) {
|
|
31
31
|
const entry = { id: crypto.randomUUID(), t: Date.now(), path: rel, kind, before, after };
|
|
32
32
|
await this.append(entry);
|
|
33
33
|
return entry;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
async recordMove(from, to) {
|
|
37
|
+
const entry = { id: crypto.randomUUID(), t: Date.now(), path: to, kind: 'move', from, to };
|
|
38
|
+
await this.append(entry);
|
|
39
|
+
return entry;
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
// Put the note back the way it was before change `id`. Refuses if someone edited it since,
|
|
37
43
|
// so a user's own later edits are never thrown away.
|
|
38
44
|
async undo(id) {
|
|
@@ -40,10 +46,16 @@ export class History {
|
|
|
40
46
|
const entry = all.find(e => e.id === id);
|
|
41
47
|
if (!entry) throw new Error('No such change.');
|
|
42
48
|
if (all.some(e => e.undoes === id)) throw new Error('Already undone.');
|
|
43
|
-
|
|
49
|
+
if (entry.kind === 'move') {
|
|
50
|
+
// put it back where it was, as long as nothing has taken either place since
|
|
51
|
+
await this.ws.move(entry.to, entry.from);
|
|
52
|
+
await this.append({ id: crypto.randomUUID(), t: Date.now(), path: entry.from, kind: 'undo', undoes: id });
|
|
53
|
+
return entry;
|
|
54
|
+
}
|
|
55
|
+
const now = await this.ws.readOrNull(entry.path);
|
|
44
56
|
if (now !== entry.after) throw new Error(`"${entry.path}" changed after the AI wrote it, so it was left as is.`);
|
|
45
|
-
if (entry.before === null) await this.
|
|
46
|
-
else await this.
|
|
57
|
+
if (entry.before === null) await this.ws.remove(entry.path);
|
|
58
|
+
else await this.ws.write(entry.path, entry.before);
|
|
47
59
|
await this.append({ id: crypto.randomUUID(), t: Date.now(), path: entry.path, kind: 'undo', undoes: id });
|
|
48
60
|
return entry;
|
|
49
61
|
}
|
package/src/hub.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Local viewer server: serves the graph page and streams what the AI does to it.
|
|
2
|
-
// Claude Desktop may start several Pathnote processes; the first one for a
|
|
2
|
+
// Claude Desktop may start several Pathnote processes; the first one for a set of folders owns the
|
|
3
3
|
// viewer port and the others forward their events to it.
|
|
4
4
|
import http from 'node:http';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
5
6
|
import fs from 'node:fs/promises';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -10,8 +11,11 @@ const PAGE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'view
|
|
|
10
11
|
const KEEP = 500;
|
|
11
12
|
|
|
12
13
|
export class Hub {
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// open: called with the viewer URL the first time the AI does something while no viewer page is
|
|
15
|
+
// connected, so the map shows up without the user looking for it. null turns this off.
|
|
16
|
+
constructor(ws, history, port, { open = null } = {}) {
|
|
17
|
+
this.ws = ws;
|
|
18
|
+
this.open = open;
|
|
15
19
|
this.history = history;
|
|
16
20
|
this.port = port;
|
|
17
21
|
this.events = [];
|
|
@@ -21,11 +25,11 @@ export class Hub {
|
|
|
21
25
|
this.seq = 0;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
|
-
// Own a port for
|
|
28
|
+
// Own a port for these folders, or find the process that already does. Returns the viewer URL.
|
|
25
29
|
async start() {
|
|
26
30
|
for (let port = this.port; port < this.port + 10; port++) {
|
|
27
31
|
const other = await this.info(port);
|
|
28
|
-
if (other?.
|
|
32
|
+
if (other?.id === this.ws.id) { this.forwardTo = port; return `http://127.0.0.1:${port}`; }
|
|
29
33
|
if (other) continue;
|
|
30
34
|
try {
|
|
31
35
|
await this.listen(port);
|
|
@@ -81,6 +85,12 @@ export class Hub {
|
|
|
81
85
|
this.events.push(e);
|
|
82
86
|
if (this.events.length > KEEP) this.events.shift();
|
|
83
87
|
for (const res of this.clients) res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
88
|
+
// Once per process: a page left open from an earlier run reconnects by itself, so it counts
|
|
89
|
+
if (this.open && this.clients.size === 0) {
|
|
90
|
+
const open = this.open;
|
|
91
|
+
this.open = null;
|
|
92
|
+
open(`http://127.0.0.1:${this.port}`);
|
|
93
|
+
}
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
// Only pages served from this port may call the API, so other websites can't trigger undo.
|
|
@@ -98,10 +108,11 @@ export class Hub {
|
|
|
98
108
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
99
109
|
return res.end(await fs.readFile(PAGE));
|
|
100
110
|
}
|
|
101
|
-
if (req.method === 'GET' && url.pathname === '/api/info') return send(res, 200, { app: 'pathnote',
|
|
102
|
-
if (req.method === 'GET' && url.pathname === '/api/graph') return send(res, 200, await this.
|
|
111
|
+
if (req.method === 'GET' && url.pathname === '/api/info') return send(res, 200, { app: 'pathnote', id: this.ws.id, roots: this.ws.roots });
|
|
112
|
+
if (req.method === 'GET' && url.pathname === '/api/graph') return send(res, 200, await this.ws.graph());
|
|
103
113
|
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
104
114
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
115
|
+
res.flushHeaders(); // the page counts as connected before the first event
|
|
105
116
|
for (const e of this.events) res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
106
117
|
this.clients.add(res);
|
|
107
118
|
req.on('close', () => this.clients.delete(res));
|
|
@@ -115,7 +126,7 @@ export class Hub {
|
|
|
115
126
|
const { id } = await body(req);
|
|
116
127
|
try {
|
|
117
128
|
const entry = await this.history.undo(id);
|
|
118
|
-
this.broadcast({ t: Date.now(), type: 'undo', path: entry.path, changeId: id, created: entry.before === null });
|
|
129
|
+
this.broadcast({ t: Date.now(), type: 'undo', path: entry.kind === 'move' ? entry.to : entry.path, changeId: id, created: entry.before === null || entry.kind === 'move', back: entry.kind === 'move' ? entry.from : undefined });
|
|
119
130
|
return send(res, 200, { ok: true });
|
|
120
131
|
} catch (e) {
|
|
121
132
|
return send(res, 409, { error: e.message });
|
|
@@ -125,6 +136,18 @@ export class Hub {
|
|
|
125
136
|
}
|
|
126
137
|
}
|
|
127
138
|
|
|
139
|
+
// Opens a URL in the default browser. Errors are ignored: the URL is also in the client's MCP log.
|
|
140
|
+
export function openBrowser(url) {
|
|
141
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
142
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
143
|
+
: ['xdg-open', [url]];
|
|
144
|
+
try {
|
|
145
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
146
|
+
child.on('error', () => {});
|
|
147
|
+
child.unref();
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
|
|
128
151
|
function send(res, status, data) {
|
|
129
152
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
130
153
|
res.end(JSON.stringify(data));
|
package/src/mcp.js
CHANGED
|
@@ -6,71 +6,93 @@ const reason = z.string().optional().describe('One short sentence on why you are
|
|
|
6
6
|
const text = s => ({ content: [{ type: 'text', text: s }] });
|
|
7
7
|
const fail = e => ({ content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
|
|
8
8
|
|
|
9
|
-
export function createServer({
|
|
9
|
+
export function createServer({ ws, history, hub, version }) {
|
|
10
10
|
const server = new McpServer({ name: 'pathnote', version });
|
|
11
|
+
const where = `Connected folders: ${ws.roots.join(', ')}. Use full paths.`;
|
|
11
12
|
|
|
12
|
-
server.registerTool('
|
|
13
|
-
title: 'List
|
|
14
|
-
description:
|
|
15
|
-
inputSchema: {}
|
|
16
|
-
}, async () => {
|
|
17
|
-
try {
|
|
13
|
+
server.registerTool('list_files', {
|
|
14
|
+
title: 'List a folder',
|
|
15
|
+
description: `List what is inside a folder (folders end with "/"). Without a folder, lists the connected folders. ${where}`,
|
|
16
|
+
inputSchema: { folder: z.string().optional(), reason }
|
|
17
|
+
}, async ({ folder, reason }) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!folder) return text(ws.roots.join('\n'));
|
|
20
|
+
const abs = ws.resolve(folder);
|
|
21
|
+
const items = await ws.list(abs);
|
|
22
|
+
await hub.emit({ type: 'list', path: abs, reason });
|
|
23
|
+
return text(items.join('\n') || '(empty folder)');
|
|
24
|
+
} catch (e) { return fail(e); }
|
|
18
25
|
});
|
|
19
26
|
|
|
20
|
-
server.registerTool('
|
|
21
|
-
title: 'Search
|
|
22
|
-
description:
|
|
27
|
+
server.registerTool('search_files', {
|
|
28
|
+
title: 'Search files',
|
|
29
|
+
description: `Search text files and documents (PDF, Word, PowerPoint, Excel, Hangul) in the connected folders for words. Returns the best matching paths with a snippet each. ${where}`,
|
|
23
30
|
inputSchema: { query: z.string().describe('Words to look for'), reason }
|
|
24
31
|
}, async ({ query, reason }) => {
|
|
25
32
|
try {
|
|
26
|
-
const hits = await
|
|
33
|
+
const hits = await ws.search(query);
|
|
27
34
|
await hub.emit({ type: 'search', query, hits: hits.map(h => h.path), reason });
|
|
28
35
|
return text(hits.length ? hits.map(h => `${h.path}\n ${h.snippet}`).join('\n') : 'No matches.');
|
|
29
36
|
} catch (e) { return fail(e); }
|
|
30
37
|
});
|
|
31
38
|
|
|
32
|
-
server.registerTool('
|
|
33
|
-
title: 'Read a
|
|
34
|
-
description:
|
|
39
|
+
server.registerTool('read_file', {
|
|
40
|
+
title: 'Read a file',
|
|
41
|
+
description: `Read a file: text files (up to 2 MB) as they are, PDF, Word (.docx), PowerPoint (.pptx), Excel (.xlsx) and Hangul (.hwpx) as their text, and pictures (.png, .jpg, .gif, .webp, .heic) as images you can see. ${where}`,
|
|
35
42
|
inputSchema: { path: z.string(), reason }
|
|
36
43
|
}, async ({ path, reason }) => {
|
|
37
44
|
try {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
const abs = ws.resolve(path);
|
|
46
|
+
const file = await ws.open(abs);
|
|
47
|
+
await hub.emit({ type: 'read', path: abs, reason });
|
|
48
|
+
if (file.kind === 'image') return { content: [{ type: 'image', data: file.data, mimeType: file.mimeType }] };
|
|
49
|
+
return text(file.text);
|
|
41
50
|
} catch (e) { return fail(e); }
|
|
42
51
|
});
|
|
43
52
|
|
|
44
|
-
server.registerTool('
|
|
45
|
-
title: 'Write a
|
|
46
|
-
description:
|
|
53
|
+
server.registerTool('write_file', {
|
|
54
|
+
title: 'Write a file',
|
|
55
|
+
description: `Create a text file, or replace a text file's whole text (not documents or pictures). The user can undo this from the Pathnote viewer. In markdown notes, link the notes you used with [[Note name]]. ${where}`,
|
|
47
56
|
inputSchema: { path: z.string(), content: z.string(), reason }
|
|
48
57
|
}, async ({ path, content, reason }) => {
|
|
49
58
|
try {
|
|
50
|
-
const
|
|
51
|
-
const before = await
|
|
52
|
-
const change = await history.record(
|
|
53
|
-
await
|
|
54
|
-
await hub.emit({ type: 'write', path:
|
|
55
|
-
return text(`${before === null ? 'Created' : 'Replaced'} ${
|
|
59
|
+
const abs = ws.resolve(path);
|
|
60
|
+
const before = await ws.readOrNull(abs);
|
|
61
|
+
const change = await history.record(abs, before, content, before === null ? 'create' : 'write');
|
|
62
|
+
await ws.write(abs, content);
|
|
63
|
+
await hub.emit({ type: 'write', path: abs, created: before === null, changeId: change.id, reason });
|
|
64
|
+
return text(`${before === null ? 'Created' : 'Replaced'} ${abs}.`);
|
|
56
65
|
} catch (e) { return fail(e); }
|
|
57
66
|
});
|
|
58
67
|
|
|
59
|
-
server.registerTool('
|
|
60
|
-
title: 'Edit a
|
|
61
|
-
description:
|
|
68
|
+
server.registerTool('edit_file', {
|
|
69
|
+
title: 'Edit a file',
|
|
70
|
+
description: `Replace one exact piece of text in a file. \`find\` must appear exactly once. The user can undo this from the Pathnote viewer. ${where}`,
|
|
62
71
|
inputSchema: { path: z.string(), find: z.string().min(1), replace: z.string(), reason }
|
|
63
72
|
}, async ({ path, find, replace, reason }) => {
|
|
64
73
|
try {
|
|
65
|
-
const
|
|
66
|
-
const before = await
|
|
74
|
+
const abs = ws.resolve(path);
|
|
75
|
+
const before = await ws.read(abs);
|
|
67
76
|
const count = before.split(find).length - 1;
|
|
68
77
|
if (count !== 1) throw new Error(count ? `The text appears ${count} times; include more of it so it is unique.` : 'The text was not found.');
|
|
69
78
|
const after = before.replace(find, () => replace);
|
|
70
|
-
const change = await history.record(
|
|
71
|
-
await
|
|
72
|
-
await hub.emit({ type: 'edit', path:
|
|
73
|
-
return text(`Edited ${
|
|
79
|
+
const change = await history.record(abs, before, after, 'edit');
|
|
80
|
+
await ws.write(abs, after);
|
|
81
|
+
await hub.emit({ type: 'edit', path: abs, changeId: change.id, reason });
|
|
82
|
+
return text(`Edited ${abs}.`);
|
|
83
|
+
} catch (e) { return fail(e); }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
server.registerTool('move_file', {
|
|
87
|
+
title: 'Move a file or folder',
|
|
88
|
+
description: `Move or rename a file or folder (any type, including pictures and videos) to a new full path. Missing parent folders are created. Never overwrites an existing path. The user can undo this from the Pathnote viewer. ${where}`,
|
|
89
|
+
inputSchema: { from: z.string(), to: z.string(), reason }
|
|
90
|
+
}, async ({ from, to, reason }) => {
|
|
91
|
+
try {
|
|
92
|
+
const moved = await ws.move(from, to);
|
|
93
|
+
const change = await history.recordMove(moved.from, moved.to);
|
|
94
|
+
await hub.emit({ type: 'move', path: moved.to, from: moved.from, dir: moved.dir, changeId: change.id, reason });
|
|
95
|
+
return text(`Moved ${moved.from} to ${moved.to}.`);
|
|
74
96
|
} catch (e) { return fail(e); }
|
|
75
97
|
});
|
|
76
98
|
|