pathnote 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- .link { stroke: var(--line); stroke-width: 1; }
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 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; }
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="vault"></span>
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가 노트를 검색하거나 읽으면 여기에 기록이 쌓입니다.', empty: '이 폴더에 .md 노트가 없습니다.',
96
- hits: n => `${n}개 찾음`, created: '새 노트', graph: '노트 그래프와 AI가 지나간 경로'
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 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'
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
- let current = null, sources = new Set(), vb = { x: 0, y: 0, w: 1000, h: 700 };
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 name = p => p.split('/').pop().replace(/\.md$/i, '');
140
+ const base = p => p.split('/').filter(Boolean).pop() || p;
141
+ const parentOf = p => p.slice(0, p.lastIndexOf('/')) || '/';
122
142
 
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
+ // 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
- 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;
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
- nodes.forEach((id, i) => pos.set(id, [P[i].x, P[i].y]));
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 = 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}`);
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: '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);
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 { 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) {
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 note the AI created: place it next to the notes it read
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 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);
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, 'search_vault', `"${e.query}" · ${L.hits(e.hits.length)}`, live);
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
- 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);
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' ? 'write_note' : 'edit_note', e.created ? `${e.path} (${L.created})` : e.path, live);
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
- svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
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
- svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
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
- document.getElementById('vault').textContent = info.vault;
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>