pathnote 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +79 -2
- package/bin/pathnote.js +46 -0
- package/examples/sample-vault/Ideas.md +5 -0
- package/examples/sample-vault/Meetings/Oct team meeting.md +7 -0
- package/examples/sample-vault/Meetings/Weekly review.md +3 -0
- package/examples/sample-vault/Product/Budget plan.md +3 -0
- package/examples/sample-vault/Product/Product roadmap.md +8 -0
- package/examples/sample-vault/README.md +5 -0
- package/examples/sample-vault/Research/Competitor comparison.md +5 -0
- package/examples/sample-vault/Research/Customer interviews.md +9 -0
- package/examples/sample-vault/Research/Signup metrics.md +7 -0
- package/examples/sample-vault/Research/User feedback.md +7 -0
- package/package.json +37 -4
- package/src/history.js +50 -0
- package/src/hub.js +137 -0
- package/src/mcp.js +78 -0
- package/src/vault.js +109 -0
- package/viewer/index.html +318 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shinehyeuk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,80 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pathnote
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
See which notes your AI searched, read and wrote, live on your note graph.
|
|
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.
|
|
6
|
+
|
|
7
|
+
Free, MIT licensed. Runs entirely on your computer; nothing is sent anywhere by Pathnote.
|
|
8
|
+
|
|
9
|
+
## Try it
|
|
10
|
+
|
|
11
|
+
You need Node.js 20 or later.
|
|
12
|
+
|
|
13
|
+
Start with the sample notes so nothing of yours is touched:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx -y pathnote --sample ~/pathnote-sample
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Claude Desktop
|
|
20
|
+
|
|
21
|
+
Settings → Developer → Edit Config, and add Pathnote to `mcpServers`:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"pathnote": {
|
|
27
|
+
"command": "npx",
|
|
28
|
+
"args": ["-y", "pathnote", "/Users/you/pathnote-sample"]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Restart Claude Desktop, open http://127.0.0.1:4747 in your browser, and ask Claude something like:
|
|
35
|
+
|
|
36
|
+
> Go through the meeting notes and customer interviews and write next quarter's priorities as a new note.
|
|
37
|
+
|
|
38
|
+
### Claude Code
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
claude mcp add pathnote -- npx -y pathnote ~/pathnote-sample
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Cursor and other MCP clients
|
|
45
|
+
|
|
46
|
+
Use the same command: `npx -y pathnote <your notes folder>`.
|
|
47
|
+
|
|
48
|
+
## The viewer
|
|
49
|
+
|
|
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.
|
|
55
|
+
|
|
56
|
+
## Tools the AI gets
|
|
57
|
+
|
|
58
|
+
| Tool | What it does |
|
|
59
|
+
|---|---|
|
|
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) |
|
|
65
|
+
|
|
66
|
+
Each tool takes an optional `reason`, a short sentence the viewer shows next to the step.
|
|
67
|
+
|
|
68
|
+
## What Pathnote touches
|
|
69
|
+
|
|
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.
|
|
72
|
+
- The viewer only listens on 127.0.0.1 and refuses requests from other websites.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm install
|
|
78
|
+
npm test
|
|
79
|
+
node bin/pathnote.js examples/sample-vault --viewer
|
|
80
|
+
```
|
package/bin/pathnote.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pathnote <vault folder> [--port 4747] [--viewer]
|
|
3
|
+
// pathnote --sample <new folder> copies the sample notes there, to try Pathnote safely
|
|
4
|
+
// Started by an MCP client: MCP over stdin/stdout, plus the viewer at http://127.0.0.1:4747
|
|
5
|
+
// --viewer: only the viewer, for keeping it open while MCP clients come and go
|
|
6
|
+
// stdout belongs to MCP, so every message for people goes to stderr.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import { Vault } from '../src/vault.js';
|
|
10
|
+
import { History } from '../src/history.js';
|
|
11
|
+
import { Hub } from '../src/hub.js';
|
|
12
|
+
import { createServer } from '../src/mcp.js';
|
|
13
|
+
|
|
14
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url)));
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const flag = name => args.includes(name);
|
|
17
|
+
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
|
+
|
|
20
|
+
if (flag('--sample')) {
|
|
21
|
+
if (!folder) { console.error('Usage: pathnote --sample <new folder>'); process.exit(1); }
|
|
22
|
+
if (fs.existsSync(folder)) { console.error(`Pathnote: "${folder}" already exists. Pick a new folder.`); process.exit(1); }
|
|
23
|
+
fs.cpSync(new URL('../examples/sample-vault', import.meta.url), folder, { recursive: true });
|
|
24
|
+
console.error(`Sample notes copied to ${fs.realpathSync(folder)}`);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
if (!folder || flag('--help')) {
|
|
28
|
+
console.error(`Pathnote ${pkg.version}\n\nUsage: pathnote <vault folder> [--port 4747] [--viewer]\n pathnote --sample <new folder>`);
|
|
29
|
+
process.exit(folder ? 0 : 1);
|
|
30
|
+
}
|
|
31
|
+
if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) {
|
|
32
|
+
console.error(`Pathnote: "${folder}" is not a folder.`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const vault = new Vault(folder);
|
|
37
|
+
const history = new History(vault);
|
|
38
|
+
const hub = new Hub(vault, history, Number(opt('--port')) || 4747);
|
|
39
|
+
const url = await hub.start();
|
|
40
|
+
console.error(`Pathnote ${pkg.version}: vault ${vault.root}\nViewer: ${url}`);
|
|
41
|
+
|
|
42
|
+
if (!flag('--viewer')) {
|
|
43
|
+
await createServer({ vault, history, hub, version: pkg.version }).connect(new StdioServerTransport());
|
|
44
|
+
// The viewer server would keep the process alive after the MCP client quits
|
|
45
|
+
process.stdin.on('close', () => process.exit(0));
|
|
46
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Oct team meeting
|
|
2
|
+
|
|
3
|
+
- Onboarding: still no decision. Several people think setup takes too long.
|
|
4
|
+
- Pricing: proposal to drop the monthly-only plan. Parked until we hear from customers.
|
|
5
|
+
- Action: read through [[Customer interviews]] before the next planning session.
|
|
6
|
+
|
|
7
|
+
Related: [[Weekly review]], [[Budget plan]]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Customer interviews
|
|
2
|
+
|
|
3
|
+
Seven interviews in September.
|
|
4
|
+
|
|
5
|
+
- 5 of 7 said first-time setup was the hardest part.
|
|
6
|
+
- 3 of 7 asked for an annual plan.
|
|
7
|
+
- 2 of 7 compared us with the tools in [[Competitor comparison]].
|
|
8
|
+
|
|
9
|
+
Raw quotes are collected in [[User feedback]].
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pathnote",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"obsidian",
|
|
9
|
+
"markdown",
|
|
10
|
+
"notes",
|
|
11
|
+
"claude",
|
|
12
|
+
"ai",
|
|
13
|
+
"knowledge-graph"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://pathnote.pages.dev",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": {
|
|
18
|
+
"pathnote": "bin/pathnote.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin",
|
|
22
|
+
"src",
|
|
23
|
+
"viewer",
|
|
24
|
+
"examples",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
37
|
+
"zod": "^4.6.7"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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.
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
|
|
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);
|
|
12
|
+
this.file = path.join(os.homedir(), '.pathnote', id, 'history.jsonl');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async all() {
|
|
16
|
+
try {
|
|
17
|
+
return (await fs.readFile(this.file, 'utf8')).split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
18
|
+
} catch (e) {
|
|
19
|
+
if (e.code === 'ENOENT') return [];
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async append(entry) {
|
|
25
|
+
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
|
26
|
+
await fs.appendFile(this.file, JSON.stringify(entry) + '\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Call before changing a note. `before` is null for a note that didn't exist.
|
|
30
|
+
async record(rel, before, after, kind) {
|
|
31
|
+
const entry = { id: crypto.randomUUID(), t: Date.now(), path: rel, kind, before, after };
|
|
32
|
+
await this.append(entry);
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Put the note back the way it was before change `id`. Refuses if someone edited it since,
|
|
37
|
+
// so a user's own later edits are never thrown away.
|
|
38
|
+
async undo(id) {
|
|
39
|
+
const all = await this.all();
|
|
40
|
+
const entry = all.find(e => e.id === id);
|
|
41
|
+
if (!entry) throw new Error('No such change.');
|
|
42
|
+
if (all.some(e => e.undoes === id)) throw new Error('Already undone.');
|
|
43
|
+
const now = await this.vault.readOrNull(entry.path);
|
|
44
|
+
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);
|
|
47
|
+
await this.append({ id: crypto.randomUUID(), t: Date.now(), path: entry.path, kind: 'undo', undoes: id });
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/hub.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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
|
|
3
|
+
// viewer port and the others forward their events to it.
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const PAGE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'viewer', 'index.html');
|
|
10
|
+
const KEEP = 500;
|
|
11
|
+
|
|
12
|
+
export class Hub {
|
|
13
|
+
constructor(vault, history, port) {
|
|
14
|
+
this.vault = vault;
|
|
15
|
+
this.history = history;
|
|
16
|
+
this.port = port;
|
|
17
|
+
this.events = [];
|
|
18
|
+
this.clients = new Set();
|
|
19
|
+
this.forwardTo = null; // port of another process that owns the viewer
|
|
20
|
+
this.boot = Date.now().toString(36); // with seq, lets the page skip events it already has
|
|
21
|
+
this.seq = 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Own a port for this vault, or find the process that already does. Returns the viewer URL.
|
|
25
|
+
async start() {
|
|
26
|
+
for (let port = this.port; port < this.port + 10; port++) {
|
|
27
|
+
const other = await this.info(port);
|
|
28
|
+
if (other?.vault === this.vault.root) { this.forwardTo = port; return `http://127.0.0.1:${port}`; }
|
|
29
|
+
if (other) continue;
|
|
30
|
+
try {
|
|
31
|
+
await this.listen(port);
|
|
32
|
+
this.port = port;
|
|
33
|
+
return `http://127.0.0.1:${port}`;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e.code !== 'EADDRINUSE') throw e;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`No free port between ${this.port} and ${this.port + 9}.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async info(port) {
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/info`, { signal: AbortSignal.timeout(500) });
|
|
44
|
+
const body = await res.json();
|
|
45
|
+
return body.app === 'pathnote' ? body : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
listen(port) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const server = http.createServer((req, res) => this.handle(req, res).catch(e => send(res, 500, { error: e.message })));
|
|
54
|
+
server.once('error', reject);
|
|
55
|
+
server.listen(port, '127.0.0.1', () => { this.server = server; resolve(); });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async emit(event, retried = false) {
|
|
60
|
+
const e = { t: Date.now(), ...event };
|
|
61
|
+
if (this.forwardTo) {
|
|
62
|
+
try {
|
|
63
|
+
await fetch(`http://127.0.0.1:${this.forwardTo}/api/event`, {
|
|
64
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', Origin: `http://127.0.0.1:${this.forwardTo}` },
|
|
65
|
+
body: JSON.stringify(e), signal: AbortSignal.timeout(1000)
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
} catch {
|
|
69
|
+
// The process that owned the viewer has quit: take the port over and keep showing events
|
|
70
|
+
this.forwardTo = null;
|
|
71
|
+
if (retried) return;
|
|
72
|
+
try { await this.start(); } catch { return; }
|
|
73
|
+
if (this.forwardTo) return this.emit(event, true);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.broadcast(e);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
broadcast(event) {
|
|
80
|
+
const e = { ...event, key: `${this.boot}:${++this.seq}` };
|
|
81
|
+
this.events.push(e);
|
|
82
|
+
if (this.events.length > KEEP) this.events.shift();
|
|
83
|
+
for (const res of this.clients) res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Only pages served from this port may call the API, so other websites can't trigger undo.
|
|
87
|
+
allowed(req) {
|
|
88
|
+
const hosts = [`127.0.0.1:${this.port}`, `localhost:${this.port}`];
|
|
89
|
+
if (!hosts.includes(req.headers.host)) return false;
|
|
90
|
+
const origin = req.headers.origin;
|
|
91
|
+
return req.method === 'GET' ? !origin || hosts.some(h => origin === `http://${h}`) : hosts.some(h => origin === `http://${h}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async handle(req, res) {
|
|
95
|
+
if (!this.allowed(req)) return send(res, 403, { error: 'forbidden' });
|
|
96
|
+
const url = new URL(req.url, 'http://x');
|
|
97
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
98
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
99
|
+
return res.end(await fs.readFile(PAGE));
|
|
100
|
+
}
|
|
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());
|
|
103
|
+
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
104
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
105
|
+
for (const e of this.events) res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
106
|
+
this.clients.add(res);
|
|
107
|
+
req.on('close', () => this.clients.delete(res));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (req.method === 'POST' && url.pathname === '/api/event') {
|
|
111
|
+
this.broadcast(await body(req));
|
|
112
|
+
return send(res, 200, { ok: true });
|
|
113
|
+
}
|
|
114
|
+
if (req.method === 'POST' && url.pathname === '/api/undo') {
|
|
115
|
+
const { id } = await body(req);
|
|
116
|
+
try {
|
|
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 });
|
|
119
|
+
return send(res, 200, { ok: true });
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return send(res, 409, { error: e.message });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
send(res, 404, { error: 'not found' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function send(res, status, data) {
|
|
129
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify(data));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function body(req) {
|
|
134
|
+
let s = '';
|
|
135
|
+
for await (const chunk of req) { s += chunk; if (s.length > 1e6) throw new Error('too large'); }
|
|
136
|
+
return JSON.parse(s || '{}');
|
|
137
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The MCP tools the AI uses. Each call does its work and tells the viewer what happened.
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const reason = z.string().optional().describe('One short sentence on why you are doing this. Shown to the user in the Pathnote viewer.');
|
|
6
|
+
const text = s => ({ content: [{ type: 'text', text: s }] });
|
|
7
|
+
const fail = e => ({ content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
|
|
8
|
+
|
|
9
|
+
export function createServer({ vault, history, hub, version }) {
|
|
10
|
+
const server = new McpServer({ name: 'pathnote', version });
|
|
11
|
+
|
|
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); }
|
|
18
|
+
});
|
|
19
|
+
|
|
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.',
|
|
23
|
+
inputSchema: { query: z.string().describe('Words to look for'), reason }
|
|
24
|
+
}, async ({ query, reason }) => {
|
|
25
|
+
try {
|
|
26
|
+
const hits = await vault.search(query);
|
|
27
|
+
await hub.emit({ type: 'search', query, hits: hits.map(h => h.path), reason });
|
|
28
|
+
return text(hits.length ? hits.map(h => `${h.path}\n ${h.snippet}`).join('\n') : 'No matches.');
|
|
29
|
+
} catch (e) { return fail(e); }
|
|
30
|
+
});
|
|
31
|
+
|
|
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.',
|
|
35
|
+
inputSchema: { path: z.string(), reason }
|
|
36
|
+
}, async ({ path, reason }) => {
|
|
37
|
+
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);
|
|
41
|
+
} catch (e) { return fail(e); }
|
|
42
|
+
});
|
|
43
|
+
|
|
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]].',
|
|
47
|
+
inputSchema: { path: z.string(), content: z.string(), reason }
|
|
48
|
+
}, async ({ path, content, reason }) => {
|
|
49
|
+
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}.`);
|
|
56
|
+
} catch (e) { return fail(e); }
|
|
57
|
+
});
|
|
58
|
+
|
|
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.',
|
|
62
|
+
inputSchema: { path: z.string(), find: z.string().min(1), replace: z.string(), reason }
|
|
63
|
+
}, async ({ path, find, replace, reason }) => {
|
|
64
|
+
try {
|
|
65
|
+
const rel = vault.rel(vault.resolve(path));
|
|
66
|
+
const before = await vault.read(rel);
|
|
67
|
+
const count = before.split(find).length - 1;
|
|
68
|
+
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
|
+
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}.`);
|
|
74
|
+
} catch (e) { return fail(e); }
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return server;
|
|
78
|
+
}
|
package/src/vault.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Pathnote</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #ffffff; --surface: #f5f5f7; --ink: #1d1d1f; --muted: #6e6e73; --line: #d2d2d7; --node: #c7c7cc;
|
|
10
|
+
--accent: #0066cc; --accent-soft: rgba(0, 102, 204, 0.12); --warn: #b3261e;
|
|
11
|
+
--font: "SF Pro Text", system-ui, -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", Inter, sans-serif;
|
|
12
|
+
--mono: ui-monospace, "SF Mono", Menlo, monospace;
|
|
13
|
+
color-scheme: light;
|
|
14
|
+
}
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root { --bg: #000; --surface: #1d1d1f; --ink: #f5f5f7; --muted: #a1a1a6; --line: #3a3a3c; --node: #48484a;
|
|
17
|
+
--accent: #2997ff; --accent-soft: rgba(41, 151, 255, 0.16); --warn: #ff6b5b; color-scheme: dark; }
|
|
18
|
+
}
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
[hidden] { display: none !important; }
|
|
21
|
+
html, body { height: 100%; margin: 0; }
|
|
22
|
+
body { background: var(--bg); color: var(--ink); font: 400 14px/1.43 var(--font); display: flex; flex-direction: column; }
|
|
23
|
+
.bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
|
24
|
+
.bar strong { font-weight: 600; }
|
|
25
|
+
.vault { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
|
|
26
|
+
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 4px 10px; border-radius: 9999px; background: var(--surface); color: var(--muted); }
|
|
27
|
+
.chip i { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
|
|
28
|
+
.chip.on { background: var(--accent-soft); color: var(--accent); }
|
|
29
|
+
.chip.on i { background: var(--accent); }
|
|
30
|
+
.main { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 340px; }
|
|
31
|
+
.graph { position: relative; min-width: 0; min-height: 0; border-right: 1px solid var(--line); }
|
|
32
|
+
svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
|
|
33
|
+
svg.drag { cursor: grabbing; }
|
|
34
|
+
.empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; pointer-events: none; }
|
|
35
|
+
.log { display: flex; flex-direction: column; min-height: 0; }
|
|
36
|
+
.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; }
|
|
37
|
+
ol { list-style: none; margin: 0; padding: 8px 0; flex: 1; min-height: 0; overflow-y: auto; }
|
|
38
|
+
li { padding: 8px 16px; display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; }
|
|
39
|
+
li.now { background: var(--accent-soft); }
|
|
40
|
+
.tool { font: 500 12px var(--mono); color: var(--accent); }
|
|
41
|
+
.undo .tool { color: var(--muted); }
|
|
42
|
+
time { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
43
|
+
.text { grid-column: 1 / -1; overflow-wrap: anywhere; }
|
|
44
|
+
.why { grid-column: 1 / -1; color: var(--muted); }
|
|
45
|
+
.err { grid-column: 1 / -1; color: var(--warn); font-size: 12px; }
|
|
46
|
+
li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
|
|
47
|
+
.btn { font: 400 13px/1 var(--font); min-height: 30px; padding: 0 14px; border-radius: 9999px; border: 1px solid var(--accent); background: transparent; color: var(--accent); cursor: pointer; }
|
|
48
|
+
.btn:active { transform: scale(0.95); }
|
|
49
|
+
.btn:focus-visible { outline: 2px solid #0071e3; outline-offset: 2px; }
|
|
50
|
+
.btn:disabled { opacity: .4; cursor: default; }
|
|
51
|
+
.hint { padding: 16px; color: var(--muted); }
|
|
52
|
+
|
|
53
|
+
.link { stroke: var(--line); stroke-width: 1; }
|
|
54
|
+
.trail { stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; transition: stroke-dashoffset .6s ease-in-out, opacity .4s; }
|
|
55
|
+
.src { stroke: var(--accent); stroke-width: 1.5; opacity: .6; }
|
|
56
|
+
.node .dot { fill: var(--node); transition: fill .3s; }
|
|
57
|
+
.node text { font: 400 11px var(--font); fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 4px; stroke-linejoin: round; pointer-events: none; }
|
|
58
|
+
.node .ring { fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 3 3; opacity: 0; }
|
|
59
|
+
.node.hit .ring { opacity: 1; }
|
|
60
|
+
.node.visited .dot, .node.written .dot { fill: var(--accent); }
|
|
61
|
+
.node.visited text, .node.written text { fill: var(--ink); font-weight: 600; }
|
|
62
|
+
.node.current .pulse { fill: var(--accent); animation: pulse 1.2s ease-out infinite; transform-box: fill-box; transform-origin: center; }
|
|
63
|
+
.node .pulse { opacity: 0; }
|
|
64
|
+
.node.gone { opacity: .25; }
|
|
65
|
+
.node.gone text { text-decoration: line-through; }
|
|
66
|
+
@keyframes pulse { from { transform: scale(1); opacity: .35; } to { transform: scale(3.2); opacity: 0; } }
|
|
67
|
+
@media (max-width: 760px) {
|
|
68
|
+
.main { grid-template-columns: 1fr; grid-template-rows: 55% 45%; }
|
|
69
|
+
.graph { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
70
|
+
}
|
|
71
|
+
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<div class="bar">
|
|
76
|
+
<strong>Pathnote</strong>
|
|
77
|
+
<span class="vault" id="vault"></span>
|
|
78
|
+
<span class="chip" id="status"><i></i><span id="statusText"></span></span>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="main">
|
|
81
|
+
<div class="graph">
|
|
82
|
+
<svg id="g" role="img"></svg>
|
|
83
|
+
<div class="empty" id="empty" hidden></div>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="log">
|
|
86
|
+
<h2><span id="logTitle"></span><button class="btn" id="clear" type="button"></button></h2>
|
|
87
|
+
<ol id="log" aria-live="polite"></ol>
|
|
88
|
+
<p class="hint" id="hint"></p>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<script>
|
|
92
|
+
const ko = (navigator.language || '').toLowerCase().startsWith('ko');
|
|
93
|
+
const L = ko ? {
|
|
94
|
+
live: '연결됨', off: '연결 끊김', activity: '작업 기록', clear: '경로 지우기', undo: '되돌리기', undone: '되돌림',
|
|
95
|
+
wait: 'AI가 노트를 검색하거나 읽으면 여기에 기록이 쌓입니다.', empty: '이 폴더에 .md 노트가 없습니다.',
|
|
96
|
+
hits: n => `${n}개 찾음`, created: '새 노트', graph: '노트 그래프와 AI가 지나간 경로'
|
|
97
|
+
} : {
|
|
98
|
+
live: 'Live', off: 'Disconnected', activity: 'Activity', clear: 'Clear path', undo: 'Undo', undone: 'undone',
|
|
99
|
+
wait: 'When the AI searches or reads your notes, it shows up here.', empty: 'No .md notes in this folder.',
|
|
100
|
+
hits: n => `${n} found`, created: 'new note', graph: 'Note graph with the path the AI took'
|
|
101
|
+
};
|
|
102
|
+
document.documentElement.lang = ko ? 'ko' : 'en';
|
|
103
|
+
document.getElementById('logTitle').textContent = L.activity;
|
|
104
|
+
document.getElementById('clear').textContent = L.clear;
|
|
105
|
+
document.getElementById('hint').textContent = L.wait;
|
|
106
|
+
|
|
107
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
108
|
+
const svg = document.getElementById('g');
|
|
109
|
+
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);
|
|
112
|
+
svg.append(view);
|
|
113
|
+
const pos = new Map(), nodeEl = new Map();
|
|
114
|
+
let current = null, sources = new Set(), vb = { x: 0, y: 0, w: 1000, h: 700 };
|
|
115
|
+
|
|
116
|
+
function el(tag, attrs = {}) {
|
|
117
|
+
const e = document.createElementNS(NS, tag);
|
|
118
|
+
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
|
119
|
+
return e;
|
|
120
|
+
}
|
|
121
|
+
const name = p => p.split('/').pop().replace(/\.md$/i, '');
|
|
122
|
+
|
|
123
|
+
// Small force layout: nodes push apart, links pull together, everything drifts to the middle
|
|
124
|
+
function layout(nodes, edges) {
|
|
125
|
+
const n = nodes.length, P = nodes.map((_, i) => {
|
|
126
|
+
const a = i * 2.39996, r = 30 * Math.sqrt(i + 1);
|
|
127
|
+
return { x: r * Math.cos(a), y: r * Math.sin(a), vx: 0, vy: 0 };
|
|
128
|
+
});
|
|
129
|
+
const idx = new Map(nodes.map((id, i) => [id, i]));
|
|
130
|
+
const E = edges.map(([a, b]) => [idx.get(a), idx.get(b)]);
|
|
131
|
+
const steps = n > 600 ? 80 : 250;
|
|
132
|
+
for (let s = 0; s < steps; s++) {
|
|
133
|
+
const cool = 1 - s / steps;
|
|
134
|
+
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
|
|
135
|
+
const dx = P[i].x - P[j].x, dy = P[i].y - P[j].y, d2 = dx * dx + dy * dy + 0.01;
|
|
136
|
+
if (d2 > 90000) continue;
|
|
137
|
+
const f = 1800 / d2;
|
|
138
|
+
P[i].vx += dx * f; P[i].vy += dy * f; P[j].vx -= dx * f; P[j].vy -= dy * f;
|
|
139
|
+
}
|
|
140
|
+
for (const [a, b] of E) {
|
|
141
|
+
const dx = P[b].x - P[a].x, dy = P[b].y - P[a].y, d = Math.hypot(dx, dy) + 0.01, f = (d - 90) * 0.02 / d;
|
|
142
|
+
P[a].vx += dx * f; P[a].vy += dy * f; P[b].vx -= dx * f; P[b].vy -= dy * f;
|
|
143
|
+
}
|
|
144
|
+
for (const p of P) {
|
|
145
|
+
p.vx -= p.x * 0.004; p.vy -= p.y * 0.004;
|
|
146
|
+
p.x += Math.max(-20, Math.min(20, p.vx)) * cool; p.y += Math.max(-20, Math.min(20, p.vy)) * cool;
|
|
147
|
+
p.vx *= 0.5; p.vy *= 0.5;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
nodes.forEach((id, i) => pos.set(id, [P[i].x, P[i].y]));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function fit() {
|
|
154
|
+
const pts = [...pos.values()];
|
|
155
|
+
if (!pts.length) return;
|
|
156
|
+
const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]);
|
|
157
|
+
const pad = 60, x0 = Math.min(...xs) - pad, y0 = Math.min(...ys) - pad;
|
|
158
|
+
vb = { x: x0, y: y0, w: Math.max(...xs) + pad - x0, h: Math.max(...ys) + pad - y0 };
|
|
159
|
+
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function addNode(id) {
|
|
163
|
+
const [x, y] = pos.get(id);
|
|
164
|
+
const g = el('g', { class: 'node' });
|
|
165
|
+
g.append(el('circle', { class: 'pulse', cx: x, cy: y, r: 7 }), el('circle', { class: 'ring', cx: x, cy: y, r: 13 }),
|
|
166
|
+
el('circle', { class: 'dot', cx: x, cy: y, r: 6 }));
|
|
167
|
+
const t = el('text', { x, y: y + 22, 'text-anchor': 'middle' });
|
|
168
|
+
t.textContent = name(id);
|
|
169
|
+
const title = el('title'); title.textContent = id;
|
|
170
|
+
g.append(t, title);
|
|
171
|
+
gNodes.append(g);
|
|
172
|
+
nodeEl.set(id, g);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function loadGraph() {
|
|
176
|
+
const { nodes, edges } = await (await fetch('/api/graph')).json();
|
|
177
|
+
document.getElementById('empty').hidden = nodes.length > 0;
|
|
178
|
+
document.getElementById('empty').textContent = L.empty;
|
|
179
|
+
layout(nodes, edges);
|
|
180
|
+
for (const [a, b] of edges) {
|
|
181
|
+
const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
|
|
182
|
+
gLinks.append(el('line', { class: 'link', x1, y1, x2, y2 }));
|
|
183
|
+
}
|
|
184
|
+
nodes.forEach(addNode);
|
|
185
|
+
fit();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function line(cls, a, b, animate) {
|
|
189
|
+
const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
|
|
190
|
+
const l = el('line', { class: cls, x1, y1, x2, y2 });
|
|
191
|
+
l.dataset.to = b;
|
|
192
|
+
if (animate) {
|
|
193
|
+
const len = Math.hypot(x2 - x1, y2 - y1);
|
|
194
|
+
l.style.strokeDasharray = len; l.style.strokeDashoffset = len;
|
|
195
|
+
gTrail.append(l); l.getBoundingClientRect(); l.style.strokeDashoffset = 0;
|
|
196
|
+
} else gTrail.append(l);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function setCurrent(id) {
|
|
200
|
+
if (current) nodeEl.get(current)?.classList.remove('current');
|
|
201
|
+
current = id;
|
|
202
|
+
nodeEl.get(id)?.classList.add('current');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A note the AI created: place it next to the notes it read
|
|
206
|
+
function ensureNode(id) {
|
|
207
|
+
if (pos.has(id)) return;
|
|
208
|
+
const src = [...sources].map(s => pos.get(s)).filter(Boolean);
|
|
209
|
+
const cx = src.length ? src.reduce((a, p) => a + p[0], 0) / src.length : vb.x + vb.w / 2;
|
|
210
|
+
const cy = src.length ? src.reduce((a, p) => a + p[1], 0) / src.length : vb.y + vb.h / 2;
|
|
211
|
+
pos.set(id, [cx + 50, cy - 40]);
|
|
212
|
+
addNode(id);
|
|
213
|
+
document.getElementById('empty').hidden = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const logEl = document.getElementById('log');
|
|
217
|
+
function addLog(e, tool, text, live) {
|
|
218
|
+
document.getElementById('hint').hidden = true;
|
|
219
|
+
for (const li of logEl.children) li.classList.remove('now');
|
|
220
|
+
const li = document.createElement('li');
|
|
221
|
+
if (live) li.classList.add('now');
|
|
222
|
+
if (e.type === 'undo') li.classList.add('undo');
|
|
223
|
+
const a = document.createElement('span'); a.className = 'tool'; a.textContent = tool;
|
|
224
|
+
const tm = document.createElement('time'); tm.textContent = new Date(e.t).toLocaleTimeString();
|
|
225
|
+
const b = document.createElement('span'); b.className = 'text'; b.textContent = text;
|
|
226
|
+
li.append(a, tm, b);
|
|
227
|
+
if (e.reason) { const w = document.createElement('span'); w.className = 'why'; w.textContent = e.reason; li.append(w); }
|
|
228
|
+
if (e.changeId && e.type !== 'undo') {
|
|
229
|
+
const btn = document.createElement('button');
|
|
230
|
+
btn.className = 'btn'; btn.type = 'button'; btn.textContent = L.undo; btn.dataset.change = e.changeId;
|
|
231
|
+
btn.onclick = async () => {
|
|
232
|
+
btn.disabled = true;
|
|
233
|
+
const res = await fetch('/api/undo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: e.changeId }) });
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
const err = document.createElement('span'); err.className = 'err';
|
|
236
|
+
err.textContent = (await res.json()).error; li.append(err); btn.disabled = false;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
li.append(btn);
|
|
240
|
+
}
|
|
241
|
+
logEl.append(li);
|
|
242
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function onEvent(e, live) {
|
|
246
|
+
if (e.type === 'search') {
|
|
247
|
+
for (const h of e.hits) nodeEl.get(h)?.classList.add('hit');
|
|
248
|
+
addLog(e, 'search_vault', `"${e.query}" · ${L.hits(e.hits.length)}`, live);
|
|
249
|
+
} else if (e.type === 'read') {
|
|
250
|
+
const g = nodeEl.get(e.path);
|
|
251
|
+
if (g) {
|
|
252
|
+
if (current && current !== e.path) line('trail', current, e.path, live);
|
|
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);
|
|
257
|
+
} else if (e.type === 'write' || e.type === 'edit') {
|
|
258
|
+
ensureNode(e.path);
|
|
259
|
+
for (const s of sources) if (s !== e.path) line('src', s, e.path, live);
|
|
260
|
+
const g = nodeEl.get(e.path);
|
|
261
|
+
g.classList.remove('gone'); g.classList.add('written');
|
|
262
|
+
setCurrent(e.path);
|
|
263
|
+
addLog(e, e.type === 'write' ? 'write_note' : 'edit_note', e.created ? `${e.path} (${L.created})` : e.path, live);
|
|
264
|
+
} else if (e.type === 'undo') {
|
|
265
|
+
if (e.created) {
|
|
266
|
+
nodeEl.get(e.path)?.classList.add('gone');
|
|
267
|
+
gTrail.querySelectorAll(`[data-to="${CSS.escape(e.path)}"]`).forEach(l => l.style.opacity = 0);
|
|
268
|
+
}
|
|
269
|
+
logEl.querySelectorAll(`[data-change="${e.changeId}"]`).forEach(b => { b.disabled = true; b.textContent = L.undone; });
|
|
270
|
+
addLog(e, 'undo', e.path, live);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
document.getElementById('clear').onclick = () => {
|
|
275
|
+
gTrail.textContent = '';
|
|
276
|
+
for (const g of nodeEl.values()) g.classList.remove('hit', 'visited', 'current', 'written');
|
|
277
|
+
current = null; sources = new Set();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Drag to pan, wheel to zoom
|
|
281
|
+
let drag = null;
|
|
282
|
+
svg.addEventListener('pointerdown', e => { drag = { x: e.clientX, y: e.clientY, vb: { ...vb } }; svg.classList.add('drag'); svg.setPointerCapture(e.pointerId); });
|
|
283
|
+
svg.addEventListener('pointermove', e => {
|
|
284
|
+
if (!drag) return;
|
|
285
|
+
const k = vb.w / svg.clientWidth;
|
|
286
|
+
vb.x = drag.vb.x - (e.clientX - drag.x) * k; vb.y = drag.vb.y - (e.clientY - drag.y) * k;
|
|
287
|
+
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
|
|
288
|
+
});
|
|
289
|
+
svg.addEventListener('pointerup', () => { drag = null; svg.classList.remove('drag'); });
|
|
290
|
+
svg.addEventListener('wheel', e => {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
const k = Math.exp(e.deltaY * 0.0015), r = svg.getBoundingClientRect();
|
|
293
|
+
const mx = vb.x + (e.clientX - r.left) / r.width * vb.w, my = vb.y + (e.clientY - r.top) / r.height * vb.h;
|
|
294
|
+
vb = { x: mx - (mx - vb.x) * k, y: my - (my - vb.y) * k, w: vb.w * k, h: vb.h * k };
|
|
295
|
+
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
|
|
296
|
+
}, { passive: false });
|
|
297
|
+
|
|
298
|
+
(async () => {
|
|
299
|
+
const info = await (await fetch('/api/info')).json();
|
|
300
|
+
document.getElementById('vault').textContent = info.vault;
|
|
301
|
+
await loadGraph();
|
|
302
|
+
const status = document.getElementById('status'), statusText = document.getElementById('statusText');
|
|
303
|
+
let replay = true;
|
|
304
|
+
const es = new EventSource('/api/events');
|
|
305
|
+
es.onopen = () => { status.classList.add('on'); statusText.textContent = L.live; setTimeout(() => { replay = false; }, 300); };
|
|
306
|
+
es.onerror = () => { status.classList.remove('on'); statusText.textContent = L.off; };
|
|
307
|
+
const seen = new Set(); // a reconnect replays events the page already showed
|
|
308
|
+
es.onmessage = m => {
|
|
309
|
+
const e = JSON.parse(m.data);
|
|
310
|
+
if (seen.has(e.key)) return;
|
|
311
|
+
seen.add(e.key);
|
|
312
|
+
onEvent(e, !replay);
|
|
313
|
+
};
|
|
314
|
+
statusText.textContent = L.off;
|
|
315
|
+
})();
|
|
316
|
+
</script>
|
|
317
|
+
</body>
|
|
318
|
+
</html>
|