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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # Pathnote
2
2
 
3
- See which notes your AI searched, read and wrote, live on your note graph.
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 a folder of markdown notes (an Obsidian vault works as is) plus a local viewer page. While Claude, Cursor or another MCP client works in your notes, the viewer draws the path: search hits, the notes it read in order, and the notes it wrote, linked to the notes they came from. Every note the AI creates or edits can be undone from the viewer.
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
- Settings → Developer → Edit Config, and add Pathnote to `mcpServers`:
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
- Restart Claude Desktop, open http://127.0.0.1:4747 in your browser, and ask Claude something like:
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 <your notes folder>`.
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
- - Several AI sessions on the same folder all report to one viewer.
52
- - To keep the viewer open while clients come and go: `npx pathnote <folder> --viewer`.
53
- - **Undo** puts a note back the way it was before the AI changed it. If you edited the note yourself afterwards, Pathnote leaves it alone and says so.
54
- - **Clear path** clears the drawing, not your notes.
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
- | `list_notes` | Every note path in the folder |
61
- | `search_vault` | Notes matching some words, with a snippet each |
62
- | `read_note` | Full text of one note |
63
- | `write_note` | Create a note or replace its text (undoable) |
64
- | `edit_note` | Replace one exact piece of text in a note (undoable) |
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 `.md` files inside the folder you give it. Paths outside it, and `.obsidian`, `.git` and `.trash`, are refused.
71
- - The text of each note before the AI changed it is kept in `~/.pathnote/` so it can be undone. Delete that folder to clear the history.
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 <vault folder> [--port 4747] [--viewer]
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 { Vault } from '../src/vault.js';
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 folder = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--port');
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 <vault folder> [--port 4747] [--viewer]\n pathnote --sample <new folder>`);
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
- if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) {
32
- console.error(`Pathnote: "${folder}" is not a folder.`);
33
- process.exit(1);
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 vault = new Vault(folder);
37
- const history = new History(vault);
38
- const hub = new Hub(vault, history, Number(opt('--port')) || 4747);
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}: vault ${vault.root}\nViewer: ${url}`);
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({ vault, history, hub, version: pkg.version }).connect(new StdioServerTransport());
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.0",
4
- "description": "See which notes your AI searched, read and wrote, live on your note graph. MCP server + local viewer for a markdown folder.",
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
  }
@@ -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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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 vault (~/.pathnote/<vault id>/history.jsonl) to keep the vault clean.
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(vault) {
10
- this.vault = vault;
11
- const id = crypto.createHash('sha1').update(vault.root).digest('hex').slice(0, 12);
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 note. `before` is null for a note that didn't exist.
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
- const now = await this.vault.readOrNull(entry.path);
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.vault.remove(entry.path);
46
- else await this.vault.write(entry.path, entry.before);
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 vault owns the
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
- constructor(vault, history, port) {
14
- this.vault = vault;
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 this vault, or find the process that already does. Returns the viewer URL.
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?.vault === this.vault.root) { this.forwardTo = port; return `http://127.0.0.1:${port}`; }
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', vault: this.vault.root });
102
- if (req.method === 'GET' && url.pathname === '/api/graph') return send(res, 200, await this.vault.graph());
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({ vault, history, hub, version }) {
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('list_notes', {
13
- title: 'List notes',
14
- description: 'List every markdown note in the vault, as paths relative to the vault root.',
15
- inputSchema: {}
16
- }, async () => {
17
- try { return text((await vault.list()).join('\n') || '(no notes)'); } catch (e) { return fail(e); }
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('search_vault', {
21
- title: 'Search notes',
22
- description: 'Search the notes for words. Returns the best matching note paths with a snippet each.',
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 vault.search(query);
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('read_note', {
33
- title: 'Read a note',
34
- description: 'Read the full text of a note. Path is relative to the vault root; ".md" is optional.',
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 body = await vault.read(path);
39
- await hub.emit({ type: 'read', path: vault.rel(vault.resolve(path)), reason });
40
- return text(body);
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('write_note', {
45
- title: 'Write a note',
46
- description: 'Create a note, or replace a note\'s whole text. The user can undo this from the Pathnote viewer. Link the notes you used with [[Note name]].',
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 rel = vault.rel(vault.resolve(path));
51
- const before = await vault.readOrNull(rel);
52
- const change = await history.record(rel, before, content, before === null ? 'create' : 'write');
53
- await vault.write(rel, content);
54
- await hub.emit({ type: 'write', path: rel, created: before === null, changeId: change.id, reason });
55
- return text(`${before === null ? 'Created' : 'Replaced'} ${rel}.`);
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('edit_note', {
60
- title: 'Edit a note',
61
- description: 'Replace one exact piece of text in a note. `find` must appear exactly once. The user can undo this from the Pathnote viewer.',
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 rel = vault.rel(vault.resolve(path));
66
- const before = await vault.read(rel);
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(rel, before, after, 'edit');
71
- await vault.write(rel, after);
72
- await hub.emit({ type: 'edit', path: rel, changeId: change.id, reason });
73
- return text(`Edited ${rel}.`);
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