diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2c9795..df1f71c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: (cd test/e2e/site && python3 -m http.server 8765 >/dev/null 2>&1 &) sleep 1 mkdir -p e2e-out - for t in 01-basics 03-rules-and-fast-results 04-find-zoom-history 05-research-session 09-playback-limit 12-pip-bookmarks-adblock 13-ask-ai 15-agents-mcp 16-research-agent 17-talk 18-redteam 19-browser-basics 20-first-run-and-help 21-rules-editor 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance; do + for t in 01-basics 03-rules-and-fast-results 04-find-zoom-history 05-research-session 09-playback-limit 12-pip-bookmarks-adblock 13-ask-ai 15-agents-mcp 16-research-agent 17-talk 18-redteam 19-browser-basics 20-first-run-and-help 21-rules-editor 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall; do echo "::group::$t" xvfb-run -a node test/e2e/$t.js "$PWD/e2e-out" 2>&1 | tee e2e-out/$t.log status=${PIPESTATUS[0]} @@ -71,7 +71,7 @@ jobs: (cd test/e2e/site && python -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 &) sleep 2 mkdir -p e2e-out - for t in 01-basics 05-research-session 19-browser-basics 20-first-run-and-help 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance; do + for t in 01-basics 05-research-session 19-browser-basics 20-first-run-and-help 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall; do echo "::group::$t" node test/e2e/$t.js "$PWD/e2e-out" 2>&1 | tee e2e-out/$t.log status=${PIPESTATUS[0]} diff --git a/core/recall.js b/core/recall.js new file mode 100644 index 0000000..bfc36d1 --- /dev/null +++ b/core/recall.js @@ -0,0 +1,323 @@ +// Find things in past trails by what the person remembers (B89): words, +// a time ("yesterday", "last Tuesday", "in October"), an order ("after the +// Discogs page", "before …", "around …"), a kind ("video", "question", +// "note") and a trail ("in Rye breads"). Local rules only, no AI; nothing +// leaves the computer. No platform imports. +// +// parseRecall(text, { now, trails }) → { words, range, order, kinds, trail } +// recallIndex(store) → entries from every trail, oldest first in each trail +// recall(entries, text, { now, trails, limit }) → results, best first + +const DAY = 86400000; +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; +const SHORT_WD = WEEKDAYS.map((d) => d.slice(0, 3)); +const SHORT_MO = MONTHS.map((m) => m.slice(0, 3)); +// Words that say nothing about which page it was. +const STOP = new Set( + "a an the that this those these one ones page pages site sites website article post thing something stuff i me my we our was were is it its of on at to for from with about some any where what which when did saw seen read found looked viewed visited opened watched listened there".split(" ") +); +// Kind words: what sort of thing it was. +const KINDS = { + video: "video", videos: "video", clip: "video", watched: "video", + song: "audio", track: "audio", audio: "audio", podcast: "audio", listened: "audio", + question: "question", questions: "question", thread: "question", threads: "question", + note: "note", notes: "note", + search: "search", searches: "search", searched: "search", + image: "image", images: "image", photo: "image", picture: "image", screenshot: "image", +}; +const VIDEO_HOSTS = /(^|\.)(youtube\.com|youtu\.be|vimeo\.com|twitch\.tv|dailymotion\.com|tiktok\.com)$/; +const AUDIO_HOSTS = /(^|\.)(soundcloud\.com|bandcamp\.com|mixcloud\.com|open\.spotify\.com|music\.apple\.com)$/; + +const lower = (s) => String(s || "").toLowerCase(); +const startOfDay = (t) => { + const d = new Date(t); + d.setHours(0, 0, 0, 0); + return d.getTime(); +}; +const addDays = (t, n) => { + const d = new Date(t); + d.setDate(d.getDate() + n); + return d.getTime(); +}; +function hostOf(url) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch (_) { + return ""; + } +} +function tokens(s) { + return lower(s) + .replace(/[“”"']/g, " ") + .split(/[^\p{L}\p{N}.+#-]+/u) + .map((w) => w.replace(/^[.-]+|[.-]+$/g, "")) + .filter(Boolean); +} + +// Time phrases at the given word position. Returns { range: [from, to), n } +// where n is how many words it used, or null. +function timeAt(w, i, now) { + const today = startOfDay(now); + const a = w[i], b = w[i + 1], c = w[i + 2]; + const day = (start) => [start, addDays(start, 1)]; + if (a === "today") return { range: day(today), n: 1 }; + if (a === "yesterday") return { range: day(addDays(today, -1)), n: 1 }; + if (a === "tonight") return { range: [today + 17 * 3600000, addDays(today, 1)], n: 1 }; + if (a === "this" && ["morning", "afternoon", "evening"].includes(b)) { + const h = { morning: [5, 12], afternoon: [12, 17], evening: [17, 24] }[b]; + return { range: [today + h[0] * 3600000, today + h[1] * 3600000], n: 2 }; + } + if (a === "last" && b === "night") return { range: [addDays(today, -1) + 17 * 3600000, today + 5 * 3600000], n: 2 }; + const wd = (x) => { + const k = WEEKDAYS.indexOf(x) >= 0 ? WEEKDAYS.indexOf(x) : SHORT_WD.indexOf(x); + return x && k >= 0 ? k : -1; + }; + // "last tuesday": the latest Tuesday before today. "tuesday", "on tuesday": + // the latest Tuesday, today included. + if ((a === "last" || a === "on") && wd(b) >= 0) { + const back = (new Date(today).getDay() - wd(b) + 7) % 7 || (a === "last" ? 7 : 0); + return { range: day(addDays(today, -back)), n: 2 }; + } + if (wd(a) >= 0 && a.length > 3) { + const back = (new Date(today).getDay() - wd(a) + 7) % 7; + return { range: day(addDays(today, -back)), n: 1 }; + } + const monday = addDays(today, -((new Date(today).getDay() + 6) % 7)); + if (a === "this" && b === "week") return { range: [monday, addDays(today, 1)], n: 2 }; + if (a === "last" && b === "week") return { range: [addDays(monday, -7), monday], n: 2 }; + const first = (() => { + const d = new Date(today); + d.setDate(1); + return d.getTime(); + })(); + const monthStart = (t, k) => { + const d = new Date(t); + d.setMonth(d.getMonth() + k, 1); + return startOfDay(d.getTime()); + }; + if (a === "this" && b === "month") return { range: [first, addDays(today, 1)], n: 2 }; + if (a === "last" && b === "month") return { range: [monthStart(first, -1), first], n: 2 }; + // "in october", "october": the latest October, this month included. + const mo = (x) => { + const k = MONTHS.indexOf(x) >= 0 ? MONTHS.indexOf(x) : x && x.length >= 3 && x !== "may" ? SHORT_MO.indexOf(x) : -1; + return x === "may" ? 4 : k; + }; + const monthRange = (k) => { + const d = new Date(today); + let y = d.getFullYear(); + if (k > d.getMonth()) y -= 1; + const s = new Date(y, k, 1).getTime(); + return [s, new Date(y, k + 1, 1).getTime()]; + }; + if (a === "in" && mo(b) >= 0) return { range: monthRange(mo(b)), n: 2 }; + if (mo(a) >= 0 && a !== "may") return { range: monthRange(mo(a)), n: 1 }; + // "3 days ago", "2 weeks ago", "a few days ago", "a week ago" + const num = (x) => (/^\d+$/.test(x || "") ? Number(x) : { a: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7 }[x]); + if (c === "ago" && num(a) && /^days?$/.test(b)) return { range: day(addDays(today, -num(a))), n: 3 }; + if (c === "ago" && num(a) && /^weeks?$/.test(b)) { + const s = addDays(today, -7 * num(a) - 3); + return { range: [s, addDays(s, 7)], n: 3 }; + } + if (a === "a" && b === "few" && c === "days" && w[i + 3] === "ago") return { range: [addDays(today, -5), addDays(today, -1)], n: 4 }; + if (a === "recently" || (a === "the" && b === "other" && c === "day")) { + const n = a === "recently" ? 1 : 3; + return { range: [addDays(today, -7), addDays(today, 1)], n }; + } + return null; +} + +function parseRecall(text, { now = Date.now(), trails = [] } = {}) { + let q = lower(text).trim(); + const out = { words: [], range: null, order: null, kinds: [], trail: null }; + // "in " / "on the trail", when a trail has that name. + const names = trails.map((t) => ({ id: t.id, name: lower(t.name).trim() })).filter((t) => t.name).sort((a, b) => b.name.length - a.name.length); + for (const t of names) { + const m = q.match(new RegExp(`(?:^|\\s)(?:in|on|from)\\s+(?:the\\s+|my\\s+)?${t.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s+trail)?(?=\\s|$)`)); + if (m) { + out.trail = t.id; + q = (q.slice(0, m.index) + " " + q.slice(m.index + m[0].length)).trim(); + break; + } + } + const w = tokens(q); + const rest = []; + let order = null; + for (let i = 0; i < w.length; ) { + const t = timeAt(w, i, now); + if (t) { + out.range = t.range; + i += t.n; + continue; + } + if (["after", "before", "around", "near", "next"].includes(w[i]) && !(w[i] === "next" && w[i + 1] !== "to")) { + const rel = w[i] === "after" ? "after" : w[i] === "before" ? "before" : "near"; + order = { rel, words: [] }; + i += w[i] === "next" ? 2 : 1; + continue; + } + if (KINDS[w[i]]) { + if (!out.kinds.includes(KINDS[w[i]])) out.kinds.push(KINDS[w[i]]); + i++; + continue; + } + if (!STOP.has(w[i])) (order ? order.words : rest).push(w[i]); + i++; + } + out.words = rest; + if (order && order.words.length) out.order = order; + else if (order) out.words.push(...order.words); + return out; +} + +// The kind of an entry, for "video", "question" and the like. +function kindOf(e) { + if (e.kind === "media") return e.media === "screenshot" ? "image" : e.media || "image"; + if (e.kind === "page" || e.kind === "visit") { + const host = hostOf(e.url); + if (VIDEO_HOSTS.test(host)) return "video"; + if (AUDIO_HOSTS.test(host)) return "audio"; + return "page"; + } + return e.kind; +} + +// Everything in every trail that can be found: the pages in each trail's +// timeline (cards and timeline only), and its other cards (questions, notes, +// searches, media). +function recallIndex(store) { + const out = []; + for (const t of store.list()) { + const snap = store.snapshot(t.id); + if (!snap) continue; + const cards = new Map(snap.cards.filter((c) => !c.removed).map((c) => [c.id, c])); + const seenCards = new Set(); + const rows = []; + for (const s of snap.timeline || []) { + for (const it of s.items || []) { + if (!it.at) continue; + const c = it.card ? cards.get(it.card) : null; + if (c) seenCards.add(c.id); + rows.push({ + kind: c ? c.kind : "visit", + media: c?.media, + trail: t.id, + card: c ? c.id : null, + url: it.url || c?.url || "", + title: it.title || c?.title || it.url || "", + text: c ? [c.excerpt, ...(c.keywords || [])].filter(Boolean).join(" ") : "", + at: it.at, + timelineOnly: !c, + }); + } + } + for (const c of cards.values()) { + if (seenCards.has(c.id) || !c.placedAt) continue; + const text = [c.text, c.quote, c.query, c.excerpt, c.conclusion, ...(c.keywords || []), ...(c.replies || []).map((r) => r.text)].filter(Boolean).join(" "); + rows.push({ kind: c.kind, media: c.media, trail: t.id, card: c.id, url: c.url || "", title: c.title || c.text || c.query || "", text, at: c.placedAt, timelineOnly: false }); + } + for (const g of snap.groups || []) { + // A group's name finds its cards: "in the hydration group". + for (const id of g.cards || []) { + const c = cards.get(id); + const r = c && rows.find((x) => x.card === id); + if (r) r.text = `${r.text} ${g.label || ""}`.trim(); + } + } + rows.sort((a, b) => a.at - b.at); + rows.forEach((r, i) => { + r.trailName = t.name; + r.pos = i; + }); + out.push(...rows); + } + return out; +} + +function hay(e) { + return lower(`${e.title} ${hostOf(e.url)} ${e.url.replace(/^https?:\/\/(www\.)?/, "")} ${e.text}`); +} +// How well the words match: 0 when one is missing. +function score(e, words) { + if (!words.length) return 1; + const title = lower(e.title), h = hay(e); + let s = 0; + for (const w of words) { + if (title.includes(w)) s += 3; + else if (hostOf(e.url).includes(w)) s += 2; + else if (h.includes(w)) s += 1; + else return 0; + } + return s; +} + +const fmtDay = (t, now) => { + const d = new Date(t), today = startOfDay(now); + if (t >= today) return "today"; + if (t >= addDays(today, -1)) return "yesterday"; + const wd = SHORT_WD[d.getDay()][0].toUpperCase() + SHORT_WD[d.getDay()].slice(1, 3); + const mo = SHORT_MO[d.getMonth()][0].toUpperCase() + SHORT_MO[d.getMonth()].slice(1); + return `${wd} ${d.getDate()} ${mo}${d.getFullYear() !== new Date(now).getFullYear() ? ` ${d.getFullYear()}` : ""}`; +}; +const short = (s, n = 40) => (String(s).length > n ? String(s).slice(0, n - 1).trimEnd() + "…" : String(s)); + +// Results, best first: { trail, trailName, card, url, title, at, kind, +// timelineOnly, why }. +function recall(entries, text, { now = Date.now(), trails = [], limit = 8 } = {}) { + const p = parseRecall(text, { now, trails }); + const nothing = !p.words.length && !p.range && !p.order && !p.kinds.length; + if (nothing) return []; + const inScope = (e) => (!p.trail || e.trail === p.trail) && (!p.range || (e.at >= p.range[0] && e.at < p.range[1])); + const kindOk = (e) => !p.kinds.length || p.kinds.includes(kindOf(e)); + const byTrail = new Map(); + for (const e of entries) { + if (!byTrail.has(e.trail)) byTrail.set(e.trail, []); + byTrail.get(e.trail).push(e); + } + const found = []; + if (p.order) { + // The pages the order words name, then what came just after or before + // them in the same trail. + const anchors = entries.filter((e) => inScope(e) && score(e, p.order.words) > 0); + for (const a of anchors) { + const list = byTrail.get(a.trail); + const steps = p.order.rel === "after" ? [1, 2, 3, 4, 5, 6] : p.order.rel === "before" ? [-1, -2, -3, -4, -5, -6] : [1, -1, 2, -2, 3, -3]; + for (const d of steps) { + const e = list[a.pos + d]; + if (!e || e.url === a.url || !kindOk(e)) continue; + const s = score(e, p.words); + if (!s) continue; + found.push({ e, s: s + 20 - Math.abs(d) * 3 + score(a, p.order.words), why: `${p.order.rel === "after" ? "after" : p.order.rel === "before" ? "before" : "near"} “${short(a.title, 32)}”` }); + } + } + } else { + for (const e of entries) { + if (!inScope(e) || !kindOk(e)) continue; + const s = score(e, p.words); + if (s) found.push({ e, s, why: "" }); + } + } + // One result per page (or card) in a trail: the best, then the latest. + const best = new Map(); + for (const f of found) { + const k = `${f.e.trail} ${f.e.card || f.e.url}`; + const o = best.get(k); + if (!o || f.s > o.s || (f.s === o.s && f.e.at > o.e.at)) best.set(k, f); + } + return [...best.values()] + .sort((a, b) => b.s - a.s || b.e.at - a.e.at) + .slice(0, limit) + .map(({ e, why }) => ({ + trail: e.trail, + trailName: e.trailName, + card: e.card, + url: e.url, + title: e.title, + at: e.at, + kind: kindOf(e), + timelineOnly: e.timelineOnly, + why: [why, fmtDay(e.at, now), e.trailName].filter(Boolean).join(" · "), + })); +} + +module.exports = { parseRecall, recallIndex, recall, kindOf, timeAt }; diff --git a/docs/02-roadmap-backlog.md b/docs/02-roadmap-backlog.md index 4c7abe3..b561151 100644 --- a/docs/02-roadmap-backlog.md +++ b/docs/02-roadmap-backlog.md @@ -110,7 +110,7 @@ Effort: XS under 1 hour, S half a day, M 1-2 days, L 3+ days. Finding IDs (C1, K | B86 | 4 | Check before creating a share link: warn when a trail has pages naming private people or showing social posts; trails private by default. Add a shared trail file with a prompt injection to the B58 red-team set | P1 | S | Not started | | B87 | 4 | Save a copy of each trail page (offered, not forced) with a SHA-256 hash and the time; kept on the computer, sent only through share links | P1 | M | Not started | | B88 | 4 | Media moments: one key saves start and end time, transcript text if available, and the link, then pauses playback; reuses the B70 loop range. "ID at 41:12" requests in threads | P1 | M | Not started | -| B89 | 4 | Search past trails by what the person remembers: order ("after the Discogs page"), time, thread, keyword | P1 | M | Not started | +| B89 | 4 | Search past trails by what the person remembers: order ("after the Discogs page"), time, thread, keyword | P1 | M | Built 2026-11-07 (PR #67): an "In your trails" group in the CommandBar and the canvas search bar's list; local rules only, no AI; picking a result goes to its card, or its timeline row (Sven, 2026-11-07). Electron build only. Tests `test:recall`, e2e `51-trail-recall` | | B90 | 4 | Close tabs into a trail with a reason (to read, reference, reminder). Possible answer to B75 | P2 | S | Not started | | B91 | 4 | Export citations as CSL-JSON and BibTeX (Zotero, Overleaf); README states trails always export to open formats | P2 | S | Not started | | B92 | 4 | Trail sharing phase 3: viewer and editor roles; owner's edits apply at once, others' wait for approval | P2 | M | Not started; with B82 phase 3 | diff --git a/docs/03-architecture.md b/docs/03-architecture.md index 7619657..f6d70ad 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -274,6 +274,12 @@ The same core modules run in the built-in extension of the Chromium build (`brow - **Rename:** `rename(id, name)` records a `renamed` event; `applySession` now does the same when a session's name changes, so renames through research:\* are events too. - **Channels:** `trail:rename-id` (id, name; refused for read-only trails; the current trail goes through research:\*), `trail:delete` (closes the trail's tabs first, then `changeTrail` if it was current; returns `{deleted, name, tabs, current}`), `trail:restore` (via `changeTrail`). - **Switcher** (`renderer/app.js`): each trail row is `.sb-menu-row` with the item and a "…" button; the actions row (`.sb-menu-acts`) holds Rename and Delete…, then the question with Delete trail and Keep. `#sb-undo` under the menu shows Undo for 10 s. Keys on a trail in the menu: F2 renames, Delete or Backspace asks to delete, → reaches "…". Over a page the field is typed in the sidebar's layer (`chrome-overlay.html`); the real field does not save on losing focus there, and Enter or Esc come back through `overlayEvent`. +## Search past trails (B89) + +- `core/recall.js` (no platform imports): `parseRecall(text, { now, trails })` takes out a trail name ("in …"), times (`timeAt`: a day, part of a day, a week, a month, "n days ago"; local time), an order ("after", "before", "around", "next to" and the words that follow), kind words, and stop words; the rest are keywords. `recallIndex(store)` lists every trail's timeline items (cards and timeline-only visits) and its other cards (questions, notes, searches, media), oldest first in each trail with a position. `recall(entries, text)` scores keywords (title 3, site 2, other text 1; all words must match), keeps the time, kind and trail, and for an order finds the pages that match the order words, then the 6 entries after or before each in the same trail, nearer first. One result per page or card, best first, with `why`. +- **Main:** `trail:recall` (text, limit) builds the index on each call (no cache; about 30 ms for 6,000 pages). +- **Window:** the CommandBar adds an "In your trails" group; the canvas search bar's list adds rows under a heading through the `recall` hook (leaving out cards on the current trail). `goRecall` switches trail (`goTrail`), then `TrailCanvas.showFound(id)` selects and reveals the card and keeps it selected through the redraws of the next 2 s, with a ring (`.cv-card.found`, opacity only). A timeline-only result shows the canvas and marks the row (`.sb-tl-row.found`), drawing it even if it is past the first 6 sessions or 12 rows. + ## Trail provenance (B83) - `core/provenance.js` (no platform imports, UMD: `NodeProvenance` in the window): `via` is `{ how, query?, host? }` with `how` one of link, search, typed, suggested, agent, next; `played` is a separate mark. `fromPage(url)` turns the page before into link (its site) or search (the query, when it is a known results page, `searchQuery`). `viaText` and `parseViaText` write and read the text used on cards, in Replay and in trail files. `clean` keeps known fields only (the window's value is not trusted). diff --git a/docs/05-changelog.md b/docs/05-changelog.md index f424329..8ff4b90 100644 --- a/docs/05-changelog.md +++ b/docs/05-changelog.md @@ -4,6 +4,7 @@ Newest first. Commit hashes refer to the git history inside the delivered zip fi | Date | Commit | Change | | --- | --- | --- | +| 2026-11-07 | (PR #67) | **B89: search past trails by what you remember.** Typing in the CommandBar (⌘L, ⌘T) or the canvas search bar now also lists up to four matches from all your trails under "In your trails", each with why it matched, the day and the trail ("after “Memex – Encyclopedia” · Tue 3 Nov · Reading list"). NODE reads, with its own rules and no AI: words (titles, sites, addresses, keywords, excerpts, question and note text, replies, group names); times (today, yesterday, this morning, last night, Tuesday, last Tuesday, this week, last week, this month, last month, in October, 3 days ago, 2 weeks ago, a few days ago, recently); order (after X, before X, around X, next to X: the pages just after or before X in the same trail); kinds (video, song, podcast, question, thread, note, search, image, screenshot); and a trail ("in Rye breads"). Pages that stayed in the timeline are found too. Picking a result switches to its trail and selects its card with a short ring (opacity only), or, for a page that never became a card, shows the canvas and marks its row in the sidebar's timeline. In the canvas search list, cards on the current trail are left out, since the search bar already marks them. Nothing leaves the computer. New `core/recall.js` (no platform imports), channel `trail:recall`. Electron build only. Tests: `test/recall-demo.js` (`npm run test:recall`, in `test:unit`, 9 checks, including 6,000 pages in 40 trails in about 30 ms), new `test/e2e/51-trail-recall.js` (13 checks, in CI). | | 2026-11-06 | (PR #65) | **B83: trail provenance.** NODE records how each page in a trail was reached: a link on another page (its site), a web search (the query; pages opened from Brave, Google, DuckDuckGo, Bing and other results pages, or after a search typed in NODE), an address you typed (address bar, CommandBar, canvas), NODE's suggestions (with the keyword or search they came from), the research agent, or the site moving on to the next video or track by itself (no click or key press in the tab for 5 s while the page before was playing). Separately, a page is marked "played by NODE" when NODE's autoplay started its video or audio with no click just before. Each page card shows one line under its title, such as "Suggested by NODE for “levain”" or "Link from reddit.com"; NODE's and the site's picks are violet. Replay captions say it ("NODE suggested “…” for “…”, and you opened it.", "youtube.com played “…” next by itself.", "The research agent found “…”."), and visits that stayed in the timeline keep it too. Trail files add `Reached: …` to page cards and `(timeline only · …)` to timeline items (doc 26); a file opened by someone else keeps it. Single-page sites that change address (YouTube's next video) now start a new page for the trail. New `core/provenance.js` (no platform imports; the window and the Chromium canvas page load it for the card line). `tab:create` and `tab:navigate` take an optional `via`. Electron build only for now. Tests: `test/provenance-demo.js` (`npm run test:provenance`, in `test:unit`), new `test/e2e/50-provenance.js` (14 checks, in CI). | | 2026-11-06 | (PR #64) | **B115: rename and delete trails from the switcher.** Each trail in the switcher menu has a "…" button with Rename and Delete…. Rename turns the row into a text field (Enter or clicking away saves, Esc cancels); double-clicking the switcher's name renames the current trail, and F2 renames the trail in focus. Delete… asks first: "Delete “X”? Its canvas, timeline and Replay go, and its n tabs close. Library files stay." with Delete trail and Keep (focus on Keep). Deleting closes the trail's tabs and, if it was current, makes the most recently used other trail current; deleting the only trail starts a new one. "Deleted “X”." with Undo shows in the sidebar for 10 s; Undo brings the trail back as the current trail (its tabs stay closed). The files are deleted when NODE next starts. The Delete key on a trail in the menu asks to delete it. Opened files (read only) can be deleted, not renamed. Over a page, renaming works in the sidebar's layer. A rename made through research:\* (the current trail, the Trail panel) is now recorded as a renamed event too. New `TrailStore.remove`, `restore`, `deletedIds`, `purge`; storage `remove()` in `fs-storage.js` and `memoryStorage`; channels `trail:rename-id`, `trail:delete`, `trail:restore`. Tests: 6 more checks in `test/trail-demo.js`, new `test/e2e/49-trail-rename-delete.js` (32 checks, in CI). | | 2026-11-05 | (PR #62) | **B114: Library media on the canvas.** Images, screenshots, videos and audio from the Library become media cards: Put on canvas on a Library item, drag an item from the Library onto the canvas, or drop image, video and audio files from Finder or Explorer (copied into the Library, placed where dropped, side by side). Screenshots and media saved with Capture now arrive as media cards next to their page, with a captured-from line, instead of text-only capture cards. Images show on the card; video plays on the card; audio has a small player with progress and time; one plays at a time, and leaving the canvas stops them. Double-click opens a large viewer. Link them like any card. In a shared file, media from the web links to where it came from and the reader's card previews it from there; screenshots and files from the computer stay behind and the card says so (doc 26). Replay names what was saved. Fixed on the way: double-click on a card (opening a page card) never reached the card. Tests: 3 more checks in `test/trail-demo.js`, 1 in `test/replay-demo.js`, new `test/e2e/48-canvas-media.js` (22 checks, in CI). | diff --git a/main.js b/main.js index 62f6f93..bf6fcf6 100644 --- a/main.js +++ b/main.js @@ -37,6 +37,7 @@ const Chat = require("./core/chat"); const { ResearchStore } = require("./core/research"); const { TrailStore, migrateSessions, sessionStorageOver, mediaKindOf } = require("./core/trail"); const Prov = require("./core/provenance"); +const { recall, recallIndex } = require("./core/recall"); const Replay = require("./core/replay"); const { Usage } = require("./core/usage"); const { HistoryStore } = require("./core/history"); @@ -3538,6 +3539,11 @@ ipcMain.handle("find:start", (_e, id, text, opts = {}) => { ipcMain.handle("find:stop", (_e, id) => tabs.get(id)?.view.webContents.stopFindInPage("keepSelection")); ipcMain.handle("tab:zoom", (_e, id, step) => zoomTab(tabs.get(id), Number(step) || 0)); ipcMain.handle("history:search", (_e, q, limit) => historyStore.search(q, limit)); +// B89: find things in past trails by what the person remembers (core/recall.js). +ipcMain.handle("trail:recall", (_e, q, limit = 6) => { + if (!trailStore || typeof q !== "string" || q.trim().length < 2) return []; + return recall(recallIndex(trailStore), q.slice(0, 200), { trails: trailStore.list(), limit: Math.min(Number(limit) || 6, 20) }); +}); // Bookmarks (core/bookmarks.js). ipcMain.handle("bookmarks:search", (_e, q, limit) => bookmarkStore.search(q, limit)); ipcMain.handle("bookmarks:has", (_e, url) => bookmarkStore.has(url)); diff --git a/package.json b/package.json index fa38c7e..1205e03 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:ladder": "node test/ladder-demo.js", "test:replay": "node test/replay-demo.js", "test:provenance": "node test/provenance-demo.js", + "test:recall": "node test/recall-demo.js", "test:trailfile": "node test/trail-file-v2-demo.js", "try:canvas": "node test/try-canvas.js", "test:keywords": "node test/keywords-demo.js", @@ -36,7 +37,7 @@ "dist:mac": "electron-builder --mac", "dist:win": "electron-builder --win", "test:redteam": "node test/e2e/18-redteam.js", - "test:unit": "node test/net-demo.js && node test/trail-demo.js && node test/canvas-demo.js && node test/placement-demo.js && node test/ladder-demo.js && node test/replay-demo.js && node test/provenance-demo.js && node test/share-link-demo.js && node test/share-worker-demo.js && node test/trail-file-demo.js && node test/trail-file-v2-demo.js && node test/threads-demo.js && node test/trail-open-demo.js && node test/passwords-demo.js && node test/chat-demo.js && node test/usage-demo.js && node test/research-demo.js && node test/keywords-demo.js && node test/keywords-eval.js && node test/providers-demo.js && node test/rules-demo.js && node test/ai-demo.js && node test/controller-demo.js && node test/playback-demo.js && node test/bookmarks-demo.js && node test/google-demo.js && node test/agents-demo.js && node test/agent-demo.js && node test/talk-demo.js", + "test:unit": "node test/net-demo.js && node test/trail-demo.js && node test/canvas-demo.js && node test/placement-demo.js && node test/ladder-demo.js && node test/replay-demo.js && node test/provenance-demo.js && node test/recall-demo.js && node test/share-link-demo.js && node test/share-worker-demo.js && node test/trail-file-demo.js && node test/trail-file-v2-demo.js && node test/threads-demo.js && node test/trail-open-demo.js && node test/passwords-demo.js && node test/chat-demo.js && node test/usage-demo.js && node test/research-demo.js && node test/keywords-demo.js && node test/keywords-eval.js && node test/providers-demo.js && node test/rules-demo.js && node test/ai-demo.js && node test/controller-demo.js && node test/playback-demo.js && node test/bookmarks-demo.js && node test/google-demo.js && node test/agents-demo.js && node test/agent-demo.js && node test/talk-demo.js", "release": "electron-builder --publish always", "build:browser-extension": "node browser/extension/build.js", "check:browser-patches": "python3 browser/check_patches.py", diff --git a/preload.js b/preload.js index cfdd664..c090ecf 100644 --- a/preload.js +++ b/preload.js @@ -113,6 +113,8 @@ contextBridge.exposeInMainWorld("scout", { findStop: (id) => ipcRenderer.invoke("find:stop", id), zoom: (id, step) => ipcRenderer.invoke("tab:zoom", id, step), historySearch: (q, limit) => ipcRenderer.invoke("history:search", q, limit), + // B89: results from past trails for what the person remembers. + trailRecall: (q, limit) => ipcRenderer.invoke("trail:recall", q, limit), historyClear: () => ipcRenderer.invoke("history:clear"), claudeDesktop: (mode) => ipcRenderer.invoke("agents:claude-desktop", mode), historyRemove: (url) => ipcRenderer.invoke("history:remove", url), diff --git a/renderer/app.js b/renderer/app.js index ae2ffd4..3be5f5f 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3902,6 +3902,10 @@ async function openCanvas({ focus = false, select = "" } = {}) { webSearch: (q) => api.trailSearch(q), privateSearch: (q) => api.trailSearch(q, { private: true }), ask: (q) => askAboutTrail(q), + // B89: results from past trails in the search bar's list. Cards on this + // trail are left out: the search bar already marks them. + recall: async (q) => ((await api.trailRecall?.(q, 6)) || []).filter((r) => !(r.card && r.trail === trailCanvas?.data?.trail?.id)).slice(0, 4), + goRecall: (r) => goRecall(r), // B110: Replay and Share, top right. replay: () => startTrailReplay(0), // B114: Library media on the canvas. @@ -3941,6 +3945,10 @@ async function openCanvas({ focus = false, select = "" } = {}) { trailCanvas.setSurvey(days || 0); trailCanvas.setResume((list || []).filter((t) => t.id !== snap?.trail.id && t.cards > 0)); if (select) trailCanvas.selectUrl(select); + if (recallPending?.card && recallPending.trail === snap?.trail.id) { + trailCanvas.showFound(recallPending.card); + recallPending = null; + } if (focus) trailCanvas.focusSearch(); else if (!canvasEl.contains(document.activeElement)) trailCanvas.vp.focus({ preventScroll: true }); } @@ -4593,6 +4601,33 @@ async function refreshTimeline(force = false) { // ---- Trail switcher: name and menu, dots, swipe ---- const switchName = document.getElementById("sb-switch-name"); const switchMenu = document.getElementById("sb-switch-menu"); +// B89: go to a result from past trails: its trail, then its card on the +// canvas, or its row in the timeline when it is not on the canvas. +let recallPending = null; +async function goRecall(r) { + if (!r) return; + recallPending = r; + // goTrail redraws a canvas that is showing; it picks up recallPending. + const switching = r.trail !== sideTrails.find((t) => t.current)?.id; + if (switching) await goTrail(r.trail); + if (r.card) { + if (canvasEl.hidden) await api.canvasShow(); // opens the canvas, which picks up recallPending + else if (!switching) openCanvas(); + } else { + recallPending = null; + showFoundInTimeline(r); + } +} +let timelineFound = null; +async function showFoundInTimeline(r) { + timelineFound = { url: r.url, at: r.at, until: Date.now() + 4000 }; + // The sidebar shows beside the canvas; over a page it is hidden. + if (canvasEl.hidden) await api.canvasShow(); + await refreshTimeline(true); + const row = document.querySelector("#sb-timeline-list .sb-tl-row.found"); + row?.scrollIntoView({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); + row?.focus({ preventScroll: true }); +} async function goTrail(id) { if (!id) return; closeSwitchMenu(); @@ -4879,17 +4914,19 @@ function paintTimeline() { const list = document.getElementById("sb-timeline-list"); list.innerHTML = ""; let n = 0; - for (const sess of sideTimeline.slice(0, 6)) { + const found = timelineFound && timelineFound.until > Date.now() ? timelineFound : null; + const isFound = (it) => found && it.url === found.url && it.at === found.at; + for (const sess of sideTimeline.filter((s, i) => i < 6 || (found && (s.items || []).some(isFound)))) { const head = document.createElement("div"); head.className = "sb-tl-session"; const when = new Date(sess.start || sess.items?.[0]?.at || Date.now()); head.textContent = `${when.toLocaleDateString(undefined, { month: "short", day: "numeric" })} · ${sess.device || "This computer"}`; list.appendChild(head); - for (const it of (sess.items || []).slice(0, 12)) { + for (const it of (sess.items || []).filter((x, i) => i < 12 || isFound(x))) { n++; const row = document.createElement("button"); row.type = "button"; - row.className = "sb-tl-row" + (it.timelineOnly ? " only" : ""); + row.className = "sb-tl-row" + (it.timelineOnly ? " only" : "") + (isFound(it) ? " found" : ""); const time = new Date(it.at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); row.innerHTML = ``; // Timeline-only pages: drag onto the canvas, or the + button, puts them on it (B104). @@ -5115,6 +5152,20 @@ async function renderCommands() { key: "⌥↵", run: () => cmdNewTrail(q), }); + // B89: things in past trails that match what was typed ("after the + // Discogs page", "rye yesterday"). + if (q && !looksLikeAddress(q) && api.trailRecall) { + const found = await api.trailRecall(q, 4); + if (seq !== cmdSeq) return; // typed again meanwhile + for (const r of found) + add("In your trails", { + id: `recall-${r.trail}-${r.card || r.url}`, + icon: r.kind === "question" ? "ask" : "trail", + html: `${cmdBold(r.title || r.url || "Untitled", words)}${escapeHtml(r.why)}`, + key: "Show", + run: () => goRecall(r), + }); + } // Ask if (q && !looksLikeAddress(q)) { add("Ask", { id: "ask", icon: "ask", html: `Ask about this page: ${escapeHtml(q)}`, key: "Tab", run: () => cmdAsk(q) }); diff --git a/renderer/canvas.js b/renderer/canvas.js index 170e89d..15d051d 100644 --- a/renderer/canvas.js +++ b/renderer/canvas.js @@ -615,6 +615,21 @@ const v = snap?.view; if (v && typeof v.z === "number" && (v.x || v.y || v.z !== 1)) this.setView({ x: v.x, y: v.y, z: v.z }, { save: false }); else this.fit(false, { save: false }); + this.applyFound(); + } + // B89: a card looked for from past trails. It stays selected through + // the redraws that follow a trail switch (for 2 s). + showFound(id) { + this.found = { id, until: Date.now() + 2000 }; + this.applyFound(); + } + applyFound() { + const f = this.found; + if (!f || f.until < Date.now() || !this.data?.cards.some((c) => c.id === f.id)) return; + this.selected = null; + this.select(f.id, { reveal: true }); + // A redraw makes new card elements: the ring goes back on. + if (!this.cardsEl.querySelector(`.cv-card.found[data-id="${CSS.escape(f.id)}"]`)) this.flashCard(f.id); } render() { @@ -1296,10 +1311,32 @@ this.dropRows.push({ act: "search", text: `Search the web: ${v}`, meta: "Adds a search card" }); this.dropRows.push({ act: "ask", text: `Ask about this trail: ${v}` }); this.dropRows.push({ act: "private", text: `Private search: ${v}`, meta: "Not in any trail" }); + this.findInTrails(v); } } this.paintDrop(); } + // B89: things in past trails that match, under the other choices. + async findInTrails(v) { + if (!this.hooks.recall || v.length < 2) return; + const seq = (this.recallSeq = (this.recallSeq || 0) + 1); + let found = []; + try { + found = (await this.hooks.recall(v)) || []; + } catch (_) {} + if (seq !== this.recallSeq || this.input.value.trim() !== v) return; + for (const r of found) this.dropRows.push({ act: "recall", text: r.title || r.url || "Untitled", meta: r.why, item: r, head: !this.dropRows.some((x) => x.act === "recall") }); + this.paintDrop(); + } + // A short ring around a card that was looked for (B89). + flashCard(id) { + const el = this.cardsEl.querySelector(`.cv-card[data-id="${CSS.escape(id)}"]`); + if (!el) return; + el.classList.remove("found"); + void el.offsetWidth; + el.classList.add("found"); + setTimeout(() => el.classList.remove("found"), 2200); + } paintDrop() { const n = this.matches ? this.matches.size : null; this.drop.replaceChildren( @@ -1309,8 +1346,13 @@ row.setAttribute("aria-selected", String(i === this.dropSel)); row.dataset.i = i; row.id = `cv-drop-${i}`; - row.append(h("span", "nm", r.text), h("span", "k", i === this.dropSel ? "↵" : r.meta || "")); - return row; + row.append(h("span", "nm", r.text), h("span", "k", i === this.dropSel && r.act !== "recall" ? "↵" : r.meta || "")); + if (r.act === "recall") row.classList.add("recall"); + if (!r.head) return row; + // The first result from past trails comes under a heading. + const wrap = document.createDocumentFragment(); + wrap.append(h("div", "cv-drop-head", "In your trails"), row); + return wrap; }), ); if (n !== null) this.drop.append(h("div", "cv-drop-meta", n === 1 ? "1 card on this trail matches" : `${n} cards on this trail match`)); @@ -1324,6 +1366,7 @@ const r = this.dropRows?.[i]; if (!v || !r) return; this.clearSearch(); + if (r.act === "recall") return this.hooks.goRecall?.(r.item); if (this.readonly && r.act !== "private") return this.sayReadonly(); if (r.act === "open") this.hooks.search?.(v); else if (r.act === "search") this.hooks.webSearch?.(v); diff --git a/renderer/styles.css b/renderer/styles.css index de56f34..2a36acf 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -1543,6 +1543,15 @@ body.chrome-overlay.side.shown #bar { transform: none; transition: transform var #canvas .cv-drop-row.sel { background: var(--surface); box-shadow: inset 2px 0 0 var(--ink); } #canvas .cv-drop-row .nm { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } #canvas .cv-drop-row .k { font: 11px var(--mono); color: var(--ink-muted); } +/* B89: results from past trails, under a heading. */ +#canvas .cv-drop-head { padding: 8px 10px 4px; font: 11px var(--mono); color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.04em; } +#canvas .cv-drop-row.recall .k { flex: 0 1 auto; max-width: 55%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +/* A card that was looked for: a ring that fades (opacity only). */ +#canvas .cv-card.found::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 2px var(--signal); pointer-events: none; animation: cv-found 2000ms var(--ease-enter) both; } +@keyframes cv-found { 0% { opacity: 0; } 15% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { #canvas .cv-card.found::after { animation: none; opacity: 1; } } +/* B89: a timeline row that was looked for. */ +.sb-tl-row.found { background: var(--surface-raised); box-shadow: inset 2px 0 0 var(--signal); } #canvas .cv-drop-meta { padding: 6px 10px 2px; font: 11px var(--mono); text-transform: uppercase; color: var(--ink-muted); border-top: 1px solid var(--line); margin-top: 4px; } #canvas .cv-card { transition: opacity var(--motion-base) var(--ease-move); } #canvas .cv-card.dim { opacity: 0.3; } diff --git a/test/e2e/51-trail-recall.js b/test/e2e/51-trail-recall.js new file mode 100644 index 0000000..9e9a8e0 --- /dev/null +++ b/test/e2e/51-trail-recall.js @@ -0,0 +1,109 @@ +// Find things in past trails by what you remember (B89): an "In your trails" +// group in the CommandBar and in the canvas search bar's list. Picking a +// result switches to its trail and shows its card, or its timeline row when +// it never became a card. Words like "after", "yesterday" and "video" are +// read locally (core/recall.js). +const { _electron: electron } = require(process.env.PLAYWRIGHT_PATH || "playwright"); +const path = require("path"), fs = require("fs"), os = require("os"); +const OUT = process.argv[2] || fs.mkdtempSync(path.join(os.tmpdir(), "node-e2e-")); +const BASE = "http://127.0.0.1:8765"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let failed = 0; +const check = (name, ok, detail = "") => { + if (!ok) failed++; + console.log(ok ? "✓" : "✗", name, detail ? `(${detail})` : ""); +}; +const userData = fs.mkdtempSync(path.join(OUT, "ud-")); +fs.writeFileSync(path.join(userData, "settings.json"), JSON.stringify({ firstRunDone: true })); + +(async () => { + const app = await electron.launch({ executablePath: require("electron"), args: ["--no-sandbox", `--user-data-dir=${userData}`, path.join(__dirname, "..", "..")] }); + await app.evaluate(() => { + const o = globalThis.fetch; + globalThis.fetch = async (u, opt) => (/wikipedia|duckduckgo|brave\.com|x\.ai|11434/.test(String(u)) ? new Response("{}", { status: 200 }) : o(u, opt)); + }); + const ui = await app.firstWindow(); + await sleep(1800); + const menuItem = (label) => app.evaluate(({ Menu }, l) => Menu.getApplicationMenu().items.flatMap((m) => m.submenu?.items || []).find((i) => i.label === l).click(), label); + const current = () => ui.evaluate(() => window.scout.trailList().then((l) => l.find((t) => t.current)?.name)); + + // Trail 1: the encyclopedia page, then the article (both cards), and a + // quick look that stays in the timeline. + await ui.evaluate(() => window.scout.trailRename("Reading list")); + await ui.evaluate((u) => window.scout.createTab(u), `${BASE}/seealso.html`); + await sleep(1500); + await ui.evaluate((u) => window.scout.createTab(u), `${BASE}/article.html`); + await sleep(1500); + const tabId = await ui.evaluate(() => activeId); + await ui.evaluate(([id, u]) => window.scout.navigate(id, u), [tabId, `${BASE}/other.html?n=quick`]); + await sleep(2500); + await ui.evaluate(([id, u]) => window.scout.navigate(id, u), [tabId, `${BASE}/article.html`]); + await sleep(1500); + // Trail 2: another trail, so the first one's open pages become cards (B104). + await ui.evaluate(() => window.scout.researchNew("Elsewhere")); + await sleep(2000); + check("now on another trail", (await current()) === "Elsewhere"); + + // CommandBar: "after memex" finds the article in the other trail. + await menuItem("Open Location"); + await sleep(700); + await ui.fill("#cmd-input", "after the memex page"); + await sleep(900); + const groups = await ui.$$eval("#cmd-list *", (els) => els.filter((e) => e.textContent === "In your trails" && !e.querySelector("*")).length); + const rows = await ui.$$eval("#cmd-list .cmd-row", (r) => r.map((x) => ({ id: x.id, t: x.querySelector(".t")?.textContent || x.textContent }))); + const hit = rows.find((r) => /Why sourdough starters fail/.test(r.t)); + check("the CommandBar has an “In your trails” group", groups >= 1, JSON.stringify(rows.map((r) => r.t))); + check("“after the memex page” finds the page that came next", Boolean(hit) && /after “Memex/.test(hit.t) && /Reading list/.test(hit.t), hit?.t); + await ui.screenshot({ path: path.join(OUT, "51-cmdbar.png") }); + await ui.click(`#${hit.id}`); + let ring = false; + for (let i = 0; i < 20 && !ring; i++) { + await sleep(100); + ring = await ui.evaluate(() => Boolean(document.querySelector("#canvas .cv-card.found"))); + } + await ui.screenshot({ path: path.join(OUT, "51-card.png") }); + await sleep(1500); + check("picking it switches to its trail", (await current()) === "Reading list", await current()); + const sel = await ui.evaluate(() => ({ shown: !canvasEl.hidden, sel: trailCanvas?.selected, title: trailCanvas?.data?.cards.find((c) => c.id === trailCanvas.selected)?.title })); + check("and shows its card, selected, on the canvas", sel.shown && /Why sourdough starters fail/.test(sel.title || ""), JSON.stringify(sel)); + check("the card gets a ring for a moment", ring); + + // Canvas search bar, from the other trail again. + await ui.evaluate(() => window.scout.trailList().then((l) => goTrail(l.find((t) => t.name === "Elsewhere").id))); + await sleep(1500); + await ui.evaluate(() => trailCanvas.focusSearch()); + await ui.keyboard.type("memex today"); + await sleep(1200); + const drop = await ui.evaluate(() => ({ head: document.querySelector("#canvas .cv-drop-head")?.textContent, rows: [...document.querySelectorAll("#canvas .cv-drop-row.recall")].map((r) => r.textContent) })); + check("the canvas search list has “In your trails” too", drop.head === "In your trails" && drop.rows.some((t) => /Memex/.test(t) && /today · Reading list/.test(t)), JSON.stringify(drop)); + check("the web search choices stay first", await ui.evaluate(() => document.querySelector("#canvas .cv-drop-row .nm")?.textContent.startsWith("Search the web"))); + await ui.screenshot({ path: path.join(OUT, "51-canvas-search.png") }); + await ui.evaluate(() => [...document.querySelectorAll("#canvas .cv-drop-row.recall")].find((r) => /Memex/.test(r.textContent)).click()); + await sleep(2000); + check("picking it from the canvas goes to its card", (await current()) === "Reading list" && /Memex/.test(await ui.evaluate(() => trailCanvas.data.cards.find((c) => c.id === trailCanvas.selected)?.title || ""))); + + // A page that stayed in the timeline: its row is shown and marked. + await ui.evaluate(() => window.scout.trailList().then((l) => goTrail(l.find((t) => t.name === "Elsewhere").id))); + await sleep(1500); + const quick = await ui.evaluate(() => window.scout.trailRecall("levain guide")); + check("a timeline-only page can be found", quick.length && quick[0].timelineOnly && quick[0].trailName === "Reading list", JSON.stringify(quick[0])); + await ui.evaluate((r) => goRecall(r), quick[0]); + await sleep(1800); + const row = await ui.evaluate(() => { + const r = document.querySelector("#sb-timeline-list .sb-tl-row.found"); + return r ? { text: r.textContent, focused: document.activeElement === r } : null; + }); + check("picking it marks its row in the timeline", (await current()) === "Reading list" && row && /Levain guide/.test(row.text) && row.focused, JSON.stringify(row)); + await ui.screenshot({ path: path.join(OUT, "51-timeline.png") }); + + // Words that say nothing, and addresses, find nothing in trails. + check("words that say nothing find nothing", (await ui.evaluate(() => window.scout.trailRecall("the page"))).length === 0); + check("short text finds nothing", (await ui.evaluate(() => window.scout.trailRecall("a"))).length === 0); + + await app.close(); + console.log(failed ? `\n${failed} FAILED` : "\nall passed"); + process.exit(failed ? 1 : 0); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/test/e2e/README.md b/test/e2e/README.md index 71e39cc..5b8948d 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -31,6 +31,7 @@ On Linux without a display, run each script under `xvfb-run -a`. | 48-canvas-media | Library media on the canvas (B114): dropped files become media cards side by side, image shows, video and audio play on the card one at a time and keep playing through a redraw, viewer, Put on canvas, drag from the Library, media saved from a page with its captured-from line, Replay caption, the shared file links to the origin, the reader previews from there, read-only trails take no media | | 49-trail-rename-delete | Rename and delete trails from the switcher (B115): the … button on each trail, inline rename (Enter saves, Esc cancels), double-click and F2, rename over a page in the sidebar's layer, a rename is an event, delete asks first and says what goes, its tabs close, the next trail becomes current, Undo, the Delete key, files purged at the next start, deleting the only trail starts a new one, an opened file can be deleted but not renamed | | 50-provenance | Trail provenance (B83): a typed address, a link clicked on a page, a suggestion with its keyword, a page the agent found, NODE's autoplay, a site moving to the next item by itself (in-page navigation), unknown ways ignored; the line on canvas cards with NODE's picks in violet; Replay captions | +| 51-trail-recall | Search past trails (B89): the "In your trails" group in the CommandBar ("after the memex page" finds the next page in another trail), picking switches trail and selects the card with a ring; the canvas search list ("memex today") under its heading, after the web choices; a timeline-only page shows its row marked; words that say nothing find nothing | | 21-rules-editor | Site rules editor: list with plain summaries, Rule for this site, saving and the rule taking effect (autoplay off after reload), invalid pattern refused with the file unchanged, hand-written fields kept, move and delete | | 20-first-run-and-help | Welcome screen on first start (page views hidden), choices saved with keys encrypted, not shown again; new tab page with most visited pages and its search box; a custom new tab page; Copy diagnostics without keys; Help menu | | 19-browser-basics | Error page for a failed load, page-stopped page after a crash, reopen closed tab, Tab 1 and Next Tab, right-click menu items for links, images, text and the page, two downloads (second renamed), Library → Downloads, Save as PDF, sleeping idle tabs and waking one, tabs restored after a restart (asleep except the active one) | diff --git a/test/recall-demo.js b/test/recall-demo.js new file mode 100644 index 0000000..4da8f50 --- /dev/null +++ b/test/recall-demo.js @@ -0,0 +1,154 @@ +// Find things in past trails by what you remember (B89): core/recall.js. +// Run: npm run test:recall +const assert = require("assert"); +const { parseRecall, recallIndex, recall } = require("../core/recall"); +const { TrailStore } = require("../core/trail"); +const { memoryStorage } = require("../core/storage"); + +let n = 0; +const ok = (name) => console.log(`ok ${++n} - ${name}`); +// Friday 6 November 2026, 15:00 local time. +const NOW = new Date(2026, 10, 6, 15, 0).getTime(); +const at = (y, m, d, h = 12, min = 0) => new Date(y, m - 1, d, h, min).getTime(); +const day = (t) => new Date(t).toDateString(); + +// ---- reading the words ---- +{ + const p = parseRecall("the video after the Discogs page last Tuesday", { now: NOW }); + assert.deepStrictEqual(p.kinds, ["video"]); + assert.deepStrictEqual(p.order, { rel: "after", words: ["discogs"] }); + assert.strictEqual(day(p.range[0]), new Date(2026, 10, 3).toDateString(), "last Tuesday from Friday 6 Nov is 3 Nov"); + assert.deepStrictEqual(p.words, []); + const y = parseRecall("levain hydration yesterday", { now: NOW }); + assert.deepStrictEqual(y.words, ["levain", "hydration"]); + assert.strictEqual(day(y.range[0]), new Date(2026, 10, 5).toDateString()); + assert.strictEqual(y.range[1] - y.range[0], 86400000); + assert.strictEqual(day(parseRecall("friday", { now: NOW }).range[0]), new Date(2026, 10, 6).toDateString(), "a weekday alone can be today"); + assert.strictEqual(day(parseRecall("last friday", { now: NOW }).range[0]), new Date(2026, 9, 30).toDateString(), "last Friday is the week before"); + const oct = parseRecall("in October", { now: NOW }).range; + assert.strictEqual(day(oct[0]), new Date(2026, 9, 1).toDateString()); + assert.strictEqual(day(oct[1]), new Date(2026, 10, 1).toDateString()); + assert.strictEqual(new Date(parseRecall("december", { now: NOW }).range[0]).getFullYear(), 2025, "a month not reached yet is last year's"); + const lw = parseRecall("last week", { now: NOW }).range; + assert.strictEqual(day(lw[0]), new Date(2026, 9, 26).toDateString(), "last week starts on the Monday before"); + assert.strictEqual(day(parseRecall("3 days ago", { now: NOW }).range[0]), new Date(2026, 10, 3).toDateString()); + assert.deepStrictEqual(parseRecall("before the rye flour one", { now: NOW }).order, { rel: "before", words: ["rye", "flour"] }); + assert.deepStrictEqual(parseRecall("question about starters", { now: NOW }).kinds, ["question"]); + const tr = parseRecall("hydration in rye breads", { now: NOW, trails: [{ id: "t1", name: "Rye breads" }] }); + assert.strictEqual(tr.trail, "t1"); + assert.deepStrictEqual(tr.words, ["hydration"]); + ok("times, order, kinds and a trail name are read from the words; the rest are keywords"); +} + +// ---- two trails, a week of browsing ---- +let clock = at(2026, 11, 3, 10); +const store = new TrailStore(memoryStorage(), { now: () => clock }); +const rye = store.current().trail.id; +store.rename(rye, "Rye breads"); +const visit = (id, url, title, { card = false, dwell = 30000 } = {}) => { + store.visit(id, url, { title }); + clock += dwell; + if (card) store.placePage(id, url, { title, x: 0, y: 0 }); + else store.bounce(id, url, dwell); + clock += 60000; +}; +// Tuesday 3 Nov +visit(rye, "https://www.discogs.com/release/123-Rye-Sessions", "Rye Sessions – Discogs", { card: true }); +visit(rye, "https://www.youtube.com/watch?v=abc", "Shaping a rye boule", { card: true }); +visit(rye, "https://example.com/enzymes", "Rye and enzymes"); +visit(rye, "https://example.com/hydration", "Hydration for rye", { card: true }); +// Wednesday 4 Nov +clock = at(2026, 11, 4, 20); +const starters = store.create("Starters").trail.id; +visit(starters, "https://www.reddit.com/r/Sourdough/comments/1", "Why my starter smells", { card: true }); +visit(starters, "https://soundcloud.com/bakers/levain-talk", "A talk about levain"); +store.addCard(starters, { kind: "question", text: "Does rye flour make a starter faster?", x: 0, y: 300, placedAt: clock }); +clock += 60000; +store.addCard(starters, { kind: "note", text: "Feed twice a day at 25°C", x: 300, y: 300, placedAt: clock }); +// Thursday 5 Nov (yesterday) +clock = at(2026, 11, 5, 9); +visit(rye, "https://www.discogs.com/artist/9-Bakers", "The Bakers – Discogs"); +visit(rye, "https://example.com/spelt", "Spelt versus rye", { card: true }); +clock = NOW; + +const entries = recallIndex(store); +const trails = store.list(); +const find = (q, o = {}) => recall(entries, q, { now: NOW, trails, ...o }); +const titles = (r) => r.map((x) => x.title); + +{ + assert.ok(entries.length >= 10); + assert.ok(entries.some((e) => e.kind === "question") && entries.some((e) => e.kind === "note")); + assert.ok(entries.find((e) => e.title === "Rye and enzymes").timelineOnly); + ok("the index holds every trail's pages, cards and timeline-only visits"); +} +{ + const r = find("the video after the Discogs page"); + assert.strictEqual(r[0].title, "Shaping a rye boule", titles(r).join()); + assert.strictEqual(r[0].trail, rye); + assert.ok(/^after “Rye Sessions – Discogs” · Tue 3 Nov · Rye breads$/.test(r[0].why), r[0].why); + const r2 = find("after the discogs page"); + assert.deepStrictEqual(titles(r2).slice(0, 2).sort(), ["Shaping a rye boule", "Spelt versus rye"], "the pages right after each Discogs page come first"); + assert.ok(titles(r2).indexOf("Rye and enzymes") > 1, "then the ones after those"); + ok("“the video after the Discogs page” finds the video that came next"); +} +{ + const r = find("before hydration"); + assert.strictEqual(r[0].title, "Rye and enzymes", titles(r).join()); + assert.ok(r[0].timelineOnly, "a page that stayed in the timeline can be found"); + ok("“before …” finds what came just before"); +} +{ + const r = find("rye yesterday"); + assert.deepStrictEqual(titles(r), ["Spelt versus rye"]); + assert.strictEqual(find("discogs last tuesday")[0].title, "Rye Sessions – Discogs"); + assert.ok(find("discogs last tuesday").every((x) => x.title !== "The Bakers – Discogs")); + ok("a time keeps only what was seen then"); +} +{ + const r = find("rye flour question"); + assert.strictEqual(r.length, 1); + assert.strictEqual(r[0].kind, "question"); + assert.strictEqual(r[0].trailName, "Starters"); + assert.strictEqual(find("25°C")[0].kind, "note"); + assert.strictEqual(find("levain podcast")[0].title, "A talk about levain"); + ok("questions, notes and kinds (podcast → audio) can be found"); +} +{ + const r = find("rye in starters"); + assert.ok(r.length && r.every((x) => x.trail === starters), titles(r).join()); + ok("“in ” keeps to that trail"); +} +{ + const r = find("rye"); + assert.ok(r[0].title.toLowerCase().includes("rye"), "a title match comes first"); + assert.strictEqual(new Set(r.map((x) => `${x.trail} ${x.url || x.card}`)).size, r.length, "each page once"); + assert.deepStrictEqual(find("the"), []); + assert.deepStrictEqual(find(""), []); + assert.deepStrictEqual(find("nothing-like-this"), []); + ok("plain words work; words that say nothing find nothing"); +} +{ + // 40 trails of 150 pages each stays quick. + let big = at(2026, 9, 1); + const s = new TrailStore(memoryStorage(), { now: () => big }); + for (let t = 0; t < 40; t++) { + const id = t ? s.create(`Trail ${t}`).trail.id : s.current().trail.id; + for (let i = 0; i < 150; i++) { + const url = `https://example.com/t${t}/p${i}`; + s.visit(id, url, { title: `Page ${i} about topic ${t}` }); + big += 30000; + if (i % 5 === 0) s.placePage(id, url, { title: `Page ${i} about topic ${t}`, x: i * 10, y: 0 }); + else s.bounce(id, url, 30000); + big += 60000; + } + } + const t0 = Date.now(); + const idx = recallIndex(s); + const r = recall(idx, "after page 12 topic 7", { now: big, trails: s.list() }); + const ms = Date.now() - t0; + assert.ok(idx.length === 6000 && r.length > 0, `${idx.length} ${r.length}`); + assert.ok(ms < 1500, `${ms} ms`); + ok(`6,000 pages in 40 trails: index and search in ${ms} ms`); +} +console.log(`\n${n} checks passed`);