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 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
- # Temporary Holding Version
1
+ # Pathnote
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ```
@@ -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,5 @@
1
+ # Ideas
2
+
3
+ - Keyboard-first capture
4
+ - Weekly digest email
5
+ - Templates gallery
@@ -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,3 @@
1
+ # Weekly review
2
+
3
+ Shipped the new search. Next week: start on onboarding research with [[Oct team meeting]] notes.
@@ -0,0 +1,3 @@
1
+ # Budget plan
2
+
3
+ Q1 budget allows one extra contractor for 8 weeks. See [[Product roadmap]].
@@ -0,0 +1,8 @@
1
+ # Product roadmap
2
+
3
+ Next quarter candidates:
4
+ 1. Guided onboarding (first-run checklist, import wizard)
5
+ 2. Annual pricing
6
+ 3. Team spaces
7
+
8
+ Inputs: [[Customer interviews]], [[Signup metrics]], [[Competitor comparison]]
@@ -0,0 +1,5 @@
1
+ # Sample vault
2
+
3
+ Example notes for trying Pathnote safely. Ask your AI something like:
4
+
5
+ "Go through the meeting notes and customer interviews and write next quarter's priorities as a new note."
@@ -0,0 +1,5 @@
1
+ # Competitor comparison
2
+
3
+ Two competitors ship an import wizard. Both offer annual plans at about 20% off.
4
+
5
+ See [[Product roadmap]].
@@ -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]].
@@ -0,0 +1,7 @@
1
+ # Signup metrics
2
+
3
+ - Visit to signup: 4.1%
4
+ - Signup to first note: 38% (this is where most people leave)
5
+ - First note to week-2 active: 61%
6
+
7
+ Linked from [[Product roadmap]].
@@ -0,0 +1,7 @@
1
+ # User feedback
2
+
3
+ > "I gave up on importing my notes the first day."
4
+ > "Once it was set up, I used it every day."
5
+ > "I'd pay yearly if it were cheaper than monthly."
6
+
7
+ Themes: setup friction, price per year. See [[Signup metrics]] for the drop-off numbers.
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "pathnote",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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>