pathnote 0.1.0 → 0.2.0
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 +28 -18
- package/bin/pathnote.js +16 -12
- package/package.json +4 -2
- package/src/documents.js +98 -0
- package/src/history.js +20 -8
- package/src/hub.js +8 -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,69 @@ 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, open http://127.0.0.1:4747 in your browser, and ask Claude something like:
|
|
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
|
+
- Drag to move, scroll to zoom. File names appear as you zoom in.
|
|
52
|
+
- Several AI sessions on the same folders all report to one viewer.
|
|
53
|
+
- To keep the viewer open while clients come and go: `npx pathnote <folders> --viewer`.
|
|
54
|
+
- **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.
|
|
55
|
+
- **Clear path** clears the drawing, not your files.
|
|
55
56
|
|
|
56
57
|
## Tools the AI gets
|
|
57
58
|
|
|
58
59
|
| Tool | What it does |
|
|
59
60
|
|---|---|
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
61
|
+
| `list_files` | The connected folders, or what is inside one folder |
|
|
62
|
+
| `search_files` | Text files and documents matching some words, with a snippet each |
|
|
63
|
+
| `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) |
|
|
64
|
+
| `write_file` | Create a text file or replace its text (undoable). Documents and pictures are never written |
|
|
65
|
+
| `edit_file` | Replace one exact piece of text in a file (undoable) |
|
|
66
|
+
| `move_file` | Move or rename a file or folder of any type, creating missing folders; never overwrites (undoable) |
|
|
65
67
|
|
|
66
68
|
Each tool takes an optional `reason`, a short sentence the viewer shows next to the step.
|
|
67
69
|
|
|
68
70
|
## What Pathnote touches
|
|
69
71
|
|
|
70
|
-
- Only
|
|
71
|
-
-
|
|
72
|
+
- Only files inside the folders you list. Anything outside them is refused, including through symlinks.
|
|
73
|
+
- Old Hangul `.hwp` files and other binary formats can't be read. Scanned PDFs without a text layer come back empty.
|
|
74
|
+
- 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.
|
|
75
|
+
- Whatever the AI reads, including document text and pictures, is sent to the AI service you use, like anything else you share with it.
|
|
76
|
+
- The AI can create and change any other text file in those folders. Connect only folders you are comfortable letting it edit.
|
|
77
|
+
- 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
78
|
- The viewer only listens on 127.0.0.1 and refuses requests from other websites.
|
|
73
79
|
|
|
80
|
+
## macOS folder permissions
|
|
81
|
+
|
|
82
|
+
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.
|
|
83
|
+
|
|
74
84
|
## Development
|
|
75
85
|
|
|
76
86
|
```bash
|
package/bin/pathnote.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// pathnote <
|
|
2
|
+
// pathnote <folder> [<folder> ...] [--port 4747] [--viewer]
|
|
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
6
|
// stdout belongs to MCP, so every message for people goes to stderr.
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
-
import {
|
|
9
|
+
import { Workspace } from '../src/workspace.js';
|
|
10
10
|
import { History } from '../src/history.js';
|
|
11
11
|
import { Hub } from '../src/hub.js';
|
|
12
12
|
import { createServer } from '../src/mcp.js';
|
|
@@ -15,7 +15,8 @@ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.ur
|
|
|
15
15
|
const args = process.argv.slice(2);
|
|
16
16
|
const flag = name => args.includes(name);
|
|
17
17
|
const opt = name => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
18
|
-
const
|
|
18
|
+
const folders = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--port');
|
|
19
|
+
const folder = folders[0];
|
|
19
20
|
|
|
20
21
|
if (flag('--sample')) {
|
|
21
22
|
if (!folder) { console.error('Usage: pathnote --sample <new folder>'); process.exit(1); }
|
|
@@ -25,22 +26,25 @@ if (flag('--sample')) {
|
|
|
25
26
|
process.exit(0);
|
|
26
27
|
}
|
|
27
28
|
if (!folder || flag('--help')) {
|
|
28
|
-
console.error(`Pathnote ${pkg.version}\n\nUsage: pathnote <
|
|
29
|
+
console.error(`Pathnote ${pkg.version}\n\nUsage: pathnote <folder> [<folder> ...] [--port 4747] [--viewer]\n pathnote --sample <new folder>`);
|
|
29
30
|
process.exit(folder ? 0 : 1);
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
for (const f of folders) {
|
|
33
|
+
if (!fs.existsSync(f) || !fs.statSync(f).isDirectory()) {
|
|
34
|
+
console.error(`Pathnote: "${f}" is not a folder.`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
const
|
|
37
|
-
const history = new History(
|
|
38
|
-
const hub = new Hub(
|
|
39
|
+
const ws = new Workspace(folders);
|
|
40
|
+
const history = new History(ws);
|
|
41
|
+
const hub = new Hub(ws, history, Number(opt('--port')) || 4747);
|
|
39
42
|
const url = await hub.start();
|
|
40
|
-
console.error(`Pathnote ${pkg.version}:
|
|
43
|
+
console.error(`Pathnote ${pkg.version}: ${ws.roots.join(', ')}\nViewer: ${url}`);
|
|
44
|
+
ws.warm().catch(() => {});
|
|
41
45
|
|
|
42
46
|
if (!flag('--viewer')) {
|
|
43
|
-
await createServer({
|
|
47
|
+
await createServer({ ws, history, hub, version: pkg.version }).connect(new StdioServerTransport());
|
|
44
48
|
// The viewer server would keep the process alive after the MCP client quits
|
|
45
49
|
process.stdin.on('close', () => process.exit(0));
|
|
46
50
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pathnote",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "See which
|
|
3
|
+
"version": "0.2.0",
|
|
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,5 +1,5 @@
|
|
|
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
5
|
import fs from 'node:fs/promises';
|
|
@@ -10,8 +10,8 @@ const PAGE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'view
|
|
|
10
10
|
const KEEP = 500;
|
|
11
11
|
|
|
12
12
|
export class Hub {
|
|
13
|
-
constructor(
|
|
14
|
-
this.
|
|
13
|
+
constructor(ws, history, port) {
|
|
14
|
+
this.ws = ws;
|
|
15
15
|
this.history = history;
|
|
16
16
|
this.port = port;
|
|
17
17
|
this.events = [];
|
|
@@ -21,11 +21,11 @@ export class Hub {
|
|
|
21
21
|
this.seq = 0;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// Own a port for
|
|
24
|
+
// Own a port for these folders, or find the process that already does. Returns the viewer URL.
|
|
25
25
|
async start() {
|
|
26
26
|
for (let port = this.port; port < this.port + 10; port++) {
|
|
27
27
|
const other = await this.info(port);
|
|
28
|
-
if (other?.
|
|
28
|
+
if (other?.id === this.ws.id) { this.forwardTo = port; return `http://127.0.0.1:${port}`; }
|
|
29
29
|
if (other) continue;
|
|
30
30
|
try {
|
|
31
31
|
await this.listen(port);
|
|
@@ -98,8 +98,8 @@ export class Hub {
|
|
|
98
98
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
99
99
|
return res.end(await fs.readFile(PAGE));
|
|
100
100
|
}
|
|
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.
|
|
101
|
+
if (req.method === 'GET' && url.pathname === '/api/info') return send(res, 200, { app: 'pathnote', id: this.ws.id, roots: this.ws.roots });
|
|
102
|
+
if (req.method === 'GET' && url.pathname === '/api/graph') return send(res, 200, await this.ws.graph());
|
|
103
103
|
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
104
104
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
105
105
|
for (const e of this.events) res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
@@ -115,7 +115,7 @@ export class Hub {
|
|
|
115
115
|
const { id } = await body(req);
|
|
116
116
|
try {
|
|
117
117
|
const entry = await this.history.undo(id);
|
|
118
|
-
this.broadcast({ t: Date.now(), type: 'undo', path: entry.path, changeId: id, created: entry.before === null });
|
|
118
|
+
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
119
|
return send(res, 200, { ok: true });
|
|
120
120
|
} catch (e) {
|
|
121
121
|
return send(res, 409, { error: e.message });
|
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
|
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The folders Pathnote may touch. Every path from the AI is checked against them: it must be
|
|
2
|
+
// inside one of the folders (after following symlinks), not in a skipped folder, and not a
|
|
3
|
+
// file that usually holds secrets.
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { isDocument, isImage, documentText, imageContent, MAX_DOC } from './documents.js';
|
|
8
|
+
|
|
9
|
+
const SKIP = new Set(['.git', '.obsidian', '.trash', '.Trash', 'node_modules', '.build', 'build', 'DerivedData', 'dist', '.wrangler', '.venv', '__pycache__']);
|
|
10
|
+
// Word and LibreOffice leave these lock files next to open documents
|
|
11
|
+
const LOCK = /^(~\$|\.~lock\.)/;
|
|
12
|
+
const SECRET_DIRS = new Set(['secrets', '.ssh', '.gnupg', '.aws']);
|
|
13
|
+
const SECRET_FILE = /^(\.env(\..*)?|.*\.(pem|p8|p12|key|keystore|jks)|id_(rsa|ed25519|ecdsa)(\.pub)?|credentials\.json|token\.json|client_secret.*\.json|\.npmrc|\.netrc)$/i;
|
|
14
|
+
const TEXT = /\.(md|mdx|txt|markdown|csv|tsv|json|jsonc|ya?ml|toml|ini|cfg|conf|xml|html?|css|scss|js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|swift|m|h|c|cc|cpp|hpp|cs|php|sh|zsh|bash|sql|r|lua|tex|bib|srt|vtt|log)$/i;
|
|
15
|
+
const LINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
|
|
16
|
+
const MAX_READ = 2 * 1024 * 1024;
|
|
17
|
+
const MAX_SEARCH = 512 * 1024;
|
|
18
|
+
export const MAX_NODES = 6000;
|
|
19
|
+
|
|
20
|
+
const BLOCKED = 'macOS is blocking access to this folder. Allow it in System Settings → Privacy & Security → Files and Folders (for the app that runs Pathnote), then try again.';
|
|
21
|
+
|
|
22
|
+
// macOS holds file calls on protected folders (Desktop, Downloads, iCloud Drive) until the user
|
|
23
|
+
// answers a permission prompt, possibly forever. Give up after `ms` instead of hanging.
|
|
24
|
+
const timeout = (promise, ms) => Promise.race([promise, new Promise((_, no) => setTimeout(() => no(Object.assign(new Error(BLOCKED), { code: 'EBLOCKED' })), ms))]);
|
|
25
|
+
|
|
26
|
+
const exists = p => fs.access(p).then(() => true, () => false);
|
|
27
|
+
|
|
28
|
+
// First file under `dir` that looks like it holds secrets, or null
|
|
29
|
+
async function findSecret(dir) {
|
|
30
|
+
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
|
31
|
+
const abs = path.join(dir, e.name);
|
|
32
|
+
if (SECRET_FILE.test(e.name) || (e.isDirectory() && SECRET_DIRS.has(e.name))) return abs;
|
|
33
|
+
if (e.isDirectory()) { const found = await findSecret(abs); if (found) return found; }
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const expand = p => String(p || '').trim().replace(/^~(?=$|\/)/, os.homedir());
|
|
39
|
+
|
|
40
|
+
export class Workspace {
|
|
41
|
+
constructor(folders) {
|
|
42
|
+
this.roots = [...new Set(folders.map(f => path.resolve(expand(f))))];
|
|
43
|
+
this.id = this.roots.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Full path for something the AI named. Relative paths are allowed when there is one folder.
|
|
47
|
+
resolve(p) {
|
|
48
|
+
let s = expand(p);
|
|
49
|
+
if (!s) throw new Error('Path is empty.');
|
|
50
|
+
if (!path.isAbsolute(s)) {
|
|
51
|
+
if (this.roots.length > 1) throw new Error(`Use a full path, e.g. ${path.join(this.roots[0], s)}`);
|
|
52
|
+
s = path.join(this.roots[0], s);
|
|
53
|
+
}
|
|
54
|
+
const abs = path.resolve(s);
|
|
55
|
+
const root = this.rootOf(abs);
|
|
56
|
+
if (!root) throw new Error(`"${p}" is outside the connected folders: ${this.roots.join(', ')}`);
|
|
57
|
+
const parts = path.relative(root, abs).split(path.sep);
|
|
58
|
+
if (parts.some(x => SKIP.has(x))) throw new Error(`"${p}" is in a folder Pathnote skips.`);
|
|
59
|
+
if (parts.some(x => SECRET_DIRS.has(x)) || SECRET_FILE.test(path.basename(abs))) throw new Error(`"${p}" looks like it holds secrets, so Pathnote won't open it.`);
|
|
60
|
+
return abs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
rootOf(abs) {
|
|
64
|
+
return this.roots.find(r => abs === r || abs.startsWith(r + path.sep));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// resolve(), then the same check after following symlinks
|
|
68
|
+
async real(p) {
|
|
69
|
+
const abs = this.resolve(p);
|
|
70
|
+
let at = abs;
|
|
71
|
+
for (;;) {
|
|
72
|
+
try { at = await timeout(fs.realpath(at), 5000); break; } catch (e) { if (e.code !== 'ENOENT') throw e; at = path.dirname(at); }
|
|
73
|
+
}
|
|
74
|
+
const roots = await Promise.all(this.roots.map(r => fs.realpath(r).catch(() => r)));
|
|
75
|
+
if (!roots.some(r => at === r || at.startsWith(r + path.sep))) throw new Error(`"${p}" leads outside the connected folders.`);
|
|
76
|
+
return abs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Immediate contents of a folder; folders end with "/"
|
|
80
|
+
async list(dir) {
|
|
81
|
+
const abs = await this.real(dir);
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const e of await timeout(fs.readdir(abs, { withFileTypes: true }), 5000)) {
|
|
84
|
+
if (e.name.startsWith('.') || LOCK.test(e.name) || SKIP.has(e.name) || SECRET_DIRS.has(e.name) || SECRET_FILE.test(e.name)) continue;
|
|
85
|
+
out.push(e.isDirectory() ? e.name + '/' : e.name);
|
|
86
|
+
}
|
|
87
|
+
return out.sort();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async read(p) {
|
|
91
|
+
const abs = await this.real(p);
|
|
92
|
+
const st = await timeout(fs.stat(abs), 5000);
|
|
93
|
+
if (st.isDirectory()) throw new Error(`"${p}" is a folder. Use list_files.`);
|
|
94
|
+
if (st.size > MAX_READ) throw new Error(`"${p}" is ${(st.size / 1048576).toFixed(1)} MB; Pathnote reads files up to 2 MB.`);
|
|
95
|
+
const buf = await timeout(fs.readFile(abs), 10000);
|
|
96
|
+
if (buf.subarray(0, 8000).includes(0)) throw new Error(`"${p}" is not a text file.`);
|
|
97
|
+
return buf.toString('utf8');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Anything the AI can take in: text as is, documents as their text, pictures as images
|
|
101
|
+
async open(p) {
|
|
102
|
+
const abs = await this.real(p);
|
|
103
|
+
if (!isDocument(abs) && !isImage(abs)) return { kind: 'text', text: await this.read(abs) };
|
|
104
|
+
const st = await timeout(fs.stat(abs), 5000);
|
|
105
|
+
if (st.size > MAX_DOC) throw new Error(`"${p}" is ${(st.size / 1048576).toFixed(1)} MB; Pathnote opens documents and images up to 50 MB.`);
|
|
106
|
+
const buf = await timeout(fs.readFile(abs), 15000);
|
|
107
|
+
if (isImage(abs)) return { kind: 'image', ...(await timeout(imageContent(abs, buf), 20000)) };
|
|
108
|
+
return { kind: 'document', text: await timeout(documentText(abs, buf), 30000) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Document text for search, kept until the file changes
|
|
112
|
+
async docText(abs) {
|
|
113
|
+
const st = await fs.stat(abs);
|
|
114
|
+
if (st.size > MAX_DOC) return null;
|
|
115
|
+
const hit = this.docs?.get(abs);
|
|
116
|
+
if (hit?.mtime === st.mtimeMs) return hit.text;
|
|
117
|
+
const text = await timeout(documentText(abs, await fs.readFile(abs)), 15000);
|
|
118
|
+
(this.docs ??= new Map()).set(abs, { mtime: st.mtimeMs, text });
|
|
119
|
+
return text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Read every document's text once in the background, so the first search doesn't wait for it
|
|
123
|
+
async warm() {
|
|
124
|
+
for (const it of await this.walk()) {
|
|
125
|
+
if (it.kind === 'file' && isDocument(it.id)) await this.docText(it.id).catch(() => {});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async readOrNull(p) {
|
|
130
|
+
try { return await this.read(p); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async write(p, text) {
|
|
134
|
+
const abs = await this.real(p);
|
|
135
|
+
if (isDocument(abs) || isImage(abs)) throw new Error(`Pathnote can read ${path.extname(abs)} files but not write them.`);
|
|
136
|
+
await timeout(fs.mkdir(path.dirname(abs), { recursive: true }), 5000);
|
|
137
|
+
await timeout(fs.writeFile(abs, text, 'utf8'), 10000);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Move a file or folder (any type) to a new path in the connected folders. Never overwrites,
|
|
141
|
+
// and refuses folders that hold secret files, since those would leave the protected place.
|
|
142
|
+
async move(from, to) {
|
|
143
|
+
const src = await this.real(from), dst = await this.real(to);
|
|
144
|
+
if (this.roots.includes(src)) throw new Error(`"${from}" is a connected folder itself; move what is inside it instead.`);
|
|
145
|
+
const st = await timeout(fs.stat(src), 5000);
|
|
146
|
+
if (await exists(dst)) throw new Error(`"${to}" already exists; Pathnote never overwrites.`);
|
|
147
|
+
if (dst.startsWith(src + path.sep)) throw new Error(`Can't move "${from}" into itself.`);
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
const secret = await findSecret(src);
|
|
150
|
+
if (secret) throw new Error(`"${from}" holds a file that looks like a secret (${path.relative(src, secret)}), so Pathnote won't move it.`);
|
|
151
|
+
}
|
|
152
|
+
await timeout(fs.mkdir(path.dirname(dst), { recursive: true }), 5000);
|
|
153
|
+
try {
|
|
154
|
+
await fs.rename(src, dst);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e.code !== 'EXDEV') throw e;
|
|
157
|
+
// different disk (e.g. iCloud Drive): copy, then remove the original
|
|
158
|
+
await fs.cp(src, dst, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true });
|
|
159
|
+
await fs.rm(src, { recursive: true });
|
|
160
|
+
}
|
|
161
|
+
return { from: src, to: dst, dir: st.isDirectory() };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async remove(p) {
|
|
165
|
+
await fs.rm(await this.real(p), { force: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Every folder and file under the roots, minus skipped, hidden and secret ones. Stops at MAX_NODES.
|
|
169
|
+
async walk() {
|
|
170
|
+
// All folders at once, so several blocked ones cost one timeout, not one each
|
|
171
|
+
const per = await Promise.all(this.roots.map(async r => {
|
|
172
|
+
const mine = [{ id: r, kind: 'root', parent: null }];
|
|
173
|
+
try {
|
|
174
|
+
await timeout(this.visit(r, mine, MAX_NODES), 4000);
|
|
175
|
+
return { items: mine };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
if (e.code !== 'EBLOCKED') throw e;
|
|
178
|
+
return { items: mine.slice(0, 1), blocked: r };
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
this.blocked = per.filter(x => x.blocked).map(x => x.blocked);
|
|
182
|
+
return per.flatMap(x => x.items).slice(0, MAX_NODES);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async visit(root, items, max) {
|
|
186
|
+
const visit = async (dir, depth) => {
|
|
187
|
+
let entries;
|
|
188
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
189
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
190
|
+
for (const e of entries) {
|
|
191
|
+
if (items.length >= max) return;
|
|
192
|
+
if (e.name.startsWith('.') || LOCK.test(e.name) || SKIP.has(e.name) || SECRET_DIRS.has(e.name) || SECRET_FILE.test(e.name)) continue;
|
|
193
|
+
const abs = path.join(dir, e.name);
|
|
194
|
+
if (e.isDirectory()) { items.push({ id: abs, kind: 'dir', parent: dir }); await visit(abs, depth + 1); }
|
|
195
|
+
else if (e.isFile()) items.push({ id: abs, kind: 'file', parent: dir });
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
await visit(root, 1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Text files and documents ranked by how often the query's words appear, file-name matches counted extra
|
|
202
|
+
async search(query, limit = 10) {
|
|
203
|
+
const words = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
204
|
+
if (!words.length) return [];
|
|
205
|
+
const hits = [];
|
|
206
|
+
for (const it of await this.walk()) {
|
|
207
|
+
if (it.kind !== 'file' || !(TEXT.test(it.id) || isDocument(it.id))) continue;
|
|
208
|
+
let text;
|
|
209
|
+
try {
|
|
210
|
+
if (isDocument(it.id)) text = await this.docText(it.id);
|
|
211
|
+
else {
|
|
212
|
+
const st = await fs.stat(it.id);
|
|
213
|
+
if (st.size > MAX_SEARCH) continue;
|
|
214
|
+
text = await timeout(fs.readFile(it.id, 'utf8'), 3000);
|
|
215
|
+
}
|
|
216
|
+
} catch { continue; }
|
|
217
|
+
if (!text) continue;
|
|
218
|
+
const lower = text.toLowerCase(), name = path.basename(it.id).toLowerCase();
|
|
219
|
+
let score = 0;
|
|
220
|
+
for (const w of words) { score += lower.split(w).length - 1; if (name.includes(w)) score += 5; }
|
|
221
|
+
if (!score) continue;
|
|
222
|
+
const at = Math.max(0, lower.indexOf(words.find(w => lower.includes(w)) ?? words[0]));
|
|
223
|
+
hits.push({ path: it.id, score, snippet: text.slice(Math.max(0, at - 60), at + 140).replace(/\s+/g, ' ').trim() });
|
|
224
|
+
}
|
|
225
|
+
return hits.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Folder tree for the viewer, plus [[wikilinks]] between markdown notes (resolved by name, like Obsidian)
|
|
229
|
+
async graph() {
|
|
230
|
+
const items = await this.walk();
|
|
231
|
+
const notes = items.filter(i => i.kind === 'file' && /\.md$/i.test(i.id));
|
|
232
|
+
const byName = new Map(notes.map(n => [path.basename(n.id, path.extname(n.id)).toLowerCase(), n.id]));
|
|
233
|
+
const links = [];
|
|
234
|
+
for (const n of notes) {
|
|
235
|
+
let text;
|
|
236
|
+
try { text = await fs.readFile(n.id, 'utf8'); } catch { continue; }
|
|
237
|
+
for (const m of text.matchAll(LINK)) {
|
|
238
|
+
const target = byName.get(path.basename(m[1].trim()).toLowerCase());
|
|
239
|
+
if (target && target !== n.id) links.push([n.id, target]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { roots: this.roots, items, links, blocked: this.blocked, capped: items.length >= MAX_NODES };
|
|
243
|
+
}
|
|
244
|
+
}
|
package/viewer/index.html
CHANGED
|
@@ -31,6 +31,8 @@ body { background: var(--bg); color: var(--ink); font: 400 14px/1.43 var(--font)
|
|
|
31
31
|
.graph { position: relative; min-width: 0; min-height: 0; border-right: 1px solid var(--line); }
|
|
32
32
|
svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
|
|
33
33
|
svg.drag { cursor: grabbing; }
|
|
34
|
+
.note { position: absolute; left: 12px; right: 12px; bottom: 12px; padding: 10px 14px; border-radius: 11px; background: var(--surface); color: var(--muted); font-size: 12px; }
|
|
35
|
+
.note b { color: var(--ink); font-weight: 600; }
|
|
34
36
|
.empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; pointer-events: none; }
|
|
35
37
|
.log { display: flex; flex-direction: column; min-height: 0; }
|
|
36
38
|
.log h2 { font-size: 14px; font-weight: 600; margin: 0; padding: 10px 16px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }
|
|
@@ -50,12 +52,21 @@ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
|
|
|
50
52
|
.btn:disabled { opacity: .4; cursor: default; }
|
|
51
53
|
.hint { padding: 16px; color: var(--muted); }
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
/* --k = user units per screen pixel, so dots, labels and lines keep their on-screen size while zooming */
|
|
56
|
+
svg { --k: 1; }
|
|
57
|
+
line { vector-effect: non-scaling-stroke; }
|
|
58
|
+
.tree { stroke: var(--line); stroke-width: 1; }
|
|
59
|
+
.link { stroke: var(--accent); stroke-width: 1; stroke-dasharray: 3 4; opacity: .35; }
|
|
54
60
|
.trail { stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; transition: stroke-dashoffset .6s ease-in-out, opacity .4s; }
|
|
55
61
|
.src { stroke: var(--accent); stroke-width: 1.5; opacity: .6; }
|
|
56
|
-
.node .dot { fill: var(--node); transition: fill .3s; }
|
|
57
|
-
.node
|
|
58
|
-
.node .
|
|
62
|
+
.node .dot { fill: var(--node); transition: fill .3s; r: calc(var(--k) * 3.5px); }
|
|
63
|
+
.node.dir .dot { r: calc(var(--k) * 5px); }
|
|
64
|
+
.node.root .dot { r: calc(var(--k) * 8px); fill: var(--ink); }
|
|
65
|
+
.node text { font-family: var(--font); font-size: calc(var(--k) * 11px); fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: calc(var(--k) * 4px); stroke-linejoin: round; pointer-events: none; display: none; }
|
|
66
|
+
.node.root text { font-size: calc(var(--k) * 13px); font-weight: 600; fill: var(--ink); }
|
|
67
|
+
.node.root text, .node.top text, .node.hit text, .node.visited text, .node.written text, svg.near .node.dir text, svg.close .node text { display: inline; }
|
|
68
|
+
.node .ring { fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 3 3; opacity: 0; r: calc(var(--k) * 11px); vector-effect: non-scaling-stroke; }
|
|
69
|
+
.node .pulse { r: calc(var(--k) * 6px); }
|
|
59
70
|
.node.hit .ring { opacity: 1; }
|
|
60
71
|
.node.visited .dot, .node.written .dot { fill: var(--accent); }
|
|
61
72
|
.node.visited text, .node.written text { fill: var(--ink); font-weight: 600; }
|
|
@@ -74,13 +85,14 @@ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
|
|
|
74
85
|
<body>
|
|
75
86
|
<div class="bar">
|
|
76
87
|
<strong>Pathnote</strong>
|
|
77
|
-
<span class="vault" id="
|
|
88
|
+
<span class="vault" id="roots"></span>
|
|
78
89
|
<span class="chip" id="status"><i></i><span id="statusText"></span></span>
|
|
79
90
|
</div>
|
|
80
91
|
<div class="main">
|
|
81
92
|
<div class="graph">
|
|
82
93
|
<svg id="g" role="img"></svg>
|
|
83
94
|
<div class="empty" id="empty" hidden></div>
|
|
95
|
+
<p class="note" id="note" hidden></p>
|
|
84
96
|
</div>
|
|
85
97
|
<div class="log">
|
|
86
98
|
<h2><span id="logTitle"></span><button class="btn" id="clear" type="button"></button></h2>
|
|
@@ -92,12 +104,16 @@ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
|
|
|
92
104
|
const ko = (navigator.language || '').toLowerCase().startsWith('ko');
|
|
93
105
|
const L = ko ? {
|
|
94
106
|
live: '연결됨', off: '연결 끊김', activity: '작업 기록', clear: '경로 지우기', undo: '되돌리기', undone: '되돌림',
|
|
95
|
-
wait: 'AI가
|
|
96
|
-
hits: n => `${n}개 찾음`, created: '새
|
|
107
|
+
wait: 'AI가 파일을 찾거나 읽으면 여기에 기록이 쌓입니다.', empty: '연결된 폴더가 비어 있습니다.',
|
|
108
|
+
hits: n => `${n}개 찾음`, created: '새 파일', graph: '연결된 폴더 구조와 AI가 지나간 경로',
|
|
109
|
+
capped: n => `파일이 많아 ${n}개까지만 그렸습니다.`, folders: n => `폴더 ${n}개`,
|
|
110
|
+
blocked: 'macOS가 막고 있어 비어 보이는 폴더: ', allow: ' 시스템 설정 → 개인정보 보호 및 보안 → 파일 및 폴더에서 Pathnote를 실행하는 앱(Claude 등)에 허용한 뒤 새로고침하세요.'
|
|
97
111
|
} : {
|
|
98
112
|
live: 'Live', off: 'Disconnected', activity: 'Activity', clear: 'Clear path', undo: 'Undo', undone: 'undone',
|
|
99
|
-
wait: 'When the AI
|
|
100
|
-
hits: n => `${n} found`, created: 'new
|
|
113
|
+
wait: 'When the AI looks through or reads your files, it shows up here.', empty: 'The connected folders are empty.',
|
|
114
|
+
hits: n => `${n} found`, created: 'new file', graph: 'Connected folders and the path the AI took',
|
|
115
|
+
capped: n => `Too many files; drew the first ${n}.`, folders: n => `${n} folders`,
|
|
116
|
+
blocked: 'macOS is blocking these folders, so they look empty: ', allow: ' Allow them in System Settings → Privacy & Security → Files and Folders for the app that runs Pathnote (e.g. Claude), then reload.'
|
|
101
117
|
};
|
|
102
118
|
document.documentElement.lang = ko ? 'ko' : 'en';
|
|
103
119
|
document.getElementById('logTitle').textContent = L.activity;
|
|
@@ -107,81 +123,124 @@ document.getElementById('hint').textContent = L.wait;
|
|
|
107
123
|
const NS = 'http://www.w3.org/2000/svg';
|
|
108
124
|
const svg = document.getElementById('g');
|
|
109
125
|
svg.setAttribute('aria-label', L.graph);
|
|
110
|
-
const view = el('g'), gLinks = el('g'), gTrail = el('g'), gNodes = el('g');
|
|
111
|
-
view.append(gLinks, gTrail, gNodes);
|
|
126
|
+
const view = el('g'), gTree = el('g'), gLinks = el('g'), gTrail = el('g'), gNodes = el('g');
|
|
127
|
+
view.append(gTree, gLinks, gTrail, gNodes);
|
|
112
128
|
svg.append(view);
|
|
113
|
-
const pos = new Map(), nodeEl = new Map();
|
|
114
|
-
|
|
129
|
+
const pos = new Map(), nodeEl = new Map(), angleOf = new Map(), depthOf = new Map();
|
|
130
|
+
const RING = 160;
|
|
131
|
+
// connected folders sit on a wide first ring so their names don't crowd the middle
|
|
132
|
+
const radius = depth => (depth + 1.5) * RING;
|
|
133
|
+
let current = null, sources = new Set(), vb = { x: -500, y: -500, w: 1000, h: 1000 };
|
|
115
134
|
|
|
116
135
|
function el(tag, attrs = {}) {
|
|
117
136
|
const e = document.createElementNS(NS, tag);
|
|
118
137
|
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
|
119
138
|
return e;
|
|
120
139
|
}
|
|
121
|
-
const
|
|
140
|
+
const base = p => p.split('/').filter(Boolean).pop() || p;
|
|
141
|
+
const parentOf = p => p.slice(0, p.lastIndexOf('/')) || '/';
|
|
122
142
|
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
// Radial tree: each folder gets a slice of the circle sized by how many files it holds,
|
|
144
|
+
// and sits one ring further out than its parent
|
|
145
|
+
function layout(items) {
|
|
146
|
+
const kids = new Map([['', []]]);
|
|
147
|
+
for (const it of items) {
|
|
148
|
+
const p = it.kind === 'root' ? '' : it.parent;
|
|
149
|
+
if (!kids.has(p)) kids.set(p, []);
|
|
150
|
+
kids.get(p).push(it.id);
|
|
151
|
+
}
|
|
152
|
+
const leaves = new Map();
|
|
153
|
+
const count = id => {
|
|
154
|
+
const c = kids.get(id) || [];
|
|
155
|
+
const n = c.length ? c.reduce((s, k) => s + count(k), 0) : 1;
|
|
156
|
+
leaves.set(id, n);
|
|
157
|
+
return n;
|
|
158
|
+
};
|
|
159
|
+
count('');
|
|
160
|
+
// a connected folder with few files still gets a readable share of the circle
|
|
161
|
+
const total = leaves.get('');
|
|
162
|
+
for (const r of kids.get('')) leaves.set(r, Math.max(leaves.get(r), total * 0.1));
|
|
163
|
+
leaves.set('', kids.get('').reduce((s, r) => s + leaves.get(r), 0));
|
|
164
|
+
const place = (id, a0, a1, depth) => {
|
|
165
|
+
const a = (a0 + a1) / 2;
|
|
166
|
+
if (id) {
|
|
167
|
+
pos.set(id, [Math.cos(a) * radius(depth), Math.sin(a) * radius(depth)]);
|
|
168
|
+
angleOf.set(id, a); depthOf.set(id, depth);
|
|
143
169
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
170
|
+
let at = a0;
|
|
171
|
+
const sum = (kids.get(id) || []).reduce((s, k) => s + leaves.get(k), 0);
|
|
172
|
+
for (const k of kids.get(id) || []) {
|
|
173
|
+
const span = (a1 - a0) * leaves.get(k) / sum;
|
|
174
|
+
place(k, at, at + span, depth + 1);
|
|
175
|
+
at += span;
|
|
148
176
|
}
|
|
149
|
-
}
|
|
150
|
-
|
|
177
|
+
};
|
|
178
|
+
place('', -Math.PI / 2, Math.PI * 1.5, 0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function setView() {
|
|
182
|
+
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
|
|
183
|
+
const k = vb.w / Math.max(1, svg.clientWidth);
|
|
184
|
+
svg.style.setProperty('--k', k);
|
|
185
|
+
svg.classList.toggle('near', k < 1.2);
|
|
186
|
+
svg.classList.toggle('close', k < 0.5);
|
|
151
187
|
}
|
|
152
188
|
|
|
153
189
|
function fit() {
|
|
154
190
|
const pts = [...pos.values()];
|
|
155
|
-
if (!pts.length) return;
|
|
191
|
+
if (!pts.length) return setView();
|
|
156
192
|
const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]);
|
|
157
|
-
const pad =
|
|
158
|
-
|
|
159
|
-
|
|
193
|
+
const pad = 80, x0 = Math.min(...xs) - pad, y0 = Math.min(...ys) - pad;
|
|
194
|
+
const w = Math.max(...xs) + pad - x0, h = Math.max(...ys) + pad - y0;
|
|
195
|
+
// keep the drawing's aspect so it isn't squashed in a wide panel
|
|
196
|
+
const r = svg.clientWidth / Math.max(1, svg.clientHeight);
|
|
197
|
+
// leave room for file names, which run sideways past the outermost dots
|
|
198
|
+
const label = 220 * Math.max(w, h * r) / Math.max(1, svg.clientWidth);
|
|
199
|
+
const size = Math.max(w + 2 * label, h * r);
|
|
200
|
+
vb = { x: x0 + w / 2 - size / 2, y: y0 + h / 2 - size / r / 2, w: size, h: size / r };
|
|
201
|
+
setView();
|
|
160
202
|
}
|
|
161
203
|
|
|
162
|
-
function addNode(id) {
|
|
204
|
+
function addNode(id, kind, top) {
|
|
163
205
|
const [x, y] = pos.get(id);
|
|
164
|
-
const g = el('g', { class:
|
|
165
|
-
g.append(el('circle', { class: 'pulse', cx: x, cy: y
|
|
166
|
-
|
|
167
|
-
const t = el('text', { x, y:
|
|
168
|
-
t.textContent =
|
|
206
|
+
const g = el('g', { class: `node ${kind}${top ? ' top' : ''}` });
|
|
207
|
+
g.append(el('circle', { class: 'pulse', cx: x, cy: y }), el('circle', { class: 'ring', cx: x, cy: y }), el('circle', { class: 'dot', cx: x, cy: y }));
|
|
208
|
+
const a = angleOf.get(id) ?? 0, right = Math.cos(a) >= 0;
|
|
209
|
+
const t = el('text', { x, y, dx: right ? '0.9em' : '-0.9em', dy: '0.35em', 'text-anchor': right ? 'start' : 'end' });
|
|
210
|
+
t.textContent = base(id);
|
|
169
211
|
const title = el('title'); title.textContent = id;
|
|
170
212
|
g.append(t, title);
|
|
171
213
|
gNodes.append(g);
|
|
172
214
|
nodeEl.set(id, g);
|
|
173
215
|
}
|
|
174
216
|
|
|
217
|
+
function treeLine(a, b) {
|
|
218
|
+
const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
|
|
219
|
+
gTree.append(el('line', { class: 'tree', x1, y1, x2, y2 }));
|
|
220
|
+
}
|
|
221
|
+
|
|
175
222
|
async function loadGraph() {
|
|
176
|
-
const
|
|
177
|
-
document.getElementById('
|
|
178
|
-
document.getElementById('
|
|
179
|
-
|
|
180
|
-
|
|
223
|
+
const g = await (await fetch('/api/graph')).json();
|
|
224
|
+
document.getElementById('roots').textContent = g.roots.map(base).join(' · ');
|
|
225
|
+
document.getElementById('roots').title = g.roots.join('\n');
|
|
226
|
+
const empty = document.getElementById('empty');
|
|
227
|
+
empty.hidden = g.items.some(i => i.kind !== 'root') && !g.capped;
|
|
228
|
+
empty.textContent = g.capped ? L.capped(g.items.length) : L.empty;
|
|
229
|
+
const note = document.getElementById('note');
|
|
230
|
+
note.hidden = !g.blocked?.length;
|
|
231
|
+
if (g.blocked?.length) {
|
|
232
|
+
note.textContent = '';
|
|
233
|
+
const b = document.createElement('b'); b.textContent = L.blocked + g.blocked.map(base).join(', ') + '.';
|
|
234
|
+
note.append(b, L.allow);
|
|
235
|
+
}
|
|
236
|
+
layout(g.items);
|
|
237
|
+
const isRoot = new Set(g.roots);
|
|
238
|
+
for (const it of g.items) if (it.parent && pos.has(it.parent)) treeLine(it.parent, it.id);
|
|
239
|
+
for (const it of g.items) addNode(it.id, it.kind, it.kind === 'dir' && isRoot.has(it.parent));
|
|
240
|
+
for (const [a, b] of g.links) {
|
|
181
241
|
const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
|
|
182
242
|
gLinks.append(el('line', { class: 'link', x1, y1, x2, y2 }));
|
|
183
243
|
}
|
|
184
|
-
nodes.forEach(addNode);
|
|
185
244
|
fit();
|
|
186
245
|
}
|
|
187
246
|
|
|
@@ -202,17 +261,30 @@ function setCurrent(id) {
|
|
|
202
261
|
nodeEl.get(id)?.classList.add('current');
|
|
203
262
|
}
|
|
204
263
|
|
|
205
|
-
// A
|
|
206
|
-
function ensureNode(id) {
|
|
264
|
+
// A file the AI created: put it one ring outside its folder
|
|
265
|
+
function ensureNode(id, kind = 'file') {
|
|
207
266
|
if (pos.has(id)) return;
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
267
|
+
const parent = parentOf(id);
|
|
268
|
+
if (pos.has(parent)) {
|
|
269
|
+
const a = (angleOf.get(parent) ?? 0) + (Math.random() - 0.5) * 0.08, d = (depthOf.get(parent) ?? 1) + 1;
|
|
270
|
+
pos.set(id, [Math.cos(a) * radius(d), Math.sin(a) * radius(d)]); angleOf.set(id, a); depthOf.set(id, d);
|
|
271
|
+
treeLine(parent, id);
|
|
272
|
+
} else {
|
|
273
|
+
pos.set(id, [vb.x + vb.w / 2, vb.y + vb.h / 2]);
|
|
274
|
+
}
|
|
275
|
+
addNode(id, kind);
|
|
213
276
|
document.getElementById('empty').hidden = true;
|
|
214
277
|
}
|
|
215
278
|
|
|
279
|
+
// Move along the path to a node: line from the last place the AI was
|
|
280
|
+
function visit(id, live) {
|
|
281
|
+
if (!nodeEl.has(id)) return;
|
|
282
|
+
if (current && current !== id) line('trail', current, id, live);
|
|
283
|
+
const g = nodeEl.get(id);
|
|
284
|
+
g.classList.remove('hit'); g.classList.add('visited');
|
|
285
|
+
setCurrent(id);
|
|
286
|
+
}
|
|
287
|
+
|
|
216
288
|
const logEl = document.getElementById('log');
|
|
217
289
|
function addLog(e, tool, text, live) {
|
|
218
290
|
document.getElementById('hint').hidden = true;
|
|
@@ -222,7 +294,7 @@ function addLog(e, tool, text, live) {
|
|
|
222
294
|
if (e.type === 'undo') li.classList.add('undo');
|
|
223
295
|
const a = document.createElement('span'); a.className = 'tool'; a.textContent = tool;
|
|
224
296
|
const tm = document.createElement('time'); tm.textContent = new Date(e.t).toLocaleTimeString();
|
|
225
|
-
const b = document.createElement('span'); b.className = 'text'; b.textContent = text;
|
|
297
|
+
const b = document.createElement('span'); b.className = 'text'; b.textContent = text; b.title = e.path || '';
|
|
226
298
|
li.append(a, tm, b);
|
|
227
299
|
if (e.reason) { const w = document.createElement('span'); w.className = 'why'; w.textContent = e.reason; li.append(w); }
|
|
228
300
|
if (e.changeId && e.type !== 'undo') {
|
|
@@ -242,32 +314,48 @@ function addLog(e, tool, text, live) {
|
|
|
242
314
|
logEl.scrollTop = logEl.scrollHeight;
|
|
243
315
|
}
|
|
244
316
|
|
|
317
|
+
// Show paths shortly: the connected folder's name, then the rest
|
|
318
|
+
function short(p) {
|
|
319
|
+
const root = rootsList.find(r => p === r || p.startsWith(r + '/'));
|
|
320
|
+
return root ? base(root) + p.slice(root.length) : p;
|
|
321
|
+
}
|
|
322
|
+
let rootsList = [];
|
|
323
|
+
|
|
245
324
|
function onEvent(e, live) {
|
|
246
325
|
if (e.type === 'search') {
|
|
247
326
|
for (const h of e.hits) nodeEl.get(h)?.classList.add('hit');
|
|
248
|
-
addLog(e, '
|
|
327
|
+
addLog(e, 'search_files', `"${e.query}" · ${L.hits(e.hits.length)}`, live);
|
|
328
|
+
} else if (e.type === 'list') {
|
|
329
|
+
visit(e.path, live);
|
|
330
|
+
addLog(e, 'list_files', short(e.path) + '/', live);
|
|
249
331
|
} else if (e.type === 'read') {
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
g.classList.remove('hit'); g.classList.add('visited');
|
|
254
|
-
sources.add(e.path); setCurrent(e.path);
|
|
255
|
-
}
|
|
256
|
-
addLog(e, 'read_note', e.path, live);
|
|
332
|
+
visit(e.path, live);
|
|
333
|
+
if (nodeEl.has(e.path)) sources.add(e.path);
|
|
334
|
+
addLog(e, 'read_file', short(e.path), live);
|
|
257
335
|
} else if (e.type === 'write' || e.type === 'edit') {
|
|
258
336
|
ensureNode(e.path);
|
|
259
|
-
for (const s of sources) if (s !== e.path) line('src', s, e.path, live);
|
|
337
|
+
for (const s of sources) if (s !== e.path && pos.has(s)) line('src', s, e.path, live);
|
|
260
338
|
const g = nodeEl.get(e.path);
|
|
261
339
|
g.classList.remove('gone'); g.classList.add('written');
|
|
262
340
|
setCurrent(e.path);
|
|
263
|
-
addLog(e, e.type === 'write' ? '
|
|
341
|
+
addLog(e, e.type === 'write' ? 'write_file' : 'edit_file', e.created ? `${short(e.path)} (${L.created})` : short(e.path), live);
|
|
342
|
+
} else if (e.type === 'move') {
|
|
343
|
+
visit(e.from, live);
|
|
344
|
+
ensureNode(e.path, e.dir ? 'dir' : 'file');
|
|
345
|
+
if (pos.has(e.from)) line('src', e.from, e.path, live);
|
|
346
|
+
nodeEl.get(e.from)?.classList.add('gone');
|
|
347
|
+
const g = nodeEl.get(e.path);
|
|
348
|
+
g.classList.remove('gone'); g.classList.add('written');
|
|
349
|
+
setCurrent(e.path);
|
|
350
|
+
addLog(e, 'move_file', `${short(e.from)} → ${short(e.path)}`, live);
|
|
264
351
|
} else if (e.type === 'undo') {
|
|
352
|
+
if (e.back) nodeEl.get(e.back)?.classList.remove('gone');
|
|
265
353
|
if (e.created) {
|
|
266
354
|
nodeEl.get(e.path)?.classList.add('gone');
|
|
267
355
|
gTrail.querySelectorAll(`[data-to="${CSS.escape(e.path)}"]`).forEach(l => l.style.opacity = 0);
|
|
268
356
|
}
|
|
269
357
|
logEl.querySelectorAll(`[data-change="${e.changeId}"]`).forEach(b => { b.disabled = true; b.textContent = L.undone; });
|
|
270
|
-
addLog(e, 'undo', e.path, live);
|
|
358
|
+
addLog(e, 'undo', short(e.path), live);
|
|
271
359
|
}
|
|
272
360
|
}
|
|
273
361
|
|
|
@@ -277,14 +365,14 @@ document.getElementById('clear').onclick = () => {
|
|
|
277
365
|
current = null; sources = new Set();
|
|
278
366
|
};
|
|
279
367
|
|
|
280
|
-
// Drag to pan, wheel to zoom
|
|
368
|
+
// Drag to pan, wheel or pinch to zoom
|
|
281
369
|
let drag = null;
|
|
282
370
|
svg.addEventListener('pointerdown', e => { drag = { x: e.clientX, y: e.clientY, vb: { ...vb } }; svg.classList.add('drag'); svg.setPointerCapture(e.pointerId); });
|
|
283
371
|
svg.addEventListener('pointermove', e => {
|
|
284
372
|
if (!drag) return;
|
|
285
373
|
const k = vb.w / svg.clientWidth;
|
|
286
374
|
vb.x = drag.vb.x - (e.clientX - drag.x) * k; vb.y = drag.vb.y - (e.clientY - drag.y) * k;
|
|
287
|
-
|
|
375
|
+
setView();
|
|
288
376
|
});
|
|
289
377
|
svg.addEventListener('pointerup', () => { drag = null; svg.classList.remove('drag'); });
|
|
290
378
|
svg.addEventListener('wheel', e => {
|
|
@@ -292,14 +380,16 @@ svg.addEventListener('wheel', e => {
|
|
|
292
380
|
const k = Math.exp(e.deltaY * 0.0015), r = svg.getBoundingClientRect();
|
|
293
381
|
const mx = vb.x + (e.clientX - r.left) / r.width * vb.w, my = vb.y + (e.clientY - r.top) / r.height * vb.h;
|
|
294
382
|
vb = { x: mx - (mx - vb.x) * k, y: my - (my - vb.y) * k, w: vb.w * k, h: vb.h * k };
|
|
295
|
-
|
|
383
|
+
setView();
|
|
296
384
|
}, { passive: false });
|
|
385
|
+
addEventListener('resize', fit);
|
|
297
386
|
|
|
298
387
|
(async () => {
|
|
299
388
|
const info = await (await fetch('/api/info')).json();
|
|
300
|
-
|
|
389
|
+
rootsList = info.roots;
|
|
301
390
|
await loadGraph();
|
|
302
391
|
const status = document.getElementById('status'), statusText = document.getElementById('statusText');
|
|
392
|
+
statusText.textContent = L.off;
|
|
303
393
|
let replay = true;
|
|
304
394
|
const es = new EventSource('/api/events');
|
|
305
395
|
es.onopen = () => { status.classList.add('on'); statusText.textContent = L.live; setTimeout(() => { replay = false; }, 300); };
|
|
@@ -311,7 +401,6 @@ svg.addEventListener('wheel', e => {
|
|
|
311
401
|
seen.add(e.key);
|
|
312
402
|
onEvent(e, !replay);
|
|
313
403
|
};
|
|
314
|
-
statusText.textContent = L.off;
|
|
315
404
|
})();
|
|
316
405
|
</script>
|
|
317
406
|
</body>
|
package/src/vault.js
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
// Read and write markdown notes inside one folder. Every path that comes from the AI is
|
|
2
|
-
// resolved against the vault root and refused if it points outside it or isn't a .md file.
|
|
3
|
-
import fs from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
|
-
const SKIP = new Set(['.git', '.obsidian', '.trash', 'node_modules']);
|
|
7
|
-
const LINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
|
|
8
|
-
|
|
9
|
-
export class Vault {
|
|
10
|
-
constructor(root) {
|
|
11
|
-
this.root = path.resolve(root);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// "Notes/Plan" or "Notes/Plan.md" -> absolute path, or throw if it escapes the vault
|
|
15
|
-
resolve(rel) {
|
|
16
|
-
let p = String(rel || '').trim().replace(/\\/g, '/');
|
|
17
|
-
if (!p) throw new Error('Note path is empty.');
|
|
18
|
-
if (!p.toLowerCase().endsWith('.md')) p += '.md';
|
|
19
|
-
const abs = path.resolve(this.root, p);
|
|
20
|
-
if (abs !== this.root && !abs.startsWith(this.root + path.sep)) throw new Error(`"${rel}" is outside the vault.`);
|
|
21
|
-
if (abs.split(path.sep).some(part => SKIP.has(part))) throw new Error(`"${rel}" is in a folder Pathnote does not touch.`);
|
|
22
|
-
return abs;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Same check after following symlinks, so a link inside the vault can't lead outside it
|
|
26
|
-
async real(rel) {
|
|
27
|
-
const abs = this.resolve(rel);
|
|
28
|
-
const root = await fs.realpath(this.root);
|
|
29
|
-
let dir = abs;
|
|
30
|
-
for (;;) {
|
|
31
|
-
try { dir = await fs.realpath(dir); break; } catch (e) { if (e.code !== 'ENOENT') throw e; dir = path.dirname(dir); }
|
|
32
|
-
}
|
|
33
|
-
if (dir !== root && !dir.startsWith(root + path.sep)) throw new Error(`"${rel}" is outside the vault.`);
|
|
34
|
-
return abs;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
rel(abs) {
|
|
38
|
-
return path.relative(this.root, abs).split(path.sep).join('/');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async list() {
|
|
42
|
-
const out = [];
|
|
43
|
-
const walk = async dir => {
|
|
44
|
-
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
|
45
|
-
if (e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
46
|
-
const abs = path.join(dir, e.name);
|
|
47
|
-
if (e.isDirectory()) await walk(abs);
|
|
48
|
-
else if (e.name.toLowerCase().endsWith('.md')) out.push(this.rel(abs));
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
await walk(this.root);
|
|
52
|
-
return out.sort();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async read(rel) {
|
|
56
|
-
return fs.readFile(await this.real(rel), 'utf8');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Current text, or null when the note doesn't exist yet
|
|
60
|
-
async readOrNull(rel) {
|
|
61
|
-
try { return await this.read(rel); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async write(rel, text) {
|
|
65
|
-
const abs = await this.real(rel);
|
|
66
|
-
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
67
|
-
await fs.writeFile(abs, text, 'utf8');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async remove(rel) {
|
|
71
|
-
await fs.rm(await this.real(rel), { force: true });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Notes ranked by how often the query's words appear, title matches counted extra
|
|
75
|
-
async search(query, limit = 10) {
|
|
76
|
-
const words = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
77
|
-
if (!words.length) return [];
|
|
78
|
-
const hits = [];
|
|
79
|
-
for (const rel of await this.list()) {
|
|
80
|
-
const text = await this.read(rel);
|
|
81
|
-
const lower = text.toLowerCase(), name = rel.toLowerCase();
|
|
82
|
-
let score = 0;
|
|
83
|
-
for (const w of words) {
|
|
84
|
-
score += lower.split(w).length - 1;
|
|
85
|
-
if (name.includes(w)) score += 5;
|
|
86
|
-
}
|
|
87
|
-
if (!score) continue;
|
|
88
|
-
const at = Math.max(0, lower.indexOf(words.find(w => lower.includes(w)) ?? words[0]));
|
|
89
|
-
const snippet = text.slice(Math.max(0, at - 60), at + 140).replace(/\s+/g, ' ').trim();
|
|
90
|
-
hits.push({ path: rel, score, snippet });
|
|
91
|
-
}
|
|
92
|
-
return hits.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Nodes and [[wikilink]] edges for the viewer. Links resolve by file name, like Obsidian.
|
|
96
|
-
async graph() {
|
|
97
|
-
const notes = await this.list();
|
|
98
|
-
const byName = new Map(notes.map(n => [path.posix.basename(n, '.md').toLowerCase(), n]));
|
|
99
|
-
const edges = [];
|
|
100
|
-
for (const n of notes) {
|
|
101
|
-
const text = await this.read(n);
|
|
102
|
-
for (const m of text.matchAll(LINK)) {
|
|
103
|
-
const target = byName.get(path.posix.basename(m[1].trim()).toLowerCase());
|
|
104
|
-
if (target && target !== n) edges.push([n, target]);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return { nodes: notes, edges };
|
|
108
|
-
}
|
|
109
|
-
}
|