diff --git a/.ai-memory/MEMORY.md b/.ai-memory/MEMORY.md index cfe0846..8e3f88a 100644 --- a/.ai-memory/MEMORY.md +++ b/.ai-memory/MEMORY.md @@ -697,3 +697,120 @@ Club Penguin-style AI debate arena live at https://penguinwalk.co per-user immunity / streak / scan-count / receipt stats, and lists the four ways to navigate (⌘K / Shift-? / Role Tour / Layer Explorer). The "you made it here" panel. +101. Episodic Cortex — vault that talks back (cannibalized from + breferrari/obsidian-mind + @cyrilxbt's "second brain" guide) + - 8-category episodic taxonomy: decision / insight / question / + artifact / win / project / person / incident, each with regex + triggers + brain-region affinity vector. data/episodicTaxonomy.js + - routeCapture(text) orchestrates the full pipeline per note: + Layer 4 firewall + Layer 29 affect decoder + Layer 87 genre + classifier + episodic taxonomy → merged region heatmap that + drives the 3D brain. utils/episodicRouter.js + - episodicMemory.js: persistent log (brainsnn_episodic_v1, cap + 800), MiniLM embeddings (separate brainsnn_episodic_emb_v1 + slot), findSimilar / mineClusters via cosine + union-find, + addCapture / addInsight / deleteCapture / togglePinned / + export+import bundle. Synthesis insights are saved back as + first-class captures (kind: 'insight') — the vault literally + talks to itself. + - episodicSynthesis.js: dailyBrief() returns connections / + pattern / question; weeklySynthesis() returns emerging + thesis / contradictions / knowledge gaps / one action. Local + path uses cluster mining + valence pairs + open-question + detection; Gemma path adds a system prompt that forces the + LLM to reference the pre-computed BrainSNN signals. Falls + back to local on Gemma error. + - episodicDream.js subscribes to Layer 26 dream phase and every + 4 cycles runs consolidationPass over captures from the last + 30d. Dominant region of each cluster STDP-reinforces every + connectome edge that touches it. markConsolidated() decorates + participating captures so the timeline shows ◐N counters. + - EpisodicCortexPanel: capture form + 4 example seeds + 8 + category filter pills + search + recent timeline + Daily + Brief / Weekly Synthesis cards + warm-embeddings + export / + import / wipe. Dropping any capture into the panel pushes a + blended region overlay into the live brain via setState. + - MCP tools (Layer 19): episodic_capture, episodic_brief, + episodic_synthesis, episodic_list — agents on the WebSocket + relay can now feed captures into and synthesize from the + Episodic Cortex. + - Why this is deeper than Obsidian: every capture is + semantically embedded out of the box, manipulation-pressure- + scored before it pollutes the vault, affect-tagged, region- + mapped onto a 3D cortex, and STDP-rehearsed during idle Dream + Mode. Connections aren't a graph plugin — they are the + physics of the brain. + - Round 2 — capture-anywhere + vault-talks-back loop: + - episodicAutoBrief.js: persisted scheduler (`brainsnn_ + episodic_auto_v1`). Daily Brief unlocks at >20h since last + + ≥3 fresh captures; Weekly Synthesis unlocks every 6.5d + with ≥4 captures in trailing 7d. Banner surfaces in the + panel when ready; recordBrief / recordSynthesis persist + result snapshots (cap 12). nextBriefRelative() and + nextSynthesisRelative() drive the schedule footer. + - episodicStreak.js: pure computeStreak(captures) walks UTC + day keys, persists longest + totalDays in + `brainsnn_episodic_streak_v1`. Renders inline as a streak + chip ("🔥 12-day streak · 3 today") next to the Timeline + header. + - episodicDrift.js: detectDecisionDrifts() scans every + `decision` capture older than 3 days and finds newer + captures with cosine ≥ 0.34 AND either valence-flip + ≥ 0.45 or `incident` shadow. Returns ranked drifts; panel + merges them into the Synthesis card under + Contradictions with a distinct red "drift" treatment. + - episodicDeepLink.js: ?capture=, ?capture-url=, + ?capture-title=. consumeDeepLinkCapture() runs on + panel mount, fires onApplyEpisodic, then strips the params + from the URL bar. Powers the bookmarklet + any external + integration. bookmarkletSource(origin) returns drag-able + javascript: source. + - EpisodicGraph.jsx: canvas-rendered connection graph mini- + viz. Cluster wheels along the top arc (radius scales with + cluster size), singletons row along the bottom. Hover + → highlighted node + tooltip. ResizeObserver on the host + keeps it responsive. Renders inline in the panel above + the timeline. + - Voice capture: 🎙 button uses Layer 59's createSpeech + Session and pipes interim+final transcripts straight into + the draft. Tap-to-stop turns the button red. No-op on + Firefox. + - OCR capture: ◳ button uses Layer 58's ocrImage(); progress + % renders inline; extracted text appends to the draft and + the file basename auto-fills the title if blank. + - styles/global.css: full Episodic Cortex theme block — + gradient header, pill row, streak tile, autobrief banner, + graph host, drift card. Honors prefers-reduced-motion via + the global rule. + - Round 3 — Ask the Vault + agent surface parity: + - episodicAsk.js: askTheVault(question) embeds the question + (MiniLM, fall back to trigram Jaccard), runs cosine + retrieval over the capture log, composes a deterministic + local answer from the hits' categories+pressure+top hit, + optionally upgrades through Gemma with a prompt that + forces grounding in the retrieved captures. Returns + consistent shape across local + Gemma paths. + - Panel: "Ask the vault" card after the synth card — + question input + answer + ranked hits (clickable, scroll + to corresponding capture card). Uses the same focusCapture + helper that the connection graph uses for click-to- + navigate. + - Pin sort: pinned captures float to the top of the timeline + regardless of recency. CaptureCard already supported + togglePinned; the sort rule lives in the panel's `captures` + useMemo. + - EpisodicGraph: onNodeClick prop wired through; canvas + cursor flips to pointer over a node; clicking a node + scrolls the matching capture card into view + flashes a + cyan ring. + - Background auto-run: panel-level opt-in toggle persisted + in `brainsnn_episodic_autorun_v1`. When on AND a brief or + synthesis becomes ready, a 45–60s timer fires the run + silently. The banner shows "auto-run on" while active. + - MCP tools: episodic_ask, episodic_drift, episodic_consol- + idate. The consolidate tool reuses the Layer 26 dream + coupling's applyEpisodicSTDP() so an agent can + consolidate the episodic graph into the brain weights on + demand without waiting for idle Dream Mode. + - hotkeys.js: 'ep' chord jumps to Layer 101 Episodic Cortex. + Reachable via Shift-? cheat sheet + ⌘K Command Palette. diff --git a/brainsnn-r3f-app/package.json b/brainsnn-r3f-app/package.json index 00c8c03..4e0aedc 100644 --- a/brainsnn-r3f-app/package.json +++ b/brainsnn-r3f-app/package.json @@ -11,7 +11,8 @@ "build": "vite build", "preview": "vite preview", "start": "node server.js", - "start:dev": "npm run build && node server.js" + "start:dev": "npm run build && node server.js", + "test": "node --test \"tests/**/*.test.mjs\"" }, "dependencies": { "@ffmpeg/ffmpeg": "^0.12.15", diff --git a/brainsnn-r3f-app/src/App.jsx b/brainsnn-r3f-app/src/App.jsx index afb3b3f..c05ddcd 100644 --- a/brainsnn-r3f-app/src/App.jsx +++ b/brainsnn-r3f-app/src/App.jsx @@ -81,6 +81,8 @@ import HotkeyMap from './components/HotkeyMap'; import ThemePanel from './components/ThemePanel'; import CommunityPackPanel from './components/CommunityPackPanel'; import MilestonePanel from './components/MilestonePanel'; +import EpisodicCortexPanel from './components/EpisodicCortexPanel'; +import { captureToBrainState } from './utils/episodicRouter'; import { registerServiceWorker } from './utils/pwa'; import { registerTheme } from './utils/theme'; import DreamModePanel from './components/DreamModePanel'; @@ -106,6 +108,7 @@ import { applyMockEEG, connectMuseEEG, connectSerialEEG, mapEEGToRegions, parseM import { startCanvasRecording } from './utils/recording'; import { listSnapshots, loadSnapshot, saveSnapshot } from './utils/snapshots'; import { registerDreamProviders, startDreamMonitor, stopDreamMonitor, markActivity } from './utils/dreamMode'; +import { mountEpisodicDream, unmountEpisodicDream } from './utils/episodicDream'; import { mapRagToRegions } from './utils/neuroRag'; import { mapMultimodalToRegions } from './utils/multimodalRag'; import { mapFusedToRegions } from './utils/vectorGraphFusion'; @@ -231,7 +234,11 @@ export default function App() { narrate: (text) => toastInfo(text) }); startDreamMonitor(); - return () => stopDreamMonitor(); + mountEpisodicDream(setState); + return () => { + stopDreamMonitor(); + unmountEpisodicDream(); + }; }, []); const trends = useMemo(() => { @@ -779,6 +786,16 @@ export default function App() { <MilestonePanel /> </ErrorBoundary> + <ErrorBoundary name="Episodic Cortex"> + <EpisodicCortexPanel + onApplyEpisodic={(capture) => { + markActivity(); + setState((prev) => captureToBrainState(prev, capture, { additive: true })); + toastSuccess(`Episodic · ${capture.title.slice(0, 36)}${capture.title.length > 36 ? '…' : ''}`); + }} + /> + </ErrorBoundary> + <ErrorBoundary name="Dream Mode"> <DreamModePanel /> </ErrorBoundary> diff --git a/brainsnn-r3f-app/src/components/EpisodicCortexPanel.jsx b/brainsnn-r3f-app/src/components/EpisodicCortexPanel.jsx new file mode 100644 index 0000000..17a8a1e --- /dev/null +++ b/brainsnn-r3f-app/src/components/EpisodicCortexPanel.jsx @@ -0,0 +1,1008 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + addCapture, + addInsight, + getCaptures, + deleteCapture, + togglePinned, + clearAllCaptures, + ensureAllEmbeddings, + subscribeEpisodic, + captureStats, + exportEpisodicBundle, + importEpisodicBundle, + findSimilar, + findBacklinks, + rewriteCapture +} from '../utils/episodicMemory'; +import { captureToBrainState } from '../utils/episodicRouter'; +import { redactPII } from '../utils/episodicPII'; +import { dailyBrief, weeklySynthesis } from '../utils/episodicSynthesis'; +import { + EPISODIC_CATEGORIES, + EPISODIC_IDS +} from '../data/episodicTaxonomy'; +import { initEmbeddings, isReady as embeddingsReady } from '../utils/embeddings'; +import { isGemmaConfigured } from '../utils/gemmaEngine'; +import { + shouldRunBrief, + shouldRunSynthesis, + recordBrief, + recordSynthesis, + nextBriefRelative, + nextSynthesisRelative, + getAutoBriefState +} from '../utils/episodicAutoBrief'; +import { computeStreak, streakLabel } from '../utils/episodicStreak'; +import { detectDecisionDrifts, formatDrift } from '../utils/episodicDrift'; +import { consumeDeepLinkCapture, bookmarkletSource } from '../utils/episodicDeepLink'; +import { isSpeechSupported, createSpeechSession } from '../utils/speech'; +import { ocrImage } from '../utils/ocr'; +import { askTheVault } from '../utils/episodicAsk'; +import EpisodicGraph from './EpisodicGraph'; + +const EXAMPLE_CAPTURES = [ + { + title: 'Picked Postgres over DynamoDB', + text: 'Decided to go with Postgres for the new analytics service. Trade-off: we lose horizontal scale-out for free, but query flexibility is more valuable for this team. Locked in for 2026 H1.' + }, + { + title: 'Production outage Wednesday', + text: 'Auth service crashed at 14:02. Root cause was a missing migration on a staging-only column. Customer-facing for 8 minutes. Regret not catching this in the deploy review — should have been obvious.' + }, + { + title: 'Ferrari paper on second brain', + text: 'Read https://github.com/breferrari/obsidian-mind — the lifecycle hooks pattern (SessionStart / Stop / PostToolUse) is interesting. Same shape as STDP consolidation in Dream Mode. Quote: "procedural code owns the environment, the agent owns content."' + }, + { + title: 'Why does pressure spike on weekends?', + text: 'Question I keep returning to: the firewall logs show mean pressure climbs 30% on Saturdays. Is it me reading more outrage content, or is the corpus actually different on weekends?' + } +]; + +function fmtTime(ts) { + const d = new Date(ts); + const now = Date.now(); + const diff = now - ts; + const min = Math.floor(diff / 60000); + if (min < 1) return 'just now'; + if (min < 60) return `${min}m`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h`; + const dd = Math.floor(hr / 24); + if (dd < 7) return `${dd}d`; + return d.toISOString().slice(0, 10); +} + +function CategoryChip({ id, score, isPrimary }) { + const cat = EPISODIC_CATEGORIES[id]; + if (!cat) return null; + const pct = Math.round((score || 0) * 100); + return ( + <span + className="firewall-chip" + style={{ + borderColor: cat.color, + background: isPrimary ? `${cat.color}33` : 'transparent', + color: cat.color, + fontWeight: isPrimary ? 600 : 400 + }} + title={cat.description} + > + {cat.icon} {cat.label} {pct ? `${pct}%` : ''} + </span> + ); +} + +function CaptureCard({ capture, onApply, onDelete, onPin, onFocus, onRedact }) { + const cat = EPISODIC_CATEGORIES[capture.primary]; + const fwPct = Math.round((capture.firewall?.pressure || 0) * 100); + const affChip = capture.affects?.dominant?.[0]; + const [similar, setSimilar] = useState(null); + const primaryScore = capture.classification?.scores?.[capture.primary]?.score || 0; + const weak = (capture.classification?.dominant?.length || 0) === 0 + || (capture.primary === 'artifact' && primaryScore < 0.18); + + function toggleSimilar() { + if (similar !== null) { setSimilar(null); return; } + const cosineHits = findSimilar(capture.id, { k: 3, minScore: 0.30 }) + .map((h) => ({ ...h, kind: 'cosine' })); + const linkHits = findBacklinks(capture.id, { k: 3 }) + .map((h) => ({ ...h, kind: 'link' })); + // Merge, dedup by id, prefer cosine. + const seen = new Set(); + const merged = []; + for (const h of [...cosineHits, ...linkHits]) { + if (seen.has(h.id)) continue; + seen.add(h.id); + merged.push(h); + } + setSimilar(merged.slice(0, 6)); + } + + return ( + <div + style={{ + padding: '10px 12px', + borderLeft: `3px solid ${cat?.color || '#7c8aa1'}`, + background: 'rgba(255,255,255,0.03)', + borderRadius: 6, + marginTop: 8 + }} + > + <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'baseline' }}> + <strong style={{ flex: 1, lineHeight: 1.3 }}> + {capture.kind === 'insight' && <span style={{ color: '#5ad4ff', marginRight: 6 }}>✦ insight</span>} + {capture.title} + </strong> + <span className="muted small-note" style={{ whiteSpace: 'nowrap' }}> + {fmtTime(capture.ts)} + {capture.consolidatedCount > 0 && ( + <span title={`Reinforced ${capture.consolidatedCount}× by Dream Mode`} style={{ marginLeft: 6, color: '#a86fdf' }}> + · ◐{capture.consolidatedCount} + </span> + )} + </span> + </div> + <div className="firewall-chips" style={{ marginTop: 6 }}> + <CategoryChip id={capture.primary} score={capture.classification?.scores?.[capture.primary]?.score} isPrimary /> + {capture.secondary && <CategoryChip id={capture.secondary} score={capture.classification?.scores?.[capture.secondary]?.score} />} + {affChip && ( + <span className="firewall-chip" style={{ borderColor: affChip.color, color: affChip.color }} title="Dominant affect"> + ♥ {affChip.label} + </span> + )} + {fwPct > 25 && ( + <span className="firewall-chip" style={{ borderColor: fwPct > 50 ? '#ff4066' : '#fdab43', color: fwPct > 50 ? '#ff4066' : '#fdab43' }} title="Firewall manipulation pressure"> + ⚠ {fwPct}% + </span> + )} + {capture.pii?.total > 0 && ( + <span + className="firewall-chip" + style={{ borderColor: '#ff8a96', color: '#ff8a96' }} + title={`Detected ${capture.pii.total} PII / secret match${capture.pii.total === 1 ? '' : 'es'}: ${capture.pii.kinds?.join(', ')}`} + > + 🔒 PII: {capture.pii.first} + </span> + )} + {capture.urls?.[0] && ( + <a className="firewall-chip" href={capture.urls[0]} target="_blank" rel="noopener noreferrer" style={{ color: '#5ad4ff' }}> + ↗ link + </a> + )} + </div> + <p className="muted small-note" style={{ margin: '6px 0 0 0', lineHeight: 1.45 }}> + {capture.text.slice(0, 220)}{capture.text.length > 220 ? '…' : ''} + </p> + {weak && ( + <p className="muted small-note" style={{ margin: '4px 0 0 0', fontStyle: 'italic', opacity: 0.7 }}> + Weak signal — no triggers fired. Add a verb (decided / regret / wondering / shipped) or an @mention to help the brain place this note. + </p> + )} + <div className="episodic-card-actions"> + <button className="ghost small" onClick={() => onApply?.(capture)} title="Push this capture's region activation to the 3D brain"> + → Brain + </button> + <button className="ghost small" onClick={toggleSimilar} title="Find captures most similar to this one (cosine via MiniLM, lexical fallback)"> + ≈ similar + </button> + {capture.pii?.total > 0 && ( + <button + className="ghost small" + onClick={() => onRedact?.(capture)} + title={`Replace ${capture.pii.total} PII / secret match${capture.pii.total === 1 ? '' : 'es'} with typed placeholders`} + style={{ color: '#ff8a96' }} + > + ✂ redact + </button> + )} + <button className="ghost small" onClick={() => onPin?.(capture)}> + {capture.pinned ? '★ pinned' : '☆ pin'} + </button> + <button className="ghost small" onClick={() => onDelete?.(capture)}> + delete + </button> + </div> + {similar && ( + <div style={{ marginTop: 6 }}> + {similar.length === 0 && ( + <p className="muted small-note" style={{ margin: 0 }}> + No related captures yet (try Warm embeddings, or capture more notes that mention the same person / link). + </p> + )} + {similar.map((s) => ( + <button + key={s.id} + className="ghost small" + onClick={() => onFocus?.(s.id)} + style={{ display: 'block', width: '100%', textAlign: 'left', marginTop: 4, padding: '4px 8px', fontSize: 11 }} + title={s.kind === 'link' ? `Linked via ${s.shared?.join(', ')}` : 'Cosine similarity'} + > + <span className="muted">{s.kind === 'link' ? '↔' : '≈'} {s.score.toFixed(2)}</span>{' '} + <strong>{s.capture.title.slice(0, 65)}</strong> + {s.kind === 'link' && s.shared && ( + <span className="muted" style={{ marginLeft: 6 }}>· {s.shared.slice(0, 2).join(' ')}</span> + )} + </button> + ))} + </div> + )} + </div> + ); +} + +function BriefCard({ brief, onUseInsight }) { + if (!brief) return null; + return ( + <div className="episodic-brief-card"> + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}> + <strong style={{ color: '#5ad4ff' }}>Daily Brief</strong> + <span className="muted small-note"> + {brief.count} captures · {brief.source}{brief.gemmaError ? ` (gemma: ${brief.gemmaError})` : ''} + </span> + </div> + + <div style={{ marginTop: 8 }}> + <strong className="muted small-note" style={{ display: 'block', marginBottom: 6 }}>Connections</strong> + {brief.connections?.length ? brief.connections.map((c, i) => ( + <div key={i} style={{ padding: '6px 8px', marginBottom: 4, background: 'rgba(255,255,255,0.03)', borderRadius: 4 }}> + <div><strong>{i + 1}.</strong> {c.headline}</div> + <div className="muted small-note">{c.detail}</div> + </div> + )) : <p className="muted small-note">No connections yet — feed it more captures.</p>} + </div> + + <div style={{ marginTop: 10 }}> + <strong className="muted small-note">Pattern</strong> + <p style={{ margin: '4px 0 0 0' }}>{brief.pattern}</p> + </div> + + <div style={{ marginTop: 10 }}> + <strong className="muted small-note">Question</strong> + <p style={{ margin: '4px 0 0 0', fontStyle: 'italic' }}>"{brief.question}"</p> + </div> + + <div style={{ marginTop: 10, display: 'flex', gap: 6 }}> + <button className="ghost small" onClick={() => onUseInsight?.(brief)} title="Save the brief back into the vault as an insight node"> + Save as insight + </button> + </div> + </div> + ); +} + +function SynthesisCard({ synth, onUseInsight }) { + if (!synth) return null; + const driftCount = synth.drifts?.length || 0; + return ( + <div className="episodic-synth-card"> + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}> + <strong style={{ color: '#a86fdf' }}> + Weekly Synthesis + {driftCount > 0 && <span style={{ marginLeft: 8, color: '#ffaaaa', fontSize: 12 }}>· {driftCount} decision drift{driftCount === 1 ? '' : 's'}</span>} + </strong> + <span className="muted small-note">{synth.count} captures · {synth.source}</span> + </div> + + <div> + <strong className="muted small-note">Emerging thesis</strong> + <p style={{ margin: '4px 0 0 0' }}>{synth.emergingThesis}</p> + </div> + + <div style={{ marginTop: 10 }}> + <strong className="muted small-note">Contradictions & decision drifts</strong> + {synth.contradictions?.length ? synth.contradictions.map((c, i) => ( + <div key={i} className={c.kind === 'drift' ? 'episodic-drift-card' : ''} style={c.kind === 'drift' ? {} : { padding: '6px 8px', marginTop: 4, background: 'rgba(255,255,255,0.03)', borderRadius: 4 }}> + <div><strong>{c.headline}</strong></div> + <div className="muted small-note">{c.detail}</div> + </div> + )) : <p className="muted small-note">None detected — your beliefs are aligned this week.</p>} + </div> + + <div style={{ marginTop: 10 }}> + <strong className="muted small-note">Knowledge gaps</strong> + {synth.knowledgeGaps?.length ? synth.knowledgeGaps.map((g, i) => ( + <div key={i} style={{ padding: '6px 8px', marginTop: 4, background: 'rgba(255,255,255,0.03)', borderRadius: 4 }}> + <div><strong>{g.headline}</strong></div> + <div className="muted small-note">{g.detail}</div> + </div> + )) : <p className="muted small-note">No gaps — every open thread has a follow-up.</p>} + </div> + + <div style={{ marginTop: 10 }}> + <strong className="muted small-note">One action</strong> + <p style={{ margin: '4px 0 0 0', fontStyle: 'italic' }}>{synth.oneAction}</p> + </div> + + <div style={{ marginTop: 10, display: 'flex', gap: 6 }}> + <button className="ghost small" onClick={() => onUseInsight?.(synth)}> + Save as insight + </button> + </div> + </div> + ); +} + +/** + * Layer 101 — Episodic Cortex. + * + * Capture every article, tweet, voice-note transcript, decision, or + * idea. Each capture flows through Layer 4 (firewall) + Layer 29 + * (affect) + Layer 87 (genre) + the Layer 101 episodic taxonomy and + * lights up the 3D brain accordingly. Push the same captures through + * Daily Brief / Weekly Synthesis to make the vault talk back. + */ +export default function EpisodicCortexPanel({ onApplyEpisodic }) { + const [draft, setDraft] = useState(''); + const [draftTitle, setDraftTitle] = useState(''); + const [filter, setFilter] = useState('all'); + const [search, setSearch] = useState(''); + const [, setTick] = useState(0); // re-render trigger + const [brief, setBrief] = useState(null); + const [synth, setSynth] = useState(null); + const [busy, setBusy] = useState(null); // 'embed' | 'brief' | 'synth' | null + const [embedReady, setEmbedReady] = useState(embeddingsReady()); + const [autoBriefReady, setAutoBriefReady] = useState(null); // {ok, freshCount} | null + const [autoSynthReady, setAutoSynthReady] = useState(null); + const [bookmarkletOpen, setBookmarkletOpen] = useState(false); + const [voiceState, setVoiceState] = useState('idle'); // 'idle' | 'listening' + const [ocrProgress, setOcrProgress] = useState(0); + const [askDraft, setAskDraft] = useState(''); + const [askResult, setAskResult] = useState(null); + const [streakPulse, setStreakPulse] = useState(null); // { current, longest } | null + const [autoRun, setAutoRun] = useState(() => { + try { return localStorage.getItem('brainsnn_episodic_autorun_v1') === '1'; } catch { return false; } + }); + const voiceSessionRef = useRef(null); + const ocrInputRef = useRef(null); + const cardRefs = useRef(new Map()); + + // Consume any deep-link capture on mount (one-shot — params get cleaned up). + useEffect(() => { + const cap = consumeDeepLinkCapture(); + if (cap) { + onApplyEpisodic?.(cap); + } + }, []); + + // Cleanup voice session on unmount so the mic doesn't stay hot if the + // panel unmounts (route change, error boundary recover) while listening. + useEffect(() => () => { + try { voiceSessionRef.current?.stop(); } catch { /* ignore */ } + }, []); + + // Quietly warm MiniLM embeddings ~12 seconds after the panel mounts IF + // the user already has captures and is in a fast environment (best- + // effort — failures are silent; the lexical fallback always works). + useEffect(() => { + if (embedReady) return; + const stats0 = captureStats(); + if (stats0.total < 5) return; + const t = setTimeout(() => { + initEmbeddings() + .then(() => ensureAllEmbeddings()) + .then(() => setEmbedReady(true)) + .catch(() => { /* graceful — fall back to lexical */ }); + }, 12_000); + return () => clearTimeout(t); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const unsub = subscribeEpisodic(() => setTick((x) => x + 1)); + return () => unsub(); + }, []); + + // Re-render when embeddings warm up + useEffect(() => { + if (embedReady) return; + const t = setInterval(() => { + if (embeddingsReady()) { + setEmbedReady(true); + clearInterval(t); + } + }, 1500); + return () => clearInterval(t); + }, [embedReady]); + + const captures = useMemo(() => { + const opts = {}; + if (filter !== 'all') opts.category = filter; + if (search.trim()) opts.search = search.trim(); + const list = getCaptures(opts); + // Pinned float to the top, then chronological desc. + list.sort((a, b) => { + if (!!b.pinned !== !!a.pinned) return b.pinned ? 1 : -1; + return b.ts - a.ts; + }); + return list.slice(0, 30); + }, [filter, search]); + + const allCapturesForGraph = useMemo(() => getCaptures().slice(0, 60), [captures]); + const stats = useMemo(() => captureStats(), [captures]); + const streak = useMemo(() => computeStreak(getCaptures()), [captures]); + + // Re-evaluate auto-brief readiness when captures change AND on a slow + // interval so a panel left open across midnight (or after the cooldown + // window expires) catches up without waiting for the next capture. + useEffect(() => { + const refresh = () => { + const all = getCaptures(); + setAutoBriefReady(shouldRunBrief(all)); + setAutoSynthReady(shouldRunSynthesis(all)); + }; + refresh(); + const t = setInterval(refresh, 60_000); + return () => clearInterval(t); + }, [captures]); + + // Background auto-run — opt-in via a toggle, persisted. When the brief + // becomes ready, wait 45s (so the user isn't interrupted mid-thought) + // then run silently. The timer is cancelled on: + // - manual capture via Capture button + // - the user typing in the draft / title / ask boxes + // - any subsequent re-render that toggles busy / brief state. + useEffect(() => { + if (!autoRun) return; + if (busy) return; + if (draft.trim() || askDraft.trim()) return; // user is mid-typing + let timer = null; + if (autoBriefReady?.ok && !brief) { + timer = setTimeout(() => { handleBrief(); }, 45_000); + } else if (autoSynthReady?.ok && !synth) { + timer = setTimeout(() => { handleSynthesis(); }, 60_000); + } + return () => { if (timer) clearTimeout(timer); }; + }, [autoRun, autoBriefReady?.ok, autoSynthReady?.ok, busy, brief, synth, draft, askDraft]); + + function toggleAutoRun() { + setAutoRun((v) => { + const next = !v; + try { localStorage.setItem('brainsnn_episodic_autorun_v1', next ? '1' : '0'); } catch { /* ignore */ } + return next; + }); + } + + function handleCapture() { + const text = draft.trim(); + if (!text) return; + const before = computeStreak(getCaptures()); + const cap = addCapture(text, { title: draftTitle.trim() || undefined }); + if (cap) { + onApplyEpisodic?.(cap); + setDraft(''); + setDraftTitle(''); + // First capture of a new UTC day → pulse a streak banner for 6s. + const after = computeStreak(getCaptures()); + if (after.current > before.current) { + setStreakPulse({ current: after.current, longest: after.longest }); + setTimeout(() => setStreakPulse(null), 6000); + } + } + } + + function handleApply(capture) { + onApplyEpisodic?.(capture); + } + + function handleDelete(capture) { + if (!confirm(`Delete "${capture.title}"?`)) return; + deleteCapture(capture.id); + } + + function handlePin(capture) { + togglePinned(capture.id); + } + + function handleRedact(capture) { + if (!capture?.id || !(capture.pii?.total > 0)) return; + if (!confirm(`Replace ${capture.pii.total} PII / secret match${capture.pii.total === 1 ? '' : 'es'} in "${capture.title}" with typed placeholders? Original text will be lost.`)) return; + const redacted = redactPII(capture.text); + rewriteCapture(capture.id, redacted); + } + + function loadExample(i) { + const ex = EXAMPLE_CAPTURES[i]; + setDraftTitle(ex.title); + setDraft(ex.text); + } + + async function handleAsk() { + const q = askDraft.trim(); + if (!q) return; + setBusy('ask'); + try { + const res = await askTheVault(q); + setAskResult(res); + } finally { + setBusy(null); + } + } + + function focusCapture(id) { + const el = cardRefs.current.get(id); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.style.boxShadow = '0 0 0 2px #5ad4ff'; + setTimeout(() => { if (el) el.style.boxShadow = ''; }, 1400); + } + + async function handleOcrPick(e) { + const file = e.target.files?.[0]; + if (!file) return; + setBusy('ocr'); + setOcrProgress(0); + try { + const out = await ocrImage(file, { onProgress: (p) => setOcrProgress(p) }); + if (!out.text) { + alert('OCR found no text in this image.'); + return; + } + const title = (file.name || 'screenshot').replace(/\.[a-zA-Z0-9]{2,5}$/, '').slice(0, 80); + setDraftTitle((t) => t || title); + setDraft((d) => (d ? `${d}\n\n${out.text}` : out.text)); + } catch (err) { + alert(`OCR error: ${err.message || err}`); + } finally { + setBusy(null); + setOcrProgress(0); + if (ocrInputRef.current) ocrInputRef.current.value = ''; + } + } + + function handleVoiceToggle() { + if (voiceState === 'listening') { + voiceSessionRef.current?.stop(); + return; + } + const session = createSpeechSession({ lang: 'en-US', continuous: true }); + if (!session.supported) { + alert('Web Speech API not supported in this browser. Try Chrome / Edge / Safari.'); + return; + } + voiceSessionRef.current = session; + setVoiceState('listening'); + session.start((evt) => { + if (evt.error) { + setVoiceState('idle'); + alert(`Voice capture error: ${evt.error}`); + return; + } + if (evt.state === 'stopped') { + setVoiceState('idle'); + // session.stop already gives us the final transcript via evt.final + if (evt.final) setDraft((d) => (d ? `${d} ${evt.final}` : evt.final)); + return; + } + if (evt.final || evt.interim) { + setDraft(((evt.final || '') + (evt.interim ? ` ${evt.interim}` : '')).trim()); + } + }); + } + + async function handleWarmEmbed() { + setBusy('embed'); + try { + if (!embeddingsReady()) await initEmbeddings(); + setEmbedReady(true); + const res = await ensureAllEmbeddings(); + alert(res.ok ? `Embedded ${res.done}/${res.total} captures.` : `Embedding paused: ${res.reason}`); + } catch (err) { + alert(`Embedding error: ${err.message || err}`); + } finally { + setBusy(null); + } + } + + async function handleBrief() { + setBusy('brief'); + try { + const all = getCaptures(); + const result = await dailyBrief(all); + setBrief(result); + recordBrief(result, all.length); + setAutoBriefReady(shouldRunBrief(all)); + } finally { + setBusy(null); + } + } + + async function handleSynthesis() { + setBusy('synth'); + try { + const all = getCaptures(); + const result = await weeklySynthesis(all); + // Augment with decision-drift detector before rendering. + const drifts = detectDecisionDrifts(all, { topK: 3 }); + const driftCards = drifts.map(formatDrift).filter(Boolean); + const enriched = { + ...result, + contradictions: [...(result.contradictions || []), ...driftCards].slice(0, 5), + drifts + }; + setSynth(enriched); + recordSynthesis(enriched); + setAutoSynthReady(shouldRunSynthesis(all)); + } finally { + setBusy(null); + } + } + + function handleSaveBriefAsInsight(b) { + if (!b) return; + const lines = []; + lines.push(`# Daily Brief — ${new Date().toISOString().slice(0, 10)}`); + lines.push(`Pattern: ${b.pattern}`); + lines.push(`Question: ${b.question}`); + if (b.connections?.length) { + lines.push('Connections:'); + b.connections.forEach((c, i) => lines.push(`${i + 1}. ${c.headline} — ${c.detail}`)); + } + addInsight(lines.join('\n'), { title: `Brief · ${new Date().toISOString().slice(0, 10)}` }); + } + + function handleSaveSynthAsInsight(s) { + if (!s) return; + const lines = []; + lines.push(`# Weekly Synthesis — ${new Date().toISOString().slice(0, 10)}`); + lines.push(`Thesis: ${s.emergingThesis}`); + lines.push(`Action: ${s.oneAction}`); + if (s.contradictions?.length) { + lines.push('Contradictions:'); + s.contradictions.forEach((c, i) => lines.push(`${i + 1}. ${c.headline} — ${c.detail}`)); + } + if (s.knowledgeGaps?.length) { + lines.push('Gaps:'); + s.knowledgeGaps.forEach((g, i) => lines.push(`${i + 1}. ${g.headline} — ${g.detail}`)); + } + addInsight(lines.join('\n'), { title: `Synthesis · ${new Date().toISOString().slice(0, 10)}` }); + } + + function handleExport() { + const bundle = exportEpisodicBundle(); + const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `brainsnn-episodic-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + } + + function handleImport(e) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const bundle = JSON.parse(String(reader.result)); + const res = importEpisodicBundle(bundle, { merge: true }); + if (res.ok) alert(`Imported. Total captures: ${res.count}`); + else alert(`Import failed: ${res.reason}`); + } catch (err) { + alert(`Bad bundle: ${err.message}`); + } + }; + reader.readAsText(file); + } + + function handleWipe() { + if (!confirm('Wipe ALL episodic captures? This cannot be undone.')) return; + clearAllCaptures(); + setBrief(null); + setSynth(null); + } + + const gemmaOn = isGemmaConfigured(); + + return ( + <section className="panel panel-pad episodic-cortex-panel"> + <div className="eyebrow">Layer 101 · episodic cortex</div> + <h2>The vault that talks back</h2> + <p className="muted"> + Drop articles, tweets, voice-note transcripts, decisions, or shower-thought ideas. + Each capture flows through the firewall, the affect decoder, and an 8-way + episodic router — then lights up the 3D brain. Two outputs make the vault + push insights back: <strong>Daily Brief</strong> (connections / pattern / + question) and <strong>Weekly Synthesis</strong> (thesis / contradictions / + gaps / one action). Local-only by default; <em>Gemma upgrades the synthesis + prompts when configured</em>{gemmaOn ? ' — and it is configured.' : '.'} + </p> + + <div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap', marginTop: 4, marginBottom: 8 }}> + <span className="muted small-note">Try:</span> + {EXAMPLE_CAPTURES.map((ex, i) => ( + <button key={i} className="ghost small" onClick={() => loadExample(i)}> + {ex.title.length > 32 ? ex.title.slice(0, 32) + '…' : ex.title} + </button> + ))} + </div> + + <input + type="text" + className="share-input" + placeholder="Title (optional — inferred from first line if blank)" + value={draftTitle} + onChange={(e) => setDraftTitle(e.target.value.slice(0, 120))} + maxLength={120} + style={{ width: '100%', marginBottom: 8 }} + /> + <textarea + className="firewall-input" + placeholder="Paste an article, a tweet, a thought, a decision, a meeting note..." + value={draft} + onChange={(e) => setDraft(e.target.value)} + rows={5} + /> + + <div className="control-actions" style={{ marginTop: 10, flexWrap: 'wrap' }}> + <button className="btn primary" onClick={handleCapture} disabled={!draft.trim()}> + Capture → + </button> + <button className="btn" onClick={handleBrief} disabled={busy === 'brief' || stats.total < 1}> + {busy === 'brief' ? 'Briefing…' : 'Daily Brief'} + </button> + <button className="btn" onClick={handleSynthesis} disabled={busy === 'synth' || stats.total < 3}> + {busy === 'synth' ? 'Synthesizing…' : 'Weekly Synthesis'} + </button> + {isSpeechSupported() && ( + <button + className={voiceState === 'listening' ? 'btn primary' : 'ghost small'} + onClick={handleVoiceToggle} + title="Web Speech API live transcription into the capture draft" + aria-pressed={voiceState === 'listening'} + aria-label={voiceState === 'listening' ? 'Stop voice capture' : 'Start voice capture'} + style={voiceState === 'listening' ? { background: '#ff4066', color: '#fff' } : {}} + > + {voiceState === 'listening' ? '● Stop voice' : '🎙 Voice capture'} + </button> + )} + <label className="ghost small" style={{ cursor: 'pointer' }} title="Tesseract.js OCR — paste a screenshot text into the draft"> + {busy === 'ocr' ? `OCR ${Math.round(ocrProgress * 100)}%` : '◳ OCR image'} + <input + ref={ocrInputRef} + type="file" + accept="image/*" + onChange={handleOcrPick} + style={{ display: 'none' }} + disabled={busy === 'ocr'} + /> + </label> + <button + className="ghost small" + onClick={handleWarmEmbed} + disabled={busy === 'embed'} + title={embedReady ? 'Embed any capture without a vector' : 'Load MiniLM (~25MB) and embed all captures'} + > + {busy === 'embed' ? 'Embedding…' : embedReady ? '↻ Re-embed' : '⇣ Warm embeddings'} + </button> + </div> + + {streakPulse && ( + <div className="episodic-autobrief-banner" role="status" aria-live="polite" style={{ borderColor: 'rgba(94,230,154,0.5)', background: 'linear-gradient(90deg, rgba(94,230,154,0.10), rgba(255,212,138,0.10))' }}> + <span> + <strong style={{ color: '#5ee69a' }}> + {streakPulse.current === 1 ? '🌱 Streak started' : `🔥 ${streakPulse.current}-day streak — kept alive`} + </strong> + {streakPulse.longest > streakPulse.current && ( + <span className="muted small-note" style={{ marginLeft: 8 }}>longest: {streakPulse.longest}d</span> + )} + </span> + </div> + )} + + {(autoBriefReady?.ok || autoSynthReady?.ok) && ( + <div className="episodic-autobrief-banner" role="status" aria-live="polite"> + <span> + <strong>The vault is ready to talk back.</strong>{' '} + {autoBriefReady?.ok && <>Daily brief unlocked ({autoBriefReady.freshCount} fresh captures). </>} + {autoSynthReady?.ok && <>Weekly synthesis unlocked ({autoSynthReady.weeklyCount} this week). </>} + {autoRun && <em>· auto-run on</em>} + </span> + <span style={{ display: 'flex', gap: 6, alignItems: 'center' }}> + <label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, cursor: 'pointer' }} title="Run brief / synthesis automatically 45s after they unlock"> + <input type="checkbox" checked={autoRun} onChange={toggleAutoRun} /> + auto-run + </label> + {autoBriefReady?.ok && ( + <button className="btn" onClick={handleBrief} disabled={busy === 'brief'}> + Run brief + </button> + )} + {autoSynthReady?.ok && ( + <button className="btn" onClick={handleSynthesis} disabled={busy === 'synth'}> + Run synthesis + </button> + )} + </span> + </div> + )} + + <BriefCard brief={brief} onUseInsight={handleSaveBriefAsInsight} /> + <SynthesisCard synth={synth} onUseInsight={handleSaveSynthAsInsight} /> + + <div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 8, background: 'rgba(255,212,138,0.06)', border: '1px solid rgba(255,212,138,0.22)' }}> + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}> + <strong style={{ color: '#ffd48a' }}>Ask the vault</strong> + <span className="muted small-note">RAG over your captures · {embedReady ? 'cosine' : 'lexical fallback'}{isGemmaConfigured() ? ' + Gemma' : ''}</span> + </div> + <div style={{ display: 'flex', gap: 8 }}> + <input + type="text" + className="share-input" + placeholder='What did I think about Postgres last week? · Why am I saving fear-coded posts? · Decisions I have not committed to' + value={askDraft} + onChange={(e) => setAskDraft(e.target.value.slice(0, 240))} + onKeyDown={(e) => { if (e.key === 'Enter') handleAsk(); }} + style={{ flex: 1 }} + /> + <button className="btn primary" onClick={handleAsk} disabled={busy === 'ask' || !askDraft.trim()}> + {busy === 'ask' ? 'Searching…' : 'Ask →'} + </button> + </div> + {askResult?.ok && ( + <div style={{ marginTop: 10 }}> + <p style={{ margin: 0 }}>{askResult.answer}</p> + {askResult.hits?.length > 0 && ( + <div style={{ marginTop: 8 }}> + <strong className="muted small-note">Top {askResult.hits.length} hits</strong> + {askResult.hits.map((h, i) => ( + <button + key={h.capture.id} + className="ghost small" + onClick={() => focusCapture(h.capture.id)} + style={{ display: 'block', width: '100%', textAlign: 'left', marginTop: 4, padding: '6px 8px', background: 'rgba(255,255,255,0.03)' }} + > + <span className="muted" style={{ fontSize: 11 }}>{i + 1}.</span>{' '} + <strong>{h.capture.title.slice(0, 70)}</strong>{' '} + <span className="muted small-note">— {h.score.toFixed(2)} · {EPISODIC_CATEGORIES[h.capture.primary]?.label}</span> + </button> + ))} + </div> + )} + </div> + )} + {askResult && !askResult.ok && ( + <p className="muted small-note" style={{ marginTop: 8 }}> + {askResult.reason === 'empty-vault' ? 'Capture some notes first — the vault is empty.' + : askResult.reason === 'question-too-short' ? 'Type a longer question.' + : `No match: ${askResult.reason}`} + </p> + )} + </div> + + <div className="episodic-stats"> + <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> + <strong>Timeline</strong> + <span className={`episodic-streak-tile${streak.current ? '' : ' cold'}`} title={`Longest: ${streak.longest}d · ${streak.totalDays} active days total`}> + {streakLabel(streak)}{streak.todayCaptures ? ` · ${streak.todayCaptures} today` : ''} + </span> + </div> + <span className="muted small-note"> + {stats.total} total · {stats.last24h} last 24h · {stats.last7d} this week · mean pressure {Math.round(stats.meanPressure * 100)}% + {stats.dreamConsolidated > 0 && ` · ${stats.dreamConsolidated} dream-reinforced`} + {stats.withPII > 0 && ( + <span style={{ color: '#ff8a96', marginLeft: 6 }} title="Captures containing email / phone / API key / SSN / IP"> + · 🔒 {stats.withPII} flagged for PII + </span> + )} + </span> + </div> + + <EpisodicGraph + captures={allCapturesForGraph} + height={150} + onNodeClick={focusCapture} + /> + + <div className="episodic-pill-row" role="tablist" aria-label="Filter captures by episodic category"> + <button + role="tab" + aria-selected={filter === 'all'} + className={`ghost small ${filter === 'all' ? 'active' : ''}`} + onClick={() => setFilter('all')} + style={filter === 'all' ? { borderColor: '#5ad4ff', color: '#5ad4ff' } : {}} + > + all ({stats.total}) + </button> + {EPISODIC_IDS.map((id) => { + const cat = EPISODIC_CATEGORIES[id]; + const n = stats.byCategory[id] || 0; + return ( + <button + key={id} + role="tab" + aria-selected={filter === id} + className="ghost small" + onClick={() => setFilter(id)} + style={filter === id ? { borderColor: cat.color, color: cat.color } : { color: 'inherit', opacity: n ? 1 : 0.5 }} + title={cat.description} + aria-label={`Filter by ${cat.label}, ${n} captures`} + > + {cat.icon} {cat.label} ({n}) + </button> + ); + })} + </div> + + <input + type="text" + className="share-input" + placeholder="Search title / text / @mention / #tag..." + value={search} + onChange={(e) => setSearch(e.target.value.slice(0, 100))} + style={{ width: '100%', marginBottom: 8 }} + /> + + {captures.length > 0 ? ( + captures.map((c) => ( + <div key={c.id} ref={(el) => { if (el) cardRefs.current.set(c.id, el); else cardRefs.current.delete(c.id); }}> + <CaptureCard + capture={c} + onApply={handleApply} + onDelete={handleDelete} + onPin={handlePin} + onFocus={focusCapture} + onRedact={handleRedact} + /> + </div> + )) + ) : ( + <p className="muted small-note">No captures match the current filter. Drop one above to feed the vault.</p> + )} + + <div style={{ marginTop: 18, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.06)' }}> + <div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap', marginBottom: 8 }}> + <strong style={{ fontSize: 12 }}>Auto-brief schedule</strong> + <span className="muted small-note"> + next brief in <strong>{nextBriefRelative()}</strong> · next synthesis in <strong>{nextSynthesisRelative()}</strong> + </span> + </div> + + <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}> + <button className="ghost small" onClick={() => setBookmarkletOpen((v) => !v)}> + {bookmarkletOpen ? '× Hide bookmarklet' : '↗ Capture bookmarklet'} + </button> + <button className="ghost small" onClick={handleExport} disabled={!stats.total}> + Export bundle + </button> + <label className="ghost small" style={{ cursor: 'pointer' }}> + Import bundle + <input type="file" accept="application/json" onChange={handleImport} style={{ display: 'none' }} /> + </label> + <button className="ghost small" onClick={handleWipe} disabled={!stats.total} style={{ marginLeft: 'auto', color: '#ff8a96' }}> + Wipe all + </button> + </div> + + {bookmarkletOpen && ( + <div style={{ marginTop: 10, padding: '10px 12px', background: 'rgba(255,255,255,0.03)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.08)' }}> + <p className="muted small-note" style={{ marginTop: 0 }}> + Drag the link below into your bookmarks bar. Selecting any text on any + page and clicking the bookmarklet opens BrainSNN with the selection + pre-captured into the Episodic Cortex. + </p> + <a + href={bookmarkletSource(typeof window !== 'undefined' ? window.location.origin : '')} + draggable="true" + onClick={(e) => e.preventDefault()} + style={{ + display: 'inline-block', padding: '6px 12px', borderRadius: 6, + background: 'linear-gradient(90deg,#5ad4ff,#a86fdf)', + color: '#0c0e16', fontWeight: 600, textDecoration: 'none', cursor: 'grab' + }} + > + ↗ Capture to BrainSNN + </a> + <p className="muted small-note" style={{ marginBottom: 0, marginTop: 8 }}> + Deep-link contract: <code>?capture=<text></code>{' '} + <code>?capture-url=<url></code>{' '} + <code>?capture-title=<title></code>. + Used by the bookmarklet, the MCP relay, and any external integration. + </p> + </div> + )} + </div> + </section> + ); +} diff --git a/brainsnn-r3f-app/src/components/EpisodicGraph.jsx b/brainsnn-r3f-app/src/components/EpisodicGraph.jsx new file mode 100644 index 0000000..a1c1c56 --- /dev/null +++ b/brainsnn-r3f-app/src/components/EpisodicGraph.jsx @@ -0,0 +1,238 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { mineClusters } from '../utils/episodicMemory'; +import { categoryColor } from '../data/episodicTaxonomy'; + +const NODE_RADIUS = 5; +const EDGE_ALPHA = 0.32; +const HIGHLIGHT_RADIUS = 9; + +/** + * Layer 101 — Connection Graph mini-viz. + * + * Canvas-rendered radial cluster layout: + * - Each connection cluster (mineClusters) is laid out as a small + * wheel; clusters are spread along a sine-arc so dense weeks + * don't overlap. + * - Singletons land in a base row at the bottom. + * - Edges within a cluster are drawn at low alpha; the highlighted + * node's edges are drawn opaque. + * + * Pure presentational — the parent owns the captures array. + */ +export default function EpisodicGraph({ captures, height = 140, onNodeClick }) { + const canvasRef = useRef(null); + const [hoverIdx, setHoverIdx] = useState(-1); + const [size, setSize] = useState({ w: 600, h: height }); + + const layout = useMemo(() => buildLayout(captures || [], size.w, size.h), [captures, size.w, size.h]); + + useEffect(() => { + const ro = new ResizeObserver((entries) => { + for (const e of entries) { + const { width } = e.contentRect; + setSize((s) => (Math.abs(s.w - width) > 1 ? { w: Math.max(120, width), h: height } : s)); + } + }); + if (canvasRef.current?.parentElement) ro.observe(canvasRef.current.parentElement); + return () => ro.disconnect(); + }, [height]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.floor(size.w * dpr); + canvas.height = Math.floor(size.h * dpr); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + drawGraph(ctx, layout, size, hoverIdx); + }, [layout, size, hoverIdx]); + + function handleClick() { + if (hoverIdx < 0 || !onNodeClick) return; + const node = layout.nodes[hoverIdx]; + if (node?.id) onNodeClick(node.id); + } + + function handleMove(e) { + const canvas = canvasRef.current; + if (!canvas || !layout.nodes.length) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + let best = -1; + let bestD = HIGHLIGHT_RADIUS * HIGHLIGHT_RADIUS; + for (let i = 0; i < layout.nodes.length; i++) { + const n = layout.nodes[i]; + const dx = n.x - x; + const dy = n.y - y; + const d = dx * dx + dy * dy; + if (d < bestD) { bestD = d; best = i; } + } + setHoverIdx(best); + } + + if (!captures?.length) { + return ( + <div className="episodic-graph-host" role="img" aria-label="Empty connection graph — capture two or more notes to see clusters"> + <div className="episodic-graph-empty"> + Connection graph will render once you have ≥2 captures. + </div> + </div> + ); + } + + const hovered = hoverIdx >= 0 ? layout.nodes[hoverIdx] : null; + const clusterCount = layout.nodes.filter((n) => n.clusterIdx >= 0).length; + const singletonCount = layout.nodes.length - clusterCount; + + return ( + <div + className="episodic-graph-host" + role="img" + aria-label={`Capture connection graph: ${clusterCount} clustered nodes, ${singletonCount} singletons. Click a node to scroll to its capture.`} + > + <canvas + ref={canvasRef} + style={{ width: '100%', height: size.h, cursor: hoverIdx >= 0 ? 'pointer' : 'default' }} + onMouseMove={handleMove} + onMouseLeave={() => setHoverIdx(-1)} + onClick={handleClick} + /> + {hovered && ( + <div + style={{ + position: 'absolute', + left: clampPx(hovered.x + 10, 4, size.w - 220), + top: clampPx(hovered.y - 30, 4, size.h - 28), + background: 'rgba(0,0,0,0.86)', + color: '#e6f1ff', + border: `1px solid ${hovered.color}`, + borderRadius: 6, + padding: '4px 8px', + fontSize: 11, + pointerEvents: 'none', + maxWidth: 220, + lineHeight: 1.3 + }} + > + <div style={{ color: hovered.color, fontWeight: 600 }}>{hovered.label}</div> + <div style={{ opacity: 0.85 }}>{hovered.title.slice(0, 60)}</div> + </div> + )} + </div> + ); +} + +function clampPx(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + +function buildLayout(captures, w, h) { + if (!captures.length) return { nodes: [], edges: [] }; + + const clusters = mineClusters(captures, { threshold: 0.40 }); + const inCluster = new Set(); + for (const cl of clusters) for (const m of cl.members) inCluster.add(m.id); + const singletons = captures.filter((c) => !inCluster.has(c.id)).slice(0, 30); + + const nodes = []; + const edges = []; + + const padX = 28; + const innerW = w - padX * 2; + const topRowY = h * 0.42; + const baseRowY = h * 0.84; + + // Cluster wheels along the top arc + const N = Math.max(1, clusters.length); + for (let i = 0; i < clusters.length; i++) { + const cl = clusters[i]; + const cx = padX + ((i + 0.5) / N) * innerW; + const cy = topRowY; + const r = Math.min(34, 10 + cl.members.length * 4); + const memberCount = Math.min(cl.members.length, 12); // cap rendered members per cluster + for (let k = 0; k < memberCount; k++) { + const m = cl.members[k]; + const t = (k / memberCount) * Math.PI * 2; + nodes.push({ + id: m.id, + x: cx + Math.cos(t) * r, + y: cy + Math.sin(t) * (r * 0.7), + clusterIdx: i, + title: m.title, + label: `cluster ${i + 1} · ${cl.size} notes`, + color: categoryColor(m.primary), + radius: NODE_RADIUS + }); + } + // intra-cluster edges (chord pattern, not full mesh — keeps it readable) + const startIdx = nodes.length - memberCount; + for (let a = 0; a < memberCount; a++) { + for (let b = a + 1; b < memberCount; b++) { + if ((b - a) > 2 && (b - a) < memberCount - 2) continue; // sparse chord + edges.push({ from: startIdx + a, to: startIdx + b, color: categoryColor(cl.members[0].primary) }); + } + } + } + + // Singleton row along the bottom + const M = Math.max(1, singletons.length); + for (let j = 0; j < singletons.length; j++) { + const m = singletons[j]; + const cx = padX + ((j + 0.5) / M) * innerW; + nodes.push({ + id: m.id, + x: cx, + y: baseRowY + (j % 2 === 0 ? 0 : 8), + clusterIdx: -1, + title: m.title, + label: 'orphan', + color: categoryColor(m.primary), + radius: NODE_RADIUS - 1 + }); + } + + return { nodes, edges }; +} + +function drawGraph(ctx, { nodes, edges }, size, hoverIdx) { + ctx.clearRect(0, 0, size.w, size.h); + + // Background subtle grid + ctx.fillStyle = 'rgba(255,255,255,0.025)'; + for (let x = 0; x < size.w; x += 24) ctx.fillRect(x, 0, 1, size.h); + for (let y = 0; y < size.h; y += 24) ctx.fillRect(0, y, size.w, 1); + + // Edges + for (const e of edges) { + const a = nodes[e.from]; + const b = nodes[e.to]; + if (!a || !b) continue; + const isHot = hoverIdx === e.from || hoverIdx === e.to; + ctx.strokeStyle = e.color; + ctx.globalAlpha = isHot ? 0.85 : EDGE_ALPHA; + ctx.lineWidth = isHot ? 1.8 : 1; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + ctx.globalAlpha = 1; + + // Nodes + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + const isHot = i === hoverIdx; + ctx.beginPath(); + ctx.fillStyle = n.color; + ctx.arc(n.x, n.y, isHot ? n.radius + 2 : n.radius, 0, Math.PI * 2); + ctx.fill(); + if (isHot) { + ctx.beginPath(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.4; + ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2); + ctx.stroke(); + } + } +} diff --git a/brainsnn-r3f-app/src/data/episodicTaxonomy.js b/brainsnn-r3f-app/src/data/episodicTaxonomy.js new file mode 100644 index 0000000..be83322 --- /dev/null +++ b/brainsnn-r3f-app/src/data/episodicTaxonomy.js @@ -0,0 +1,169 @@ +/** + * Layer 101 — Episodic Cortex taxonomy + * + * Seven episodic categories that every captured note is routed through. + * Inspired by obsidian-mind's classifier (decision/incident/win/...) + * and cyrilXBT's "vault that talks back" architecture, but BrainSNN-native: + * each category has a brain-region affinity vector so a stream of captures + * actually drives the 3D cortex instead of sitting in a folder. + * + * Capture once → flows through firewall + affect + this taxonomy → lights + * up the brain → gets embedded → becomes part of the synthesis pool. + */ + +export const EPISODIC_CLUSTERS = { + internal: { label: 'From you', color: '#5ee69a' }, + external: { label: 'From the world', color: '#5ad4ff' }, + social: { label: 'About people', color: '#ff9ab8' }, + pivot: { label: 'Pivots & shocks', color: '#ff4066' } +}; + +export const EPISODIC_CATEGORIES = { + decision: { + id: 'decision', + label: 'Decision', + cluster: 'internal', + icon: '◆', + color: '#a86fdf', + description: 'A choice you took, a position you formed, a trade-off you owned.', + regions: { PFC: 0.55, CTX: 0.25, BG: 0.20 }, + triggers: [ + /\b(decide(?:d)?|deciding|chose|picked|going with|will (?:use|build|ship|adopt))/i, + /\b(committed to|locked in|settling on|final answer)/i, + /\b(tradeoff|trade-off|because we|chose .* over|in favor of)/i, + /\b(architecture|approach|strategy|principle|stance)/i + ] + }, + insight: { + id: 'insight', + label: 'Insight', + cluster: 'internal', + icon: '✦', + color: '#5ad4ff', + description: 'Your own thinking — observation, hypothesis, dot connected.', + regions: { CTX: 0.45, HPC: 0.30, PFC: 0.25 }, + triggers: [ + /\b(realized|noticed|observation|hypothes(?:is|ize)|theory|idea)/i, + /\b(it seems|i think|i suspect|maybe|might be|worth noting)/i, + /\b(connection between|same as|reminds me|pattern|recurring)/i, + /\b(insight|epiphany|click(?:ed)?|aha)/i + ] + }, + question: { + id: 'question', + label: 'Question', + cluster: 'internal', + icon: '?', + color: '#fdab43', + description: 'Something you do not yet know. Open loops the brain returns to.', + regions: { PFC: 0.40, CTX: 0.30, HPC: 0.30 }, + triggers: [ + /\?\s*$/m, + /\b(why|how come|what if|i wonder|do we know|is it true|why does)/i, + /\b(question|unclear|unknown|tbd|don't know|not sure)/i, + /\b(should i|should we|worth (?:asking|investigating|checking))/i + ] + }, + + artifact: { + id: 'artifact', + label: 'Artifact', + cluster: 'external', + icon: '◫', + color: '#77dbe4', + description: 'External input — saved article, tweet, paper, quote, snippet.', + regions: { CTX: 0.40, THL: 0.35, HPC: 0.25 }, + triggers: [ + /\bhttps?:\/\/\S+/, + /\b(article|paper|tweet|post|video|podcast|interview|quote)/i, + /\b(via|source|from .*\.(com|org|io|dev|ai|net))/i, + /^\s*["“]/m + ] + }, + win: { + id: 'win', + label: 'Win', + cluster: 'external', + icon: '★', + color: '#5ee69a', + description: 'Shipped, succeeded, broke through, hit a milestone.', + regions: { BG: 0.45, PFC: 0.30, CTX: 0.25 }, + triggers: [ + /\b(shipped|launched|released|deployed|merged|landed)/i, + /\b(won|nailed|cracked|solved|fixed|finished|completed)/i, + /\b(milestone|breakthrough|first time|achievement)/i, + /\b(:rocket:|🚀|🎉|✅|finally)/i + ] + }, + project: { + id: 'project', + label: 'Project', + cluster: 'external', + icon: '▶', + color: '#5591c7', + description: 'Active work — plan, milestone, status, in-progress thread.', + regions: { PFC: 0.45, CTX: 0.35, BG: 0.20 }, + triggers: [ + /\b(working on|building|prototyp(?:e|ing)|drafting|writing)/i, + /\b(milestone|sprint|todo|roadmap|next step|in progress)/i, + /\b(blocked on|waiting on|stuck on|need to)/i, + /\b(project|repo|codebase|product|feature|launch)/i + ] + }, + + person: { + id: 'person', + label: 'Person', + cluster: 'social', + icon: '☻', + color: '#ff9ab8', + description: 'A note about a human — meeting, observation, relationship.', + regions: { CTX: 0.35, AMY: 0.35, HPC: 0.30 }, + triggers: [ + /@[a-zA-Z][\w-]{1,30}\b/, + /\b(met with|talked to|coffee with|1:1 with|call with)/i, + /\b(said|told me|mentioned|asked|raised|pushed back)/i, + /\b(my (?:manager|colleague|friend|partner|cofounder|kid|dad|mom))/i + ] + }, + + incident: { + id: 'incident', + label: 'Incident', + cluster: 'pivot', + icon: '⚠', + color: '#ff4066', + description: 'Something broke, surprised, or hurt. The kind of moment to remember.', + regions: { AMY: 0.45, HPC: 0.30, THL: 0.25 }, + triggers: [ + /\b(broke|broken|down|outage|incident|crash(?:ed)?|failed|failure)/i, + /\b(regret|mistake|oops|messed up|screwed up|fucked up)/i, + /\b(surprised|shocked|caught off guard|did not see)/i, + /\b(production|prod|live|customer-facing) (?:bug|issue|outage)/i + ] + } +}; + +export const EPISODIC_IDS = Object.keys(EPISODIC_CATEGORIES); + +/** + * Default activation profile when a capture matches no triggers. + * Diffuse cortical input — treat it as an artifact-by-default. + */ +export const DEFAULT_REGIONS = { CTX: 0.30, THL: 0.20, HPC: 0.20, PFC: 0.15, AMY: 0.05, BG: 0.05, CBL: 0.05 }; + +/** + * Color lookup — returns the dominant category color, or grey if none. + */ +export function categoryColor(id) { + return EPISODIC_CATEGORIES[id]?.color || '#7c8aa1'; +} + +/** + * Cluster label/color for grouping in the panel UI. + */ +export function clusterFor(id) { + const cat = EPISODIC_CATEGORIES[id]; + if (!cat) return EPISODIC_CLUSTERS.external; + return EPISODIC_CLUSTERS[cat.cluster] || EPISODIC_CLUSTERS.external; +} diff --git a/brainsnn-r3f-app/src/styles/global.css b/brainsnn-r3f-app/src/styles/global.css index 27d340c..c423a66 100644 --- a/brainsnn-r3f-app/src/styles/global.css +++ b/brainsnn-r3f-app/src/styles/global.css @@ -3075,3 +3075,92 @@ h1,h2,h3{ .lower-grid,.metric-grid,.inspector-grid,.export-panel .export-grid{grid-template-columns:1fr} .inline-bar{width:70px} } + +/* ---------- Layer 101 — Episodic Cortex ---------- */ +.episodic-cortex-panel{ + display:flex;flex-direction:column;gap:10px; + background:linear-gradient(180deg, rgba(90,212,255,0.04), rgba(168,111,223,0.04) 60%, transparent); + border:1px solid rgba(90,212,255,0.18); +} +.episodic-cortex-panel h2{ + background:linear-gradient(90deg,#5ad4ff 0%, #a86fdf 60%, #ff9ab8 100%); + -webkit-background-clip:text;background-clip:text;color:transparent; + margin:0; +} +.episodic-pill-row{display:flex;flex-wrap:wrap;gap:6px} +.episodic-pill{ + font-size:11px;font-weight:500; + padding:4px 10px;border-radius:14px;border:1px solid currentColor; + background:transparent;cursor:pointer;line-height:1.3; + transition:transform .12s ease, background .12s ease; +} +.episodic-pill:hover{transform:translateY(-1px)} +.episodic-pill.active{font-weight:700} +.episodic-card{ + padding:10px 12px;border-radius:8px; + background:rgba(255,255,255,0.03); + border-left:3px solid #7c8aa1;margin-top:8px; + transition:background .15s ease, transform .12s ease; +} +.episodic-card:hover{background:rgba(255,255,255,0.05);transform:translateX(2px)} +.episodic-card.consolidated{box-shadow:inset 0 0 0 1px rgba(168,111,223,0.25)} +.episodic-card-head{display:flex;justify-content:space-between;gap:8px;align-items:baseline} +.episodic-card-actions{display:flex;gap:6px;margin-top:6px;flex-wrap:wrap} +.episodic-brief-card,.episodic-synth-card{ + padding:14px 16px;border-radius:10px;margin-top:14px; +} +.episodic-brief-card{background:rgba(90,212,255,0.06);border:1px solid rgba(90,212,255,0.25)} +.episodic-synth-card{background:rgba(168,111,223,0.06);border:1px solid rgba(168,111,223,0.3)} +.episodic-brief-row{padding:6px 8px;margin-top:4px;background:rgba(255,255,255,0.03);border-radius:4px} +.episodic-stats{ + display:flex;justify-content:space-between;align-items:baseline; + margin-top:18px;margin-bottom:6px; +} +.episodic-graph-host{ + width:100%;height:140px;border-radius:8px; + background:rgba(0,0,0,0.18); + border:1px solid rgba(255,255,255,0.05); + position:relative;overflow:hidden;margin-top:10px; +} +.episodic-graph-host canvas{display:block;width:100%;height:100%} +.episodic-graph-empty{ + position:absolute;inset:0;display:flex;align-items:center;justify-content:center; + font-size:11px;color:var(--muted);text-align:center;padding:0 12px; +} +.episodic-streak-tile{ + display:inline-flex;align-items:center;gap:6px; + padding:4px 10px;border-radius:14px; + background:rgba(94,230,154,0.08);border:1px solid rgba(94,230,154,0.25); + font-size:11px;color:#5ee69a;font-weight:600; +} +.episodic-streak-tile.cold{background:rgba(255,255,255,0.04);border-color:rgba(255,255,255,0.12);color:var(--muted)} +.episodic-autobrief-banner{ + padding:10px 12px;margin:10px 0 0;border-radius:8px; + background:linear-gradient(90deg,rgba(90,212,255,0.10),rgba(168,111,223,0.10)); + border:1px solid rgba(90,212,255,0.3); + display:flex;justify-content:space-between;align-items:center;gap:10px; + font-size:12px;flex-wrap:wrap; +} +.episodic-autobrief-banner strong{color:#5ad4ff} +.episodic-drift-card{ + padding:8px 10px;border-radius:6px;margin-top:6px; + background:rgba(255,128,128,0.05); + border-left:3px solid #ff8080; +} +.episodic-drift-card strong{color:#ffaaaa} +.episodic-cortex-panel button:focus-visible, +.episodic-cortex-panel input:focus-visible, +.episodic-cortex-panel textarea:focus-visible, +.episodic-cortex-panel a:focus-visible{ + outline:2px solid #5ad4ff;outline-offset:2px;border-radius:6px; +} +.episodic-cortex-panel [role="tab"][aria-selected="true"]{ + font-weight:700; +} +@media (prefers-reduced-motion: reduce){ + .episodic-card{transition:none} +} +@media (max-width: 760px){ + .episodic-stats{flex-direction:column;align-items:flex-start;gap:4px} + .episodic-graph-host{height:110px} +} diff --git a/brainsnn-r3f-app/src/utils/episodicAsk.js b/brainsnn-r3f-app/src/utils/episodicAsk.js new file mode 100644 index 0000000..d35ed27 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicAsk.js @@ -0,0 +1,173 @@ +/** + * Layer 101 — Ask the Vault + * + * Natural-language Q&A over the Episodic Cortex. Embeds the question + * (MiniLM, Layer 24), runs cosine retrieval over the capture log, + * builds a context block of top-K hits with their pre-computed + * BrainSNN signals, and either: + * - returns the ranked hits + a deterministic local answer + * (composed from the hits + their categories + affects), or + * - routes the context through Gemma when configured. + * + * Falls back to trigram-Jaccard retrieval when embeddings aren't + * warm. Always returns the same shape so the UI doesn't branch. + */ + +import { embed, isReady as embeddingsReady, cosineSimilarity } from './embeddings'; +import { getCaptures, getEmbeddingFor } from './episodicMemory'; +import { EPISODIC_CATEGORIES } from '../data/episodicTaxonomy'; +import { isGemmaConfigured } from './gemmaEngine'; + +const TOP_K = 6; +const MIN_COSINE = 0.32; + +function fmtDate(ts) { return new Date(ts).toISOString().slice(0, 10); } + +const TRIGRAM_CAP = 4096; +function trigrams(s) { + const out = new Set(); + const t = String(s || '').slice(0, TRIGRAM_CAP).toLowerCase().replace(/\s+/g, ' '); + for (let i = 0; i < t.length - 2; i++) out.add(t.slice(i, i + 3)); + return out; +} + +function jaccard(a, b) { + if (!a.size || !b.size) return 0; + let inter = 0; + for (const x of a) if (b.has(x)) inter++; + return inter / (a.size + b.size - inter); +} + +export async function askTheVault(question, opts = {}) { + const q = String(question || '').trim(); + if (q.length < 3) return { ok: false, reason: 'question-too-short' }; + + const list = getCaptures(); + if (!list.length) return { ok: false, reason: 'empty-vault' }; + + // 1) embed the question if possible + let qVec = null; + if (embeddingsReady()) { + try { qVec = await embed(q); } catch (err) { + // eslint-disable-next-line no-console + console.warn('[episodicAsk] question embed failed, falling back to lexical:', err?.message); + } + } + + // 2) score every capture + const qGrams = trigrams(q); + const scored = []; + for (const c of list) { + let score; + const cVec = getEmbeddingFor(c.hash); + if (qVec && cVec) { + score = cosineSimilarity(qVec, cVec); + } else { + score = jaccard(qGrams, trigrams(`${c.title} ${c.text}`)); + } + if (score >= MIN_COSINE) scored.push({ score, capture: c }); + } + scored.sort((a, b) => b.score - a.score); + const hits = scored.slice(0, TOP_K); + + if (!hits.length) { + return { + ok: true, + hits: [], + answer: 'Nothing in the vault matches that question yet — capture more on this thread first.', + source: 'local', + mode: qVec ? 'cosine' : 'lexical' + }; + } + + // 3) compose a deterministic local answer from the hits + const local = composeLocalAnswer(q, hits); + + // 4) optionally upgrade through Gemma + if (opts.useGemma === false || !isGemmaConfigured()) { + return { ok: true, hits, answer: local, source: 'local', mode: qVec ? 'cosine' : 'lexical' }; + } + try { + const gemma = await callGemmaAnswer(q, hits); + return { ok: true, hits, answer: gemma, source: 'gemma', mode: qVec ? 'cosine' : 'lexical' }; + } catch (err) { + return { ok: true, hits, answer: local, source: 'local-fallback', gemmaError: String(err?.message || err) }; + } +} + +function composeLocalAnswer(question, hits) { + const lines = []; + lines.push(`The vault holds ${hits.length} note${hits.length === 1 ? '' : 's'} that touch this question.`); + + const catTally = {}; + for (const h of hits) { + catTally[h.capture.primary] = (catTally[h.capture.primary] || 0) + 1; + } + const topCat = Object.entries(catTally).sort(([, a], [, b]) => b - a)[0]; + if (topCat) { + const label = EPISODIC_CATEGORIES[topCat[0]]?.label || topCat[0]; + lines.push(`Most are tagged **${label.toLowerCase()}** (${topCat[1]}/${hits.length}).`); + } + + const press = hits.reduce((s, h) => s + (h.capture.firewall?.pressure || 0), 0) / hits.length; + if (press > 0.4) { + lines.push(`Mean firewall pressure across the hits is ${Math.round(press * 100)}% — re-read with that bias in mind.`); + } + + const top = hits[0]; + if (top) { + lines.push(`Top hit: "${top.capture.title}" (${fmtDate(top.capture.ts)}, score ${top.score.toFixed(2)}).`); + } + return lines.join(' '); +} + +async function callGemmaAnswer(question, hits) { + const ENDPOINT = import.meta.env.VITE_GEMMA_API_ENDPOINT || ''; + const API_KEY = import.meta.env.VITE_GEMMA_API_KEY || ''; + if (!ENDPOINT) throw new Error('Gemma not configured'); + + const isGoogle = ENDPOINT.includes('generativelanguage.googleapis.com'); + const url = isGoogle + ? `${ENDPOINT}${ENDPOINT.includes('?') ? '&' : '?'}key=${API_KEY}` + : ENDPOINT; + const headers = { 'Content-Type': 'application/json' }; + if (API_KEY && !isGoogle) headers['Authorization'] = `Bearer ${API_KEY}`; + + const system = `You are answering a question against the user's BrainSNN Episodic Cortex (Layer 101). Each retrieved note carries category, dominant affects, manipulation pressure, and a date. Quote titles. Be specific. If the corpus does not actually answer the question, say so. No markdown fences. 2-4 sentences.`; + const ctx = hits.map((h, i) => { + const c = h.capture; + const cat = EPISODIC_CATEGORIES[c.primary]?.label || c.primary; + const aff = (c.affects?.dominant || []).slice(0, 2).map((a) => a.label).join('/') || '—'; + const press = Math.round((c.firewall?.pressure || 0) * 100); + const excerpt = c.text.replace(/\s+/g, ' ').slice(0, 320); + return `[${i + 1}] (score ${h.score.toFixed(2)} · ${fmtDate(c.ts)} · ${cat} · affect ${aff} · pressure ${press}%) "${c.title}" — ${excerpt}`; + }).join('\n'); + const userPrompt = `Question: ${question}\n\nRetrieved captures:\n${ctx}\n\nAnswer the question grounded in those captures.`; + + let body; + if (isGoogle) { + body = JSON.stringify({ + system_instruction: { parts: [{ text: system }] }, + contents: [{ parts: [{ text: userPrompt }] }], + generationConfig: { temperature: 0.4, maxOutputTokens: 800 } + }); + } else { + body = JSON.stringify({ + model: 'gemma4', + messages: [ + { role: 'system', content: system }, + { role: 'user', content: userPrompt } + ], + temperature: 0.4, + max_tokens: 800 + }); + } + + const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(20_000) : undefined; + const res = await fetch(url, { method: 'POST', headers, body, signal }); + if (!res.ok) throw new Error(`Gemma ${res.status}`); + const json = await res.json(); + if (json.candidates?.[0]?.content?.parts?.[0]?.text) return json.candidates[0].content.parts[0].text.trim(); + if (json.choices?.[0]?.message?.content) return json.choices[0].message.content.trim(); + throw new Error('Unexpected Gemma response shape'); +} diff --git a/brainsnn-r3f-app/src/utils/episodicAutoBrief.js b/brainsnn-r3f-app/src/utils/episodicAutoBrief.js new file mode 100644 index 0000000..f6880bf --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicAutoBrief.js @@ -0,0 +1,141 @@ +/** + * Layer 101 — Auto-Brief Scheduler + * + * Background scheduler that runs the Daily Brief / Weekly Synthesis + * automatically when conditions are met. Persisted to localStorage + * (`brainsnn_episodic_auto_v1`) so the brief schedule survives page + * reloads and timezone changes. + * + * Trigger rules: + * Brief — fires when ≥20h have elapsed since the last brief AND + * at least 3 captures have landed since. + * Synthesis — fires every 6.5 days IF there are ≥4 captures in + * the trailing 7-day window. + * + * The scheduler does not run the LLM call itself — the panel calls + * `dailyBrief()` / `weeklySynthesis()` and reports the result back + * via `recordBrief / recordSynthesis`. This keeps the scheduler pure + * and lets the panel decide whether to defer (e.g. user is mid-typing). + */ + +const KEY = 'brainsnn_episodic_auto_v1'; +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +const BRIEF_INTERVAL_MS = 20 * HOUR; +const SYNTH_INTERVAL_MS = 6.5 * DAY; +const MIN_CAPTURES_FOR_BRIEF = 3; +const MIN_CAPTURES_FOR_SYNTH = 4; +const HISTORY_CAP = 12; + +function loadState() { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return defaultState(); + const parsed = JSON.parse(raw); + return { ...defaultState(), ...parsed }; + } catch { + return defaultState(); + } +} + +function defaultState() { + return { + lastBriefAt: 0, + lastSynthAt: 0, + seenCapturesAtLastBrief: 0, + briefHistory: [], // [{ ts, count, pattern, question }] + synthHistory: [] // [{ ts, count, thesis, oneAction }] + }; +} + +function saveState(state) { + try { + localStorage.setItem(KEY, JSON.stringify(state)); + } catch { /* quota — ignore */ } +} + +export function getAutoBriefState() { + return loadState(); +} + +export function shouldRunBrief(captures) { + const s = loadState(); + const now = Date.now(); + if (now - s.lastBriefAt < BRIEF_INTERVAL_MS) return { ok: false, reason: 'too-soon', nextEligibleAt: s.lastBriefAt + BRIEF_INTERVAL_MS }; + const eligible = (captures || []).filter((c) => now - c.ts <= 24 * HOUR); + if (eligible.length < MIN_CAPTURES_FOR_BRIEF) { + return { ok: false, reason: 'too-few-captures', need: MIN_CAPTURES_FOR_BRIEF, have: eligible.length }; + } + // require at least 1 NEW capture since last brief + const fresh = eligible.filter((c) => c.ts > s.lastBriefAt); + if (!fresh.length) return { ok: false, reason: 'no-fresh-captures' }; + return { ok: true, freshCount: fresh.length }; +} + +export function shouldRunSynthesis(captures) { + const s = loadState(); + const now = Date.now(); + if (now - s.lastSynthAt < SYNTH_INTERVAL_MS) { + return { ok: false, reason: 'too-soon', nextEligibleAt: s.lastSynthAt + SYNTH_INTERVAL_MS }; + } + const weekly = (captures || []).filter((c) => now - c.ts <= 7 * DAY); + if (weekly.length < MIN_CAPTURES_FOR_SYNTH) { + return { ok: false, reason: 'too-few-captures', need: MIN_CAPTURES_FOR_SYNTH, have: weekly.length }; + } + return { ok: true, weeklyCount: weekly.length }; +} + +export function recordBrief(brief, captureCount) { + const s = loadState(); + s.lastBriefAt = Date.now(); + s.seenCapturesAtLastBrief = captureCount; + s.briefHistory.unshift({ + ts: s.lastBriefAt, + count: brief?.count ?? 0, + source: brief?.source || 'local', + pattern: brief?.pattern || '', + question: brief?.question || '' + }); + if (s.briefHistory.length > HISTORY_CAP) s.briefHistory.length = HISTORY_CAP; + saveState(s); + return s; +} + +export function recordSynthesis(synth) { + const s = loadState(); + s.lastSynthAt = Date.now(); + s.synthHistory.unshift({ + ts: s.lastSynthAt, + count: synth?.count ?? 0, + source: synth?.source || 'local', + thesis: synth?.emergingThesis || '', + oneAction: synth?.oneAction || '' + }); + if (s.synthHistory.length > HISTORY_CAP) s.synthHistory.length = HISTORY_CAP; + saveState(s); + return s; +} + +export function clearAutoBrief() { + saveState(defaultState()); +} + +export function nextBriefRelative() { + const s = loadState(); + if (!s.lastBriefAt) return 'never run'; + const ms = (s.lastBriefAt + BRIEF_INTERVAL_MS) - Date.now(); + if (ms <= 0) return 'ready now'; + const h = Math.floor(ms / HOUR); + const m = Math.floor((ms % HOUR) / (60 * 1000)); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + +export function nextSynthesisRelative() { + const s = loadState(); + if (!s.lastSynthAt) return 'never run'; + const ms = (s.lastSynthAt + SYNTH_INTERVAL_MS) - Date.now(); + if (ms <= 0) return 'ready now'; + const d = Math.floor(ms / DAY); + const h = Math.floor((ms % DAY) / HOUR); + return d > 0 ? `${d}d ${h}h` : `${h}h`; +} diff --git a/brainsnn-r3f-app/src/utils/episodicDeepLink.js b/brainsnn-r3f-app/src/utils/episodicDeepLink.js new file mode 100644 index 0000000..d496a2c --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicDeepLink.js @@ -0,0 +1,66 @@ +/** + * Layer 101 — Deep-link capture entrypoints + * + * Three URL contracts so external surfaces (bookmarklet, browser + * extension, mobile share sheet, MCP relay tool) can drop content + * straight into the Episodic Cortex without opening a panel: + * + * ?capture=<text> — raw text + * ?capture-url=<url> — fetch + capture as an artifact + * ?capture-title=<title> — optional title override (combine + * with either of the above) + * + * After a successful capture the params are stripped from the URL + * so reloads don't double-add the same note. + */ + +import { addCapture } from './episodicMemory'; + +export const DEEP_LINK_KEYS = ['capture', 'capture-url', 'capture-title']; + +export function consumeDeepLinkCapture() { + if (typeof window === 'undefined') return null; + const params = new URLSearchParams(window.location.search); + const text = params.get('capture'); + const url = params.get('capture-url'); + const title = params.get('capture-title') || undefined; + + if (!text && !url) return null; + + let capture = null; + if (text) { + capture = addCapture(text, { title, source: 'deeplink' }); + } else if (url) { + // Fetching arbitrary cross-origin URLs from the browser is unreliable + // (CORS), so the conservative move is to capture the URL as an artifact + // with whatever title the caller provided. Tools that can pre-extract + // the article text should pass it via `capture` instead. + const text = title ? `${title}\n\n${url}` : url; + capture = addCapture(text, { title: title || url, source: 'deeplink-url' }); + } + + // Clean up the URL so reloads don't re-trigger. + cleanUrl(params); + return capture; +} + +function cleanUrl(params) { + for (const k of DEEP_LINK_KEYS) params.delete(k); + const search = params.toString(); + const next = window.location.pathname + (search ? `?${search}` : '') + window.location.hash; + try { window.history.replaceState(null, '', next); } catch { /* ignore */ } +} + +/** + * Bookmarklet source — drag this string into the bookmarks bar. + * Selecting any text on any page and clicking the bookmarklet opens + * BrainSNN with the selection / page title pre-captured. + * + * Mirrors Layer 49's "Scan Anywhere" pattern — separate keys so the + * two surfaces don't collide. + */ +export function bookmarkletSource(origin = '') { + const root = origin || (typeof window !== 'undefined' ? window.location.origin : 'https://brainsnn.com'); + const code = `(function(){var s=window.getSelection&&window.getSelection().toString();var t=s||document.title;var u=location.href;var url='${root}/?capture='+encodeURIComponent(t)+'&capture-url='+encodeURIComponent(u)+'&capture-title='+encodeURIComponent(document.title.slice(0,80));window.open(url,'_blank','noopener');})();`; + return `javascript:${code}`; +} diff --git a/brainsnn-r3f-app/src/utils/episodicDream.js b/brainsnn-r3f-app/src/utils/episodicDream.js new file mode 100644 index 0000000..070b6fa --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicDream.js @@ -0,0 +1,129 @@ +/** + * Layer 101 — Dream-Mode coupling + * + * While Dream Mode (Layer 26) is replaying snapshots and STDP-blending + * region weights, this module quietly runs a consolidation pass over + * the Episodic Cortex (Layer 101) every few cycles and reinforces + * connection weights between the regions whose captures co-cluster. + * + * Result: the brain "rehearses" its episodic vault while idle — + * connections the synthesis engine surfaces become stronger STDP + * edges automatically, without the user clicking anything. + * + * This is the structural difference from a vanilla Obsidian vault: + * the connection graph is not just searchable — it is the physics + * of the cortex itself. Strong cross-cluster captures = strong + * inter-region weights = brighter pulses through the connectome. + */ + +import { subscribeDream } from './dreamMode'; +import { getCaptures, markConsolidated } from './episodicMemory'; +import { consolidationPass } from './episodicSynthesis'; +import { LINKS } from '../data/network'; + +const REGION_KEYS = ['CTX', 'HPC', 'THL', 'AMY', 'BG', 'PFC', 'CBL']; +const STDP_NUDGE = 0.045; // weight bump per consolidation pair +const CYCLE_INTERVAL = 4; // run pass every N replay cycles +const MAX_PAIRS_PER_PASS = 3; + +let lastCycleSeen = -1; +let unsubscribe = null; + +/** + * Mount the dream-coupling subscriber. Call once at app startup. + * + * setState: same updater the React tree uses to mutate brain state. + * + * Returns an unsubscribe handle. + */ +export function mountEpisodicDream(setState) { + if (unsubscribe) return unsubscribe; + + unsubscribe = subscribeDream((dreamSnap) => { + try { + if (!dreamSnap || dreamSnap.phase !== 'dreaming') return; + if (dreamSnap.cycleCount === lastCycleSeen) return; + lastCycleSeen = dreamSnap.cycleCount; + + if (dreamSnap.cycleCount === 0 || dreamSnap.cycleCount % CYCLE_INTERVAL !== 0) return; + + const recent = getCaptures({ since: Date.now() - 30 * 24 * 60 * 60 * 1000 }); + if (recent.length < 2) return; + + const pairs = consolidationPass(recent, { topK: MAX_PAIRS_PER_PASS, threshold: 0.45 }); + if (!pairs.length) return; + + if (typeof setState === 'function') { + setState((prev) => { + try { return applyEpisodicSTDP(prev, pairs); } + catch { return prev; } + }); + } + + for (const p of pairs) { + for (const id of (p.memberIds || [])) { + try { markConsolidated(id); } catch { /* ignore */ } + } + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[episodicDream] consolidation cycle failed:', err?.message || err); + } + }); + + return unsubscribe; +} + +export function unmountEpisodicDream() { + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + lastCycleSeen = -1; +} + +// ---------- pure helpers ---------- + +/** + * Reinforce STDP weights between regions implied by the supplied + * episodic-consolidation pairs. + * + * For each pair we pick the dominant region the cluster activates + * (consolidationPass already exposes regionKey) and nudge every + * connectome edge that touches it upward by STDP_NUDGE × clusterSize. + * + * Pure function — returns a new state object. + */ +export function applyEpisodicSTDP(state, pairs) { + if (!state?.weights || !pairs?.length) return state; + const weights = { ...state.weights }; + const regions = { ...state.regions }; + + for (const pair of pairs) { + const r = pair.regionKey; + if (!REGION_KEYS.includes(r)) continue; + const sz = (pair.memberIds || []).length || 2; + const nudge = STDP_NUDGE * Math.log2(1 + sz); + + for (const [a, b] of LINKS) { + if (a !== r && b !== r) continue; + const k = `${a}→${b}`; + if (weights[k] != null) { + weights[k] = clamp(weights[k] + nudge, 0.08, 0.95); + } + } + // Episodic rehearsal also brightens the dominant region a touch. + if (regions[r] != null) regions[r] = clamp(regions[r] + nudge * 0.5, 0.02, 0.95); + } + + return { + ...state, + weights, + regions, + scenario: state.scenario?.startsWith('Dream ·') ? state.scenario : `${state.scenario || 'Dream'} · episodic rehearsal` + }; +} + +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); +} diff --git a/brainsnn-r3f-app/src/utils/episodicDrift.js b/brainsnn-r3f-app/src/utils/episodicDrift.js new file mode 100644 index 0000000..a705e2f --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicDrift.js @@ -0,0 +1,96 @@ +/** + * Layer 101 — Decision Drift Detector + * + * Looks for "decision" captures that newer captures may quietly + * contradict — the shape of a belief shift before the user has + * consciously updated. + * + * Method: + * 1. Restrict to captures classified as `decision` older than + * MIN_AGE_DAYS (default 3). + * 2. For each, find recent captures (≤ DRIFT_WINDOW_DAYS) with: + * - cosine similarity ≥ 0.40 (or trigram fallback ≥ 0.20) + * - valence delta ≥ 0.45 (sign flip in affect) + * OR a primary category of `incident` in the same cluster. + * 3. Score by similarity × |valence delta| × log2(daysApart + 2). + * 4. Return the top N drifts. + * + * Pure deterministic — no LLM. Renders in the Weekly Synthesis card + * under the contradictions list. + */ + +import { findSimilar } from './episodicMemory'; + +const DAY = 24 * 60 * 60 * 1000; +const MIN_AGE_DAYS = 3; +const DRIFT_WINDOW_DAYS = 28; +const MIN_SIMILARITY = 0.34; +const MIN_VALENCE_DELTA = 0.45; + +export function detectDecisionDrifts(captures, { topK = 3 } = {}) { + if (!Array.isArray(captures) || captures.length < 2) return []; + const now = Date.now(); + + const decisions = captures.filter((c) => c?.primary === 'decision' && now - c.ts >= MIN_AGE_DAYS * DAY); + if (!decisions.length) return []; + + const drifts = []; + for (const dec of decisions) { + const decValence = dec.affects?.valence ?? 0; + const sims = findSimilar(dec.id, { k: 5, minScore: MIN_SIMILARITY }); + + for (const { score, capture } of sims) { + if (!capture) continue; + if (capture.ts <= dec.ts) continue; + const daysApart = (capture.ts - dec.ts) / DAY; + if (daysApart > DRIFT_WINDOW_DAYS) continue; + + const newValence = capture.affects?.valence ?? 0; + const valenceDelta = Math.abs(decValence - newValence); + const sameSign = (decValence > 0) === (newValence > 0); + const flip = !sameSign && valenceDelta >= MIN_VALENCE_DELTA; + const incidentShadow = capture.primary === 'incident'; + + if (!flip && !incidentShadow) continue; + + const driftScore = score * Math.max(0.3, valenceDelta) * Math.log2(daysApart + 2); + drifts.push({ + decisionId: dec.id, + decisionTitle: dec.title, + decisionTs: dec.ts, + decisionValence: decValence, + contradictionId: capture.id, + contradictionTitle: capture.title, + contradictionTs: capture.ts, + contradictionPrimary: capture.primary, + contradictionValence: newValence, + similarity: score, + valenceDelta, + daysApart, + driftScore, + kind: flip ? 'valence-flip' : 'incident-shadow' + }); + } + } + + drifts.sort((a, b) => b.driftScore - a.driftScore); + return drifts.slice(0, topK); +} + +export function formatDrift(d) { + if (!d) return null; + const days = Math.round(d.daysApart); + const reason = d.kind === 'valence-flip' + ? `valence flipped from ${d.decisionValence.toFixed(2)} → ${d.contradictionValence.toFixed(2)}` + : `followed by an incident in the same cluster`; + return { + kind: 'drift', + headline: `Decision drift — ${days}d apart`, + detail: `Decision "${truncate(d.decisionTitle, 90)}" → ${d.kind === 'valence-flip' ? 'now contradicted' : 'shadowed'} by "${truncate(d.contradictionTitle, 90)}" (${reason}, cosine ${d.similarity.toFixed(2)}).` + }; +} + +function truncate(s, n) { + s = String(s || ''); + return s.length > n ? s.slice(0, n - 1) + '…' : s; +} diff --git a/brainsnn-r3f-app/src/utils/episodicMemory.js b/brainsnn-r3f-app/src/utils/episodicMemory.js new file mode 100644 index 0000000..43a406c --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicMemory.js @@ -0,0 +1,592 @@ +/** + * Layer 101 — Episodic Memory Store + * + * Persistent capture log with on-demand embeddings and similarity + * search. Backs the Episodic Cortex panel and the synthesis engine. + * + * Design: + * - Captures live in `brainsnn_episodic_v1` localStorage as a flat + * array of routed records (synchronous classification + + * firewall + affect + genre + regions; see episodicRouter.js). + * - Embeddings live in a separate `brainsnn_episodic_emb_v1` slot + * keyed by capture hash so model upgrades can wipe the vector + * cache without losing the notes themselves. + * - `subscribe()` lets the panel re-render on every mutation. + * - `findSimilar()` and `mineClusters()` are the connection + * surface used by Daily Brief / Weekly Synthesis. + * + * Why not just Obsidian's graph? Because every capture here flows + * through the firewall + affect decoder + genre + 8-way episodic + * router before it lands. Connections aren't just lexical/wikilink — + * they're semantic (MiniLM cosine), affective (shared emotion + * fingerprint), and episodic (same category + nearby in time). + */ + +import { embed, isReady as embeddingsReady, cosineSimilarity } from './embeddings'; +import { routeCapture } from './episodicRouter'; +import { EPISODIC_CATEGORIES } from '../data/episodicTaxonomy'; + +const STORE_KEY = 'brainsnn_episodic_v1'; +const EMB_KEY = 'brainsnn_episodic_emb_v1'; +const MAX_CAPTURES = 800; +const MAX_EMBED_CACHE = 800; +const MAX_CAPTURE_CHARS = 50_000; // hard cap per capture text +const MAX_TITLE_CHARS = 200; // hard cap per inferred or supplied title +const EMB_SAVE_INTERVAL_MS = 8_000; // debounce embeddings save +let _embSaveTimer = null; +let _quotaWarnedAt = 0; + +let memCaptures = null; // null = not yet loaded +let memEmbeddings = null; +const subscribers = new Set(); + +// ---------- load / save ---------- + +function loadCaptures() { + if (memCaptures) return memCaptures; + try { + const raw = localStorage.getItem(STORE_KEY); + memCaptures = raw ? JSON.parse(raw) : []; + if (!Array.isArray(memCaptures)) memCaptures = []; + } catch { + memCaptures = []; + } + return memCaptures; +} + +function saveCaptures() { + try { + localStorage.setItem(STORE_KEY, JSON.stringify(memCaptures || [])); + } catch (err) { + warnQuota('episodic captures', err); + } +} + +function warnQuota(label, err) { + // Throttle to once per 30s — quota errors can fire on every save attempt. + const now = Date.now(); + if (now - _quotaWarnedAt < 30_000) return; + _quotaWarnedAt = now; + // eslint-disable-next-line no-console + console.warn(`[episodicMemory] localStorage quota exceeded for ${label}: ${err?.message || err}. Wipe or export from the panel.`); +} + +function loadEmbeddings() { + if (memEmbeddings) return memEmbeddings; + memEmbeddings = new Map(); + try { + const raw = localStorage.getItem(EMB_KEY); + if (raw) { + const obj = JSON.parse(raw); + for (const [hash, arr] of Object.entries(obj || {})) { + memEmbeddings.set(hash, Float32Array.from(arr)); + } + } + } catch { /* ignore */ } + return memEmbeddings; +} + +function saveEmbeddings() { + // Debounce: a batch of addCaptures shouldn't trigger a serialize per call. + if (_embSaveTimer) return; + _embSaveTimer = setTimeout(() => { + _embSaveTimer = null; + try { + const obj = {}; + let n = 0; + for (const [hash, vec] of memEmbeddings) { + if (n++ >= MAX_EMBED_CACHE) break; + obj[hash] = Array.from(vec); + } + localStorage.setItem(EMB_KEY, JSON.stringify(obj)); + } catch (err) { + warnQuota('episodic embeddings cache', err); + } + }, EMB_SAVE_INTERVAL_MS); +} + +// ---------- subscribe ---------- + +export function subscribeEpisodic(cb) { + subscribers.add(cb); + return () => subscribers.delete(cb); +} + +function emit() { + for (const cb of subscribers) { + try { cb(); } catch { /* ignore */ } + } +} + +// ---------- core API ---------- + +/** + * Capture a new note. Returns the routed record immediately. + * Embeddings happen asynchronously when MiniLM is warm. + * + * Defensive caps: + * - text trimmed to MAX_CAPTURE_CHARS (50KB) before routing + * - title trimmed to MAX_TITLE_CHARS (200) + * - opts.source / opts.kind validated against an allowlist + */ +export function addCapture(text, opts = {}) { + const safeText = String(text || '').trim().slice(0, MAX_CAPTURE_CHARS); + if (safeText.length < 1) return null; + const safeOpts = { + ...opts, + title: opts.title ? String(opts.title).trim().slice(0, MAX_TITLE_CHARS) : undefined + }; + const routed = routeCapture(safeText, safeOpts); + if (!routed) return null; + if (routed.title && routed.title.length > MAX_TITLE_CHARS) { + routed.title = routed.title.slice(0, MAX_TITLE_CHARS); + } + const list = loadCaptures(); + + // Dedup: if the same hash already exists in the last hour, bump ts + // instead of adding a duplicate. + const cutoff = Date.now() - 60 * 60 * 1000; + const recent = list.find((c) => c.hash === routed.hash && c.ts > cutoff); + if (recent) { + recent.ts = Date.now(); + saveCaptures(); + emit(); + return recent; + } + + const ALLOWED_KINDS = new Set(['capture', 'insight', 'brief', 'synthesis']); + const ALLOWED_SOURCES = new Set(['manual', 'paste', 'url', 'voice', 'import', 'dream', 'mcp', 'deeplink', 'deeplink-url', 'synthesis', 'agent']); + const kind = ALLOWED_KINDS.has(safeOpts.kind) ? safeOpts.kind : 'capture'; + const source = ALLOWED_SOURCES.has(safeOpts.source) ? safeOpts.source : 'manual'; + + const record = { + id: `cap_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + ts: Date.now(), + kind, + source, + pinned: !!safeOpts.pinned, + ...routed + }; + + list.unshift(record); + if (list.length > MAX_CAPTURES) list.length = MAX_CAPTURES; + memCaptures = list; + saveCaptures(); + emit(); + + // Background embed — don't block the UI. + ensureEmbeddingFor(record).catch(() => { /* graceful — fall back to lexical */ }); + + return record; +} + +/** + * Push a synthesis result back into the store as a derived insight. + * This is the "vault talks back" loop — outputs from Daily Brief / + * Weekly Synthesis become first-class captures the brain can read + * on the next pass. + */ +export function addInsight(text, opts = {}) { + return addCapture(text, { ...opts, kind: opts.kind || 'insight', source: 'synthesis' }); +} + +export function getCaptures(filter = {}) { + const list = loadCaptures().slice(); + let out = list; + if (filter.since) out = out.filter((c) => c.ts >= filter.since); + if (filter.until) out = out.filter((c) => c.ts <= filter.until); + if (filter.category) out = out.filter((c) => c.primary === filter.category); + if (filter.kind) out = out.filter((c) => c.kind === filter.kind); + if (filter.search) { + const q = String(filter.search).toLowerCase(); + out = out.filter((c) => + c.text.toLowerCase().includes(q) + || c.title.toLowerCase().includes(q) + || (c.tags || []).some((t) => t.toLowerCase().includes(q)) + || (c.mentions || []).some((m) => m.toLowerCase().includes(q)) + ); + } + if (filter.minPressure != null) { + out = out.filter((c) => (c.firewall?.pressure || 0) >= filter.minPressure); + } + return out; +} + +export function getCaptureById(id) { + return loadCaptures().find((c) => c.id === id) || null; +} + +export function deleteCapture(id) { + const list = loadCaptures(); + const idx = list.findIndex((c) => c.id === id); + if (idx === -1) return false; + list.splice(idx, 1); + saveCaptures(); + emit(); + return true; +} + +export function togglePinned(id) { + const cap = getCaptureById(id); + if (!cap) return false; + cap.pinned = !cap.pinned; + saveCaptures(); + emit(); + return true; +} + +/** + * Replace a capture's text and re-run the route pipeline so its + * classification, affect, regions, urls, mentions, hash, and pii + * summary stay consistent. Used by the panel's redact-PII action. + * + * Keeps the last 2 prior texts in `priorTexts` so a rewrite can be + * inspected (or eventually undone). The original ts is preserved; + * `editedAt` records when the rewrite happened. + */ +export function rewriteCapture(id, newText) { + const cap = getCaptureById(id); + if (!cap) return null; + const safe = String(newText || '').trim().slice(0, MAX_CAPTURE_CHARS); + if (!safe) return null; + const reRouted = routeCapture(safe, { title: cap.title }); + if (!reRouted) return null; + + // Preserve audit history before mutating. + const priorText = cap.text; + const prior = Array.isArray(cap.priorTexts) ? cap.priorTexts.slice(0, 2) : []; + const oldHash = cap.hash; + Object.assign(cap, reRouted); + cap.editedAt = Date.now(); + cap.priorTexts = priorText ? [{ text: priorText, ts: cap.editedAt }, ...prior].slice(0, 2) : prior; + saveCaptures(); + + // Embedding for the previous hash is now orphaned; drop it so the cache + // doesn't accumulate dead vectors as captures are rewritten. + const cache = loadEmbeddings(); + if (oldHash && cache.has(oldHash) && oldHash !== reRouted.hash) cache.delete(oldHash); + cache.delete(reRouted.hash); // drop any stale entry for the new hash too + emit(); + return cap; +} + +export function clearAllCaptures() { + memCaptures = []; + saveCaptures(); + memEmbeddings?.clear(); + // Wipe embeddings synchronously — saveEmbeddings is debounced, and the + // user explicitly asked for an immediate wipe. + if (_embSaveTimer) { clearTimeout(_embSaveTimer); _embSaveTimer = null; } + try { localStorage.removeItem(EMB_KEY); } catch { /* ignore */ } + emit(); +} + +export function captureCount() { + return loadCaptures().length; +} + +// ---------- aggregate stats ---------- + +export function captureStats() { + const list = loadCaptures(); + const now = Date.now(); + const day = 24 * 60 * 60 * 1000; + const week = 7 * day; + + const byCategory = {}; + for (const id of Object.keys(EPISODIC_CATEGORIES)) byCategory[id] = 0; + let pressureSum = 0; + let pressureN = 0; + let last24h = 0; + let last7d = 0; + let dreamConsolidated = 0; + let withPII = 0; + for (const c of list) { + byCategory[c.primary] = (byCategory[c.primary] || 0) + 1; + if (c.firewall?.pressure != null) { + pressureSum += c.firewall.pressure; + pressureN += 1; + } + if (now - c.ts <= day) last24h++; + if (now - c.ts <= week) last7d++; + if (c.consolidatedAt) dreamConsolidated++; + if (c.pii?.total > 0) withPII++; + } + + return { + total: list.length, + last24h, + last7d, + meanPressure: pressureN > 0 ? pressureSum / pressureN : 0, + byCategory, + dreamConsolidated, + withPII + }; +} + +// ---------- embeddings ---------- + +async function ensureEmbeddingFor(record) { + const cache = loadEmbeddings(); + if (cache.has(record.hash)) return cache.get(record.hash); + if (!embeddingsReady()) return null; + try { + const text = `${record.title}\n\n${record.text}`.slice(0, 2400); + const vec = await embed(text); + cache.set(record.hash, vec); + if (cache.size % 5 === 0) saveEmbeddings(); + return vec; + } catch { + return null; + } +} + +/** + * Batch-embed every capture that doesn't yet have a vector. + * Called by the panel's "Warm up" button or implicitly from + * findSimilar / mineClusters. + */ +export async function ensureAllEmbeddings(progressCb) { + if (!embeddingsReady()) return { ok: false, reason: 'embeddings-not-ready' }; + const list = loadCaptures(); + const cache = loadEmbeddings(); + let done = 0; + for (const c of list) { + if (cache.has(c.hash)) { done++; continue; } + const text = `${c.title}\n\n${c.text}`.slice(0, 2400); + try { + const vec = await embed(text); + cache.set(c.hash, vec); + } catch { /* skip */ } + done++; + if (progressCb) progressCb({ done, total: list.length }); + } + saveEmbeddings(); + return { ok: true, done, total: list.length }; +} + +export function getEmbeddingFor(hash) { + return loadEmbeddings().get(hash) || null; +} + +// ---------- similarity / connection mining ---------- + +/** + * Find captures most similar to the target capture. + * Cosine over MiniLM when warm, lexical Jaccard fallback otherwise. + */ +export function findSimilar(captureId, { k = 5, minScore = 0.32 } = {}) { + const list = loadCaptures(); + const target = list.find((c) => c.id === captureId); + if (!target) return []; + + const cache = loadEmbeddings(); + const targetVec = cache.get(target.hash); + + const scored = []; + for (const c of list) { + if (c.id === target.id) continue; + let score; + const cVec = cache.get(c.hash); + if (targetVec && cVec) { + score = cosineSimilarity(targetVec, cVec); + } else { + score = lexicalJaccard(target.text, c.text); + } + if (score >= minScore) scored.push({ id: c.id, score, capture: c }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, k); +} + +const LEX_TRIGRAM_CAP = 4096; +const _lexCache = new WeakMap(); + +function lexTrigrams(s) { + const text = String(s || '').slice(0, LEX_TRIGRAM_CAP); + if (typeof s === 'object' && s !== null) { + const cached = _lexCache.get(s); + if (cached) return cached; + } + const out = new Set(); + const t = text.toLowerCase().replace(/\s+/g, ' '); + for (let i = 0; i < t.length - 2; i++) out.add(t.slice(i, i + 3)); + return out; +} + +function lexicalJaccard(a, b) { + const A = lexTrigrams(a); + const B = lexTrigrams(b); + if (!A.size || !B.size) return 0; + let inter = 0; + for (const x of A) if (B.has(x)) inter++; + return inter / (A.size + B.size - inter); +} + +/** + * Mine connection clusters from a set of captures. + * + * Builds a similarity graph (edges where cosine ≥ threshold) and runs + * union-find to extract connected components — same shape as the echo + * detector (Layer 53). Each cluster is a "thread" the synthesis engine + * can summarize as a connection or pattern. + */ +export function mineClusters(captures, { threshold = 0.42 } = {}) { + const cache = loadEmbeddings(); + const n = captures.length; + if (n < 2) return []; + + const parent = Array.from({ length: n }, (_, i) => i); + const find = (x) => (parent[x] === x ? x : (parent[x] = find(parent[x]))); + const union = (a, b) => { parent[find(a)] = find(b); }; + + const edges = []; + for (let i = 0; i < n; i++) { + const a = captures[i]; + const va = cache.get(a.hash); + for (let j = i + 1; j < n; j++) { + const b = captures[j]; + const vb = cache.get(b.hash); + let sim; + if (va && vb) sim = cosineSimilarity(va, vb); + else sim = lexicalJaccard(a.text, b.text); + if (sim >= threshold) { + edges.push({ i, j, sim }); + union(i, j); + } + } + } + + const buckets = new Map(); + for (let i = 0; i < n; i++) { + const root = find(i); + if (!buckets.has(root)) buckets.set(root, []); + buckets.get(root).push(captures[i]); + } + + const clusters = []; + for (const members of buckets.values()) { + if (members.length < 2) continue; + const meanPressure = members.reduce((s, c) => s + (c.firewall?.pressure || 0), 0) / members.length; + const tsSpan = members.length > 1 + ? Math.max(...members.map((m) => m.ts)) - Math.min(...members.map((m) => m.ts)) + : 0; + clusters.push({ + members, + size: members.length, + meanPressure, + tsSpanMs: tsSpan, + categories: dominantCategories(members) + }); + } + clusters.sort((a, b) => b.size - a.size); + return clusters; +} + +function dominantCategories(captures) { + const tally = {}; + for (const c of captures) tally[c.primary] = (tally[c.primary] || 0) + 1; + return Object.entries(tally) + .sort(([, a], [, b]) => b - a) + .slice(0, 3) + .map(([id, count]) => ({ id, count, label: EPISODIC_CATEGORIES[id]?.label || id })); +} + +/** + * Find captures linked to the target by shared @mentions or URLs. + * Pure lexical — does not depend on embeddings — so it surfaces the + * graph structure even before MiniLM is warm. Mirrors the wikilink + * backlinks Obsidian users expect, but derived automatically from + * the routed capture's mentions/urls fields. + */ +export function findBacklinks(captureId, { k = 5 } = {}) { + const list = loadCaptures(); + const target = list.find((c) => c.id === captureId); + if (!target) return []; + const targetMentions = new Set((target.mentions || []).map((m) => m.toLowerCase())); + const targetUrls = new Set(target.urls || []); + if (!targetMentions.size && !targetUrls.size) return []; + + const scored = []; + for (const c of list) { + if (c.id === target.id) continue; + const sharedMentions = (c.mentions || []).filter((m) => targetMentions.has(m.toLowerCase())); + const sharedUrls = (c.urls || []).filter((u) => targetUrls.has(u)); + if (!sharedMentions.length && !sharedUrls.length) continue; + const score = sharedMentions.length * 0.6 + sharedUrls.length * 0.8; + scored.push({ + id: c.id, + score, + capture: c, + via: sharedMentions.length ? 'mention' : 'url', + shared: [...sharedMentions.map((m) => `@${m}`), ...sharedUrls] + }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, k); +} + +/** + * Mark a capture as having been visited by Dream Mode consolidation. + * Used to surface "the brain reinforced this in idle" in the UI. + */ +export function markConsolidated(id) { + const cap = getCaptureById(id); + if (!cap) return false; + cap.consolidatedAt = Date.now(); + cap.consolidatedCount = (cap.consolidatedCount || 0) + 1; + saveCaptures(); + return true; +} + +// ---------- dev / export ---------- + +export function exportEpisodicBundle() { + return { + version: 'brainsnn-episodic-v1', + exportedAt: new Date().toISOString(), + captures: loadCaptures() + }; +} + +export function importEpisodicBundle(bundle, { merge = true } = {}) { + if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bad-bundle' }; + if (bundle.version && bundle.version !== 'brainsnn-episodic-v1') { + return { ok: false, reason: `unsupported-version: ${bundle.version}` }; + } + if (!Array.isArray(bundle.captures)) return { ok: false, reason: 'missing-captures-array' }; + + const incoming = []; + let rejected = 0; + for (const c of bundle.captures) { + if (!c || typeof c !== 'object') { rejected++; continue; } + if (typeof c.text !== 'string' || !c.text.trim()) { rejected++; continue; } + if (typeof c.ts !== 'number' || !isFinite(c.ts) || c.ts <= 0) { rejected++; continue; } + if (typeof c.id !== 'string' || !c.id) { rejected++; continue; } + if (typeof c.hash !== 'string' || !c.hash) { rejected++; continue; } + // Soft cap on imported text length so a malicious bundle can't blow up + // the local store with a single 100MB capture. + if (c.text.length > MAX_CAPTURE_CHARS) c.text = c.text.slice(0, MAX_CAPTURE_CHARS); + if (c.title && c.title.length > MAX_TITLE_CHARS) c.title = c.title.slice(0, MAX_TITLE_CHARS); + incoming.push(c); + } + + if (!merge) { + memCaptures = incoming.slice(0, MAX_CAPTURES); + } else { + const list = loadCaptures(); + const seen = new Set(list.map((c) => c.hash)); + for (const c of incoming) { + if (!seen.has(c.hash)) { + list.unshift(c); + seen.add(c.hash); + } + } + list.sort((a, b) => b.ts - a.ts); + if (list.length > MAX_CAPTURES) list.length = MAX_CAPTURES; + memCaptures = list; + } + saveCaptures(); + emit(); + return { ok: true, count: memCaptures.length, imported: incoming.length, rejected }; +} diff --git a/brainsnn-r3f-app/src/utils/episodicPII.js b/brainsnn-r3f-app/src/utils/episodicPII.js new file mode 100644 index 0000000..eb47cda --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicPII.js @@ -0,0 +1,104 @@ +/** + * Layer 101 — PII / Secret Sniffer + * + * Lightweight regex pass that flags captures containing emails, phone + * numbers, common API-key prefixes, SSN-shaped numbers, and IP + * addresses. Surfaced as a warning chip on the capture card so the + * user notices BEFORE the capture sits in localStorage forever or + * before they hand the bundle to anyone else. + * + * Conservative on purpose — false positives are cheap (a chip), false + * negatives could leak credentials. Patterns are biased toward common + * shapes; not a substitute for proper secret scanning. + */ + +const PATTERNS = { + email: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,24}\b/gi, + // Phone: international E.164 + common North American formats + phone: /(?:(?:\+\d{1,3}[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b/g, + // Recognizable secret prefixes — non-exhaustive but catches the + // ones that most often leak in copy-paste. + apiKey: /\b(?:sk-[A-Za-z0-9-_]{20,}|ghp_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,}|xox[abp]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|hf_[A-Za-z0-9]{30,}|sk_live_[A-Za-z0-9]{20,}|pk_live_[A-Za-z0-9]{20,}|sk_test_[A-Za-z0-9]{20,})\b/g, + // SSN: XXX-XX-XXXX, with word boundaries + ssn: /\b\d{3}-\d{2}-\d{4}\b/g, + // Public IPv4 (simple) + ipv4: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d{1,2})\.){3}(?:25[0-5]|2[0-4]\d|1?\d{1,2})\b/g, + // Long base64-ish blobs (40+ chars without whitespace) — catches + // raw JWTs, signed URLs, etc. We strip `http(s)://` URLs first to + // reduce false positives. + longSecret: /\b[A-Za-z0-9+/]{60,}={0,2}\b/g +}; + +const PII_LABELS = { + email: 'email', + phone: 'phone', + apiKey: 'API key', + ssn: 'SSN', + ipv4: 'IP', + longSecret: 'token-shaped' +}; + +// Cap output to keep capture records small. +const MAX_HITS_PER_KIND = 5; + +function findAll(re, text) { + const out = []; + const rx = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'); + let m; + let safety = 0; + while ((m = rx.exec(text)) !== null) { + out.push(m[0]); + if (out.length >= MAX_HITS_PER_KIND) break; + if (m.index === rx.lastIndex) rx.lastIndex++; + if (++safety > 200) break; + } + return out; +} + +/** + * Scan text for PII / secret-shaped substrings. + * Returns { kinds, total, redacted? }. + * + * kinds: { [kind]: string[] } — first 5 matches per kind + * total: number — total hits across kinds + * first: string|null — first matched kind (for chip label) + */ +export function detectPII(text) { + const safe = String(text || ''); + if (safe.length < 4) return { kinds: {}, total: 0, first: null }; + + // Strip URLs before running longSecret pattern to reduce FPs. + const stripped = safe.replace(/\bhttps?:\/\/\S+/g, ' '); + + const kinds = {}; + let total = 0; + let first = null; + for (const [k, re] of Object.entries(PATTERNS)) { + const source = k === 'longSecret' ? stripped : safe; + const hits = findAll(re, source); + if (hits.length) { + kinds[k] = hits; + total += hits.length; + if (!first) first = k; + } + } + return { kinds, total, first }; +} + +export function piiLabel(kind) { + return PII_LABELS[kind] || kind; +} + +/** + * Redact a capture text in place — replace each match of every + * detected PII kind with a typed placeholder. Pure helper used by + * the panel's "Redact PII" button. + */ +export function redactPII(text) { + let out = String(text || ''); + for (const [k, re] of Object.entries(PATTERNS)) { + const rx = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'); + out = out.replace(rx, `<${piiLabel(k).toUpperCase()}_REDACTED>`); + } + return out; +} diff --git a/brainsnn-r3f-app/src/utils/episodicRouter.js b/brainsnn-r3f-app/src/utils/episodicRouter.js new file mode 100644 index 0000000..2e09c6d --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicRouter.js @@ -0,0 +1,309 @@ +/** + * Layer 101 — Episodic Router + * + * Single entry point that turns raw text into a structured episodic + * capture. Orchestrates: + * + * classifyEpisodic(text) → category scores + dominant id (regex) + * routeCapture(text) → full capture object: classification, + * firewall, affects, genre, regions, + * urls, tags, mentions, title. + * + * The output is intentionally pre-computed so the synthesis engine + * (Layer 101 brief / weekly synthesis) can operate over a flat array + * of capture objects without re-running classifiers per query. + * + * Region contributions are merged so dropping a paragraph into the + * Episodic Cortex panel actually drives the 3D brain — the same way + * Layer 4 / Layer 29 do — instead of just filing into a folder. + */ + +import { + EPISODIC_CATEGORIES, + EPISODIC_IDS, + DEFAULT_REGIONS +} from '../data/episodicTaxonomy'; +import { scoreContent } from './cognitiveFirewall'; +import { decodeAffects } from './affectiveDecoder'; +import { classifyGenre } from './genreClassifier'; +import { detectPII } from './episodicPII'; + +const URL_RE = /\bhttps?:\/\/[^\s)<>\]]+/gi; +const HASHTAG_RE = /(?:^|\s)#([A-Za-z][\w-]{1,32})\b/g; +const MENTION_RE = /(?:^|\s)@([A-Za-z][\w-]{1,32})\b/g; + +const REGION_KEYS = ['CTX', 'HPC', 'THL', 'AMY', 'BG', 'PFC', 'CBL']; + +// ---------- low-level helpers ---------- + +function clamp(v, lo = 0, hi = 1) { + return Math.max(lo, Math.min(hi, v)); +} + +function fnvHash(s) { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(36); +} + +function countMatches(text, patterns) { + let count = 0; + for (const rx of patterns) { + const m = text.match(new RegExp(rx.source, rx.flags.includes('g') ? rx.flags : rx.flags + 'g')); + if (m) count += m.length; + } + return count; +} + +function inferTitle(text) { + const firstLine = (text.split(/\n/)[0] || '').trim(); + if (firstLine.length > 0 && firstLine.length <= 100) return firstLine; + const firstSentence = (text.match(/[^.!?\n]{8,140}[.!?]/) || [''])[0].trim(); + return firstSentence || firstLine.slice(0, 80) || 'Untitled capture'; +} + +function extractAll(re, text, group = 0) { + const out = []; + const rx = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'); + let m; + let safety = 0; + while ((m = rx.exec(text)) !== null) { + out.push(m[group]); + if (out.length > 30) break; // sanity + // Guard against zero-width matches that would never advance. + if (m.index === rx.lastIndex) rx.lastIndex++; + if (++safety > 1000) break; + } + return out; +} + +function normalizeRegions(regionsObj) { + const out = {}; + for (const k of REGION_KEYS) out[k] = 0; + for (const [k, v] of Object.entries(regionsObj || {})) { + if (REGION_KEYS.includes(k)) out[k] = clamp(v, 0, 1); + } + return out; +} + +// ---------- core classifier ---------- + +/** + * Classify text into the 8 episodic categories. + * Returns: + * { + * scores: { [categoryId]: { score, hits, evidence } }, + * dominant: [{ id, label, score, color, regions }], // up to 2 + * primary: <categoryId>, + * secondary: <categoryId|null> + * } + */ +export function classifyEpisodic(text = '') { + const normalized = (text || '').trim(); + if (normalized.length < 3) { + return emptyClassification(); + } + + const wordCount = normalized.split(/\s+/).filter(Boolean).length || 1; + // baseline scales softly with length so a 200-word post needs a few + // more hits to claim a category than a one-line note. + const baseline = Math.max(2, 1.5 + Math.log10(Math.max(wordCount, 8))); + + const scores = {}; + for (const id of EPISODIC_IDS) { + const cat = EPISODIC_CATEGORIES[id]; + const hits = countMatches(normalized, cat.triggers); + const score = clamp(hits / baseline, 0, 1); + // sample up to 2 short evidence snippets for the UI + const evidence = collectEvidence(normalized, cat.triggers); + scores[id] = { score, hits, evidence }; + } + + const ranked = EPISODIC_IDS + .map((id) => ({ + id, + label: EPISODIC_CATEGORIES[id].label, + color: EPISODIC_CATEGORIES[id].color, + icon: EPISODIC_CATEGORIES[id].icon, + regions: EPISODIC_CATEGORIES[id].regions, + cluster: EPISODIC_CATEGORIES[id].cluster, + score: scores[id].score + })) + .filter((c) => c.score >= 0.18) + .sort((a, b) => b.score - a.score); + + const primary = ranked[0]?.id || 'artifact'; + const secondary = ranked[1]?.id || null; + + return { + scores, + dominant: ranked.slice(0, 2), + primary, + secondary + }; +} + +function collectEvidence(text, patterns) { + const out = new Set(); + for (const rx of patterns) { + const flags = rx.flags.includes('g') ? rx.flags : rx.flags + 'g'; + const re = new RegExp(rx.source, flags); + let m; + let safety = 0; + while ((m = re.exec(text)) !== null) { + const snippet = m[0].trim(); + if (snippet.length >= 2 && snippet.length <= 60) { + out.add(snippet.toLowerCase()); + } + if (out.size >= 3) break; + // Guard against zero-width matches. + if (m.index === re.lastIndex) re.lastIndex++; + if (++safety > 500) break; + } + if (out.size >= 3) break; + } + return Array.from(out); +} + +function emptyClassification() { + const scores = {}; + for (const id of EPISODIC_IDS) scores[id] = { score: 0, hits: 0, evidence: [] }; + return { scores, dominant: [], primary: 'artifact', secondary: null }; +} + +// ---------- region contributions ---------- + +/** + * Merge the dominant episodic categories' region affinities with the + * affective decoder's region contributions. The result is the + * "this capture activates these regions" overlay used by the brain + * scene. + * + * episodic regions are weighted by category score, affects by their + * own score; we union both maps additively then renormalize so the + * peak region sits between 0.55 and 0.95 (visible-but-not-saturating). + */ +export function mergeRegionContributions(classification, affectResult) { + const acc = {}; + for (const k of REGION_KEYS) acc[k] = 0; + + for (const cat of classification.dominant) { + const w = cat.score; + for (const [r, v] of Object.entries(cat.regions || {})) { + acc[r] = (acc[r] || 0) + v * w; + } + } + + if (affectResult?.regionHeatmap) { + for (const [r, v] of Object.entries(affectResult.regionHeatmap)) { + acc[r] = (acc[r] || 0) + v * 0.7; + } + } + + const peak = Math.max(...Object.values(acc), 1e-6); + // Renormalize so the strongest activation lands ~0.85 — visible glow + // without saturating the brain. + const target = 0.85; + const scale = peak > 0 ? target / peak : 0; + const out = {}; + for (const k of REGION_KEYS) { + out[k] = clamp(acc[k] * scale, 0, 0.95); + } + // If literally nothing activated, fall back to the diffuse default + if (peak < 0.05) return { ...DEFAULT_REGIONS }; + return out; +} + +// ---------- main route entry ---------- + +/** + * Build a full capture record from raw text. + * Caller is expected to add { id, ts, embedding } separately — + * this function is synchronous and stateless. + */ +export function routeCapture(text, opts = {}) { + const normalized = String(text || '').trim(); + if (normalized.length < 1) return null; + + const classification = classifyEpisodic(normalized); + const firewall = scoreContent(normalized); + const affects = decodeAffects(normalized); + const genre = classifyGenre(normalized); + const pii = detectPII(normalized); + + const urls = Array.from(new Set(normalized.match(URL_RE) || [])); + const tags = Array.from(new Set(extractAll(HASHTAG_RE, normalized, 1))); + const mentions = Array.from(new Set(extractAll(MENTION_RE, normalized, 1))); + + const regions = mergeRegionContributions(classification, affects); + + const title = opts.title?.trim() || inferTitle(normalized); + const wordCount = normalized.split(/\s+/).filter(Boolean).length; + + return { + text: normalized, + title, + wordCount, + primary: classification.primary, + secondary: classification.secondary, + classification, + firewall: { + score: firewall.score, + pressure: firewall.pressure ?? firewall.score, + dimensions: { + emotionalActivation: firewall.emotionalActivation, + cognitiveSuppression: firewall.cognitiveSuppression, + manipulationPressure: firewall.manipulationPressure, + trustErosion: firewall.trustErosion + }, + templates: (firewall.templates || []).slice(0, 6), + language: firewall.language || 'en' + }, + affects: { + dominant: affects.dominant.map((a) => ({ id: a.id, label: a.label, score: a.score, color: a.color })), + valence: affects.valence, + arousal: affects.arousal, + quadrant: affects.quadrant + }, + genre: { + primary: genre.primary, + ranked: (genre.ranked || []).slice(0, 3) + }, + regions, + urls, + tags, + mentions, + pii: { total: pii.total, first: pii.first, kinds: Object.keys(pii.kinds) }, + hash: fnvHash(normalized.slice(0, 1024)) + }; +} + +/** + * Convert a routed capture into a partial brain state the scene can + * apply via setBrainState((cur) => ({ ...cur, regions, scenario })). + * + * additive=true → merge with existing regions instead of replacing them + * (matches the affect-overlay pattern in Layer 29). + */ +export function captureToBrainState(curState, capture, { additive = true } = {}) { + if (!curState || !capture?.regions) return curState; + const regions = additive ? { ...curState.regions } : normalizeRegions(capture.regions); + if (additive) { + for (const [k, v] of Object.entries(capture.regions)) { + if (!REGION_KEYS.includes(k)) continue; + regions[k] = clamp((regions[k] || 0) * 0.55 + v * 0.65, 0.02, 0.95); + } + } + const cat = EPISODIC_CATEGORIES[capture.primary]; + return { + ...curState, + regions, + scenario: `Episodic · ${cat?.label || 'capture'} · ${capture.title.slice(0, 28)}`, + burst: Math.max(curState.burst || 0, 3) + }; +} + +export const __test__ = { fnvHash, normalizeRegions, REGION_KEYS }; diff --git a/brainsnn-r3f-app/src/utils/episodicStreak.js b/brainsnn-r3f-app/src/utils/episodicStreak.js new file mode 100644 index 0000000..ad546d4 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicStreak.js @@ -0,0 +1,94 @@ +/** + * Layer 101 — Capture Streak + * + * Daily-capture habit counter, modeled on Layer 38 (Daily Firewall + * Challenge) but specific to the Episodic Cortex. UTC-day boundaries + * so timezones don't break streaks. + * + * Pure derivation from the capture log (computeStreak) plus a + * persisted `longest` so it survives wipes of the capture list. + */ + +const KEY = 'brainsnn_episodic_streak_v1'; +const DAY_MS = 24 * 60 * 60 * 1000; + +function utcDayKey(ts) { + return new Date(ts).toISOString().slice(0, 10); +} + +function loadState() { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return { longest: 0, totalDays: 0 }; + return JSON.parse(raw); + } catch { + return { longest: 0, totalDays: 0 }; + } +} + +function saveState(s) { + try { localStorage.setItem(KEY, JSON.stringify(s)); } catch { /* ignore */ } +} + +/** + * Pure: derive the trailing streak (consecutive UTC days each + * containing ≥1 capture, ending today or yesterday). + * + * Returns { current, longest, totalDays, todayCaptures }. + */ +export function computeStreak(captures = []) { + if (!captures.length) { + const persisted = loadState(); + return { current: 0, longest: persisted.longest || 0, totalDays: persisted.totalDays || 0, todayCaptures: 0 }; + } + + // Build a Set of UTC-day keys that have at least one capture. + const days = new Set(); + for (const c of captures) { + if (!c?.ts) continue; + days.add(utcDayKey(c.ts)); + } + const totalDays = days.size; + + const today = utcDayKey(Date.now()); + const yesterday = utcDayKey(Date.now() - DAY_MS); + let current = 0; + let cursor; + if (days.has(today)) cursor = today; + else if (days.has(yesterday)) cursor = yesterday; + else { + const persisted = loadState(); + return { current: 0, longest: persisted.longest || 0, totalDays, todayCaptures: 0 }; + } + + while (days.has(cursor)) { + current += 1; + const prev = new Date(cursor); + prev.setUTCDate(prev.getUTCDate() - 1); + cursor = prev.toISOString().slice(0, 10); + } + + // Persist longest. + const persisted = loadState(); + const longest = Math.max(current, persisted.longest || 0); + if (longest !== persisted.longest || totalDays !== persisted.totalDays) { + saveState({ longest, totalDays }); + } + + const todayCaptures = captures.filter((c) => utcDayKey(c.ts) === today).length; + + return { current, longest, totalDays, todayCaptures }; +} + +export function streakLabel(streak) { + if (!streak || !streak.current) return 'no streak yet'; + if (streak.current === 1) return '1-day streak'; + if (streak.current < 7) return `${streak.current}-day streak`; + if (streak.current < 30) return `🔥 ${streak.current}-day streak`; + if (streak.current < 100) return `🔥🔥 ${streak.current}-day streak`; + return `🔥🔥🔥 ${streak.current}-day streak`; +} + +export function clearStreak() { + saveState({ longest: 0, totalDays: 0 }); +} diff --git a/brainsnn-r3f-app/src/utils/episodicSynthesis.js b/brainsnn-r3f-app/src/utils/episodicSynthesis.js new file mode 100644 index 0000000..b0fcae3 --- /dev/null +++ b/brainsnn-r3f-app/src/utils/episodicSynthesis.js @@ -0,0 +1,534 @@ +/** + * Layer 101 — Episodic Synthesis Engine + * + * Two outputs that turn the vault from a graveyard into a thinking + * partner (cyrilXBT thesis: "a second brain that never talks back is + * not a second brain"): + * + * dailyBrief(captures, opts) + * → { connections, pattern, question } + * + * weeklySynthesis(captures, opts) + * → { emergingThesis, contradictions, knowledgeGaps, oneAction } + * + * Both have a deterministic local path that uses the BrainSNN signals + * already attached to every capture (affect, firewall pressure, + * regions, episodic category, MiniLM embeddings) and an optional + * Gemma path (Layer 5) when configured. Gemma errors fall back to + * local — the panel always renders something useful. + * + * The Gemma prompts are upgraded versions of the cyrilXBT prompts: + * they accept BrainSNN signals so the LLM can reference the brain's + * own measurements ("AMY peaked Wednesday on the @cyrilxbt thread") + * instead of treating notes as opaque text. + */ + +import { mineClusters, findSimilar } from './episodicMemory'; +import { EPISODIC_CATEGORIES } from '../data/episodicTaxonomy'; +import { isGemmaConfigured } from './gemmaEngine'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// ---------- shared helpers ---------- + +function fmtDate(ts) { + const d = new Date(ts); + return d.toISOString().slice(0, 10); +} + +function daysAgo(ts) { + const dd = (Date.now() - ts) / DAY_MS; + if (dd < 1) return 'today'; + if (dd < 2) return 'yesterday'; + if (dd < 7) return `${Math.floor(dd)}d ago`; + if (dd < 30) return `${Math.floor(dd / 7)}w ago`; + return fmtDate(ts); +} + +function topN(arr, n, fn) { + return arr.slice().sort((a, b) => fn(b) - fn(a)).slice(0, n); +} + +function quoteSnippet(text, max = 140) { + const t = String(text || '').replace(/\s+/g, ' ').trim(); + if (t.length <= max) return t; + return t.slice(0, max - 1) + '…'; +} + +function normalizeBucket(captures) { + // Defensive: drop any malformed entries the panel might have + return (captures || []).filter((c) => c && c.text && c.id); +} + +// ---------- local connection mining ---------- + +function localConnections(captures, { k = 3 } = {}) { + const list = normalizeBucket(captures); + if (list.length < 2) return []; + + // Pass 1 — clustered connections (multi-member threads). + const clusters = mineClusters(list, { threshold: 0.42 }); + const fromClusters = clusters.slice(0, k).map((cl) => { + const a = topN(cl.members, 1, (c) => c.firewall?.pressure || 0)[0]; + const others = cl.members.filter((m) => m.id !== a.id); + const b = topN(others, 1, (c) => c.ts)[0] || others[0]; + const span = cl.tsSpanMs ? `${Math.round(cl.tsSpanMs / DAY_MS)}d span` : 'same day'; + const sharedAffect = sharedAffectLabel([a, b]); + return { + kind: 'cluster', + headline: `${cl.size} notes converge on ${cl.categories.map((x) => x.label.toLowerCase()).join(' + ')}`, + detail: `"${quoteSnippet(a.title)}" (${daysAgo(a.ts)}) ⇄ "${quoteSnippet(b?.title || '')}" (${daysAgo(b?.ts || a.ts)}) · ${span}${sharedAffect ? ` · shared affect: ${sharedAffect}` : ''}`, + memberIds: cl.members.map((m) => m.id) + }; + }); + + // Pass 2 — pairwise matches for any newest captures not already covered. + const covered = new Set(); + fromClusters.forEach((c) => c.memberIds.forEach((id) => covered.add(id))); + + const newest = topN(list, Math.min(8, list.length), (c) => c.ts); + const pair = []; + for (const a of newest) { + if (covered.has(a.id)) continue; + const sims = findSimilar(a.id, { k: 1, minScore: 0.36 }); + const best = sims[0]; + if (!best || covered.has(best.id)) continue; + pair.push({ + kind: 'pair', + headline: `Echo of an older note in "${quoteSnippet(a.title, 60)}"`, + detail: `"${quoteSnippet(a.title)}" (${daysAgo(a.ts)}) ↔ "${quoteSnippet(best.capture.title)}" (${daysAgo(best.capture.ts)}) · cosine ${best.score.toFixed(2)}`, + memberIds: [a.id, best.id] + }); + covered.add(a.id); + covered.add(best.id); + if (fromClusters.length + pair.length >= k) break; + } + + return [...fromClusters, ...pair].slice(0, k); +} + +function sharedAffectLabel(members) { + const tally = {}; + for (const m of members) { + for (const a of (m.affects?.dominant || [])) { + tally[a.label] = (tally[a.label] || 0) + a.score; + } + } + const ranked = Object.entries(tally).sort(([, x], [, y]) => y - x); + const top = ranked[0]; + if (!top || top[1] < 0.4) return null; + return top[0].toLowerCase(); +} + +// ---------- local pattern mining ---------- + +function localPattern(captures) { + const list = normalizeBucket(captures); + if (!list.length) return 'Not enough recent captures yet — feed it a few notes first.'; + + const catTally = {}; + const affectTally = {}; + const regionTally = {}; + let pressureSum = 0; + let pressureN = 0; + for (const c of list) { + catTally[c.primary] = (catTally[c.primary] || 0) + 1; + for (const a of (c.affects?.dominant || [])) { + affectTally[a.label] = (affectTally[a.label] || 0) + a.score; + } + for (const [r, v] of Object.entries(c.regions || {})) { + regionTally[r] = (regionTally[r] || 0) + v; + } + if (c.firewall?.pressure != null) { + pressureSum += c.firewall.pressure; + pressureN += 1; + } + } + + const topCat = Object.entries(catTally).sort(([, a], [, b]) => b - a)[0]; + const topAffect = Object.entries(affectTally).sort(([, a], [, b]) => b - a)[0]; + const topRegion = Object.entries(regionTally).sort(([, a], [, b]) => b - a)[0]; + const meanPressure = pressureN ? pressureSum / pressureN : 0; + + const catLabel = topCat ? EPISODIC_CATEGORIES[topCat[0]]?.label || topCat[0] : 'mixed'; + const affectLabel = topAffect ? topAffect[0].toLowerCase() : 'neutral'; + const regionLabel = topRegion ? topRegion[0] : 'CTX'; + + const pressureFrame = meanPressure > 0.45 + ? `mean firewall pressure is high (${Math.round(meanPressure * 100)}%) — manipulative content is leaking into your inputs` + : meanPressure > 0.25 + ? `mean firewall pressure is mid (${Math.round(meanPressure * 100)}%) — some pressured material in the mix` + : `mean firewall pressure is low (${Math.round(meanPressure * 100)}%) — clean inputs`; + + return `${list.length} captures over the window. Your brain is mostly in **${catLabel}** mode, with **${affectLabel}** as the dominant affect and **${regionLabel}** as the cortex peak. ${pressureFrame}.`; +} + +// ---------- local question generation ---------- + +function localQuestion(captures) { + const list = normalizeBucket(captures); + if (!list.length) return 'What is one thing you have been chewing on but never written down?'; + + // Open questions with no follow-up insight in the same week + const openQs = list.filter((c) => c.primary === 'question'); + const recentInsightTitles = list.filter((c) => c.primary === 'insight').map((c) => c.title.toLowerCase()); + const orphaned = openQs.filter((q) => + !recentInsightTitles.some((t) => sharesNgram(t, q.title.toLowerCase(), 4)) + ); + + if (orphaned.length) { + const oldest = orphaned.sort((a, b) => a.ts - b.ts)[0]; + return `Open question from ${daysAgo(oldest.ts)} you never closed: "${quoteSnippet(oldest.title, 160)}" — what do you actually believe now?`; + } + + // Otherwise: ask about the highest-pressure recent note. + const hottest = topN(list, 1, (c) => c.firewall?.pressure || 0)[0]; + if (hottest && (hottest.firewall?.pressure || 0) > 0.4) { + return `You saved "${quoteSnippet(hottest.title, 80)}" (${daysAgo(hottest.ts)}) at ${Math.round(hottest.firewall.pressure * 100)}% manipulation pressure. Do you actually agree with the framing, or did the urgency just bypass you?`; + } + + // Otherwise: ask about the largest cluster. + const clusters = mineClusters(list, { threshold: 0.42 }); + const big = clusters[0]; + if (big && big.size >= 2) { + return `${big.size} notes are clustering around ${big.categories[0]?.label.toLowerCase() || 'a topic'}. What is the single sentence that ties them together?`; + } + + return 'What is one belief you held a month ago that the last week of inputs would now contradict?'; +} + +function sharesNgram(a, b, n = 4) { + const grams = (s) => { + const out = new Set(); + const tokens = s.toLowerCase().replace(/[^\w\s]/g, ' ').split(/\s+/).filter(Boolean); + for (let i = 0; i + n <= tokens.length; i++) out.add(tokens.slice(i, i + n).join(' ')); + return out; + }; + const A = grams(a); + const B = grams(b); + for (const x of A) if (B.has(x)) return true; + return false; +} + +// ---------- weekly synthesis local ---------- + +function localEmergingThesis(captures) { + const list = normalizeBucket(captures); + if (list.length < 3) { + return 'Vault is still warming up — emerging thesis needs at least 3 captures to triangulate.'; + } + + // Center of gravity = capture with the highest mean cosine to the rest. + // Falls back to the largest cluster's most-pressured member. + const clusters = mineClusters(list, { threshold: 0.40 }); + const big = clusters[0]; + if (big) { + const center = topN(big.members, 1, (c) => c.firewall?.pressure || 0)[0]; + const catLabels = big.categories.map((c) => c.label.toLowerCase()).join(' + '); + return `Your gravitational center is forming around **${catLabels}**. The anchor: "${quoteSnippet(center.title, 120)}" (${daysAgo(center.ts)}). ${big.size} notes are pulling toward it. The position you have not stated yet is whatever you would write if forced to title that cluster.`; + } + + const newest = topN(list, 1, (c) => c.ts)[0]; + return `No tight cluster yet — but the most recent move was "${quoteSnippet(newest.title, 120)}". If a thesis is forming, it is upstream of that note.`; +} + +function localContradictions(captures) { + const list = normalizeBucket(captures); + if (list.length < 2) return []; + + const out = []; + // Find pairs in same cluster with opposite valence (semantic-similar but + // emotionally opposite — the shape of a belief in motion). + const clusters = mineClusters(list, { threshold: 0.40 }); + for (const cl of clusters) { + const positives = cl.members.filter((m) => (m.affects?.valence || 0) > 0.25); + const negatives = cl.members.filter((m) => (m.affects?.valence || 0) < -0.25); + if (positives.length && negatives.length) { + const a = positives[0]; + const b = negatives[0]; + out.push({ + headline: `Same topic, opposite charge`, + detail: `Earlier you saved "${quoteSnippet(a.title, 90)}" (valence ${a.affects.valence.toFixed(2)}). Then later "${quoteSnippet(b.title, 90)}" (valence ${b.affects.valence.toFixed(2)}). The brain is rotating around it — which side do you actually hold?`, + memberIds: [a.id, b.id] + }); + } + if (out.length >= 3) break; + } + + // Decision ↔ Incident in same cluster — a choice followed by something breaking. + for (const cl of clusters) { + const dec = cl.members.find((m) => m.primary === 'decision'); + const inc = cl.members.find((m) => m.primary === 'incident'); + if (dec && inc && Math.abs(dec.ts - inc.ts) < 30 * DAY_MS) { + out.push({ + headline: `Decision shadowed by incident`, + detail: `Decision "${quoteSnippet(dec.title, 90)}" (${daysAgo(dec.ts)}) and incident "${quoteSnippet(inc.title, 90)}" (${daysAgo(inc.ts)}) are in the same cluster. Was the call wrong, or is this an unrelated cost?`, + memberIds: [dec.id, inc.id] + }); + } + if (out.length >= 3) break; + } + + return out.slice(0, 3); +} + +function localKnowledgeGaps(captures) { + const list = normalizeBucket(captures); + if (!list.length) return []; + + const out = []; + // Open questions older than 7 days without a clearly related insight + const cutoff = Date.now() - 7 * DAY_MS; + const openQs = list.filter((c) => c.primary === 'question' && c.ts < cutoff); + const insightTitles = list.filter((c) => c.primary === 'insight').map((c) => c.title.toLowerCase()); + + for (const q of openQs.slice(0, 3)) { + if (insightTitles.some((t) => sharesNgram(t, q.title.toLowerCase(), 4))) continue; + out.push({ + headline: `Stale open question`, + detail: `"${quoteSnippet(q.title, 140)}" — saved ${daysAgo(q.ts)}. No follow-up insight in the vault.`, + memberIds: [q.id] + }); + } + + // Orphan high-pressure captures with no neighbors — high signal, low context. + // Bound the work: take the top 8 highest-pressure candidates first so we + // don't run findSimilar over the entire list when the corpus is large. + const candidates = list + .filter((c) => (c.firewall?.pressure || 0) >= 0.4) + .sort((a, b) => (b.firewall?.pressure || 0) - (a.firewall?.pressure || 0)) + .slice(0, 8); + const orphans = candidates.filter((c) => { + const sims = findSimilar(c.id, { k: 1, minScore: 0.36 }); + return sims.length === 0; + }); + for (const o of orphans.slice(0, 2)) { + out.push({ + headline: `High-pressure orphan`, + detail: `"${quoteSnippet(o.title, 140)}" (${Math.round((o.firewall?.pressure || 0) * 100)}% pressure, ${daysAgo(o.ts)}) is sitting alone — what context would make this make sense?`, + memberIds: [o.id] + }); + if (out.length >= 4) break; + } + + return out.slice(0, 4); +} + +function localOneAction(captures) { + const list = normalizeBucket(captures); + if (!list.length) return 'Drop your first capture into the panel above. The vault cannot brief you on an empty corpus.'; + + const clusters = mineClusters(list, { threshold: 0.42 }); + const big = clusters[0]; + if (big && big.size >= 3) { + const cat = big.categories[0]?.label || 'recent notes'; + return `Compose one paragraph that titles the ${big.size}-note cluster on **${cat.toLowerCase()}**. If you cannot name it, the position is not real yet.`; + } + + const hottest = topN(list, 1, (c) => c.firewall?.pressure || 0)[0]; + if (hottest && (hottest.firewall?.pressure || 0) > 0.5) { + return `Counter-draft "${quoteSnippet(hottest.title, 80)}" — mean pressure is high enough that re-reading the rewrite will tell you whether you actually agreed or just absorbed.`; + } + + const openQs = list.filter((c) => c.primary === 'question').sort((a, b) => a.ts - b.ts); + if (openQs.length) { + return `Sit 10 minutes with "${quoteSnippet(openQs[0].title, 120)}" and answer it in writing. An open question becomes a belief the moment you commit one sentence.`; + } + + return 'Pick one capture from the last week that you have not re-read since saving. Re-read it. Decide whether to pin it or delete it.'; +} + +// ---------- Gemma augmentation ---------- + +function buildGemmaContext(captures, { maxItems = 30, maxChars = 320 } = {}) { + const list = normalizeBucket(captures).slice(0, maxItems); + const lines = list.map((c, i) => { + const cat = EPISODIC_CATEGORIES[c.primary]?.label || c.primary; + const aff = (c.affects?.dominant || []).slice(0, 2).map((a) => a.label).join('/') || '—'; + const press = Math.round((c.firewall?.pressure || 0) * 100); + const excerpt = c.text.replace(/\s+/g, ' ').slice(0, maxChars); + return `[${i + 1}] (${fmtDate(c.ts)} · ${cat} · affect ${aff} · pressure ${press}%) "${c.title}" — ${excerpt}`; + }); + return lines.join('\n'); +} + +const BRIEF_SYSTEM = `You are the synthesis voice of BrainSNN's Episodic Cortex (Layer 101). Each note in the vault has been pre-classified with: episodic category (decision/insight/question/artifact/win/project/person/incident), dominant affects, manipulation pressure, and brain-region activation. Use these signals — do not just summarize text. + +Return ONLY valid JSON with EXACTLY these fields: +{ + "connections": [ { "headline": "...", "detail": "..." }, ...up to 3 ], + "pattern": "single-sentence pattern across the window", + "question": "one question worth sitting with today (not a task)" +} +Be specific. Quote titles. Reference categories or affects when they matter. No markdown fences, no commentary.`; + +const SYNTH_SYSTEM = `You are the weekly-synthesis voice of BrainSNN's Episodic Cortex (Layer 101). Use the pre-classified signals on each note (category, affect, pressure, region) — they are not decoration, they are the measurement. + +Return ONLY valid JSON with EXACTLY these fields: +{ + "emergingThesis": "what position is forming that the user has not yet stated", + "contradictions": [ { "headline": "...", "detail": "..." }, ...up to 3 ], + "knowledgeGaps": [ { "headline": "...", "detail": "..." }, ...up to 3 ], + "oneAction": "single highest-leverage action this week" +} +Be direct. Challenge. Do not summarize what the user already knows. No markdown fences.`; + +async function callGemmaJson(systemPrompt, userPrompt) { + const ENDPOINT = import.meta.env.VITE_GEMMA_API_ENDPOINT || ''; + const API_KEY = import.meta.env.VITE_GEMMA_API_KEY || ''; + if (!ENDPOINT) throw new Error('Gemma not configured'); + + const isGoogle = ENDPOINT.includes('generativelanguage.googleapis.com'); + const url = isGoogle + ? `${ENDPOINT}${ENDPOINT.includes('?') ? '&' : '?'}key=${API_KEY}` + : ENDPOINT; + const headers = { 'Content-Type': 'application/json' }; + if (API_KEY && !isGoogle) headers['Authorization'] = `Bearer ${API_KEY}`; + + let body; + if (isGoogle) { + body = JSON.stringify({ + system_instruction: { parts: [{ text: systemPrompt }] }, + contents: [{ parts: [{ text: userPrompt }] }], + generationConfig: { temperature: 0.4, maxOutputTokens: 1500, responseMimeType: 'application/json' } + }); + } else { + body = JSON.stringify({ + model: 'gemma4', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 0.4, + max_tokens: 1500 + }); + } + + // Hard 30s timeout — synthesis prompts are large; budget extra time but + // never let a hung request stall the panel forever. + const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(30_000) : undefined; + const res = await fetch(url, { method: 'POST', headers, body, signal }); + if (!res.ok) throw new Error(`Gemma ${res.status}`); + const json = await res.json(); + let raw; + if (json.candidates?.[0]?.content?.parts?.[0]?.text) { + raw = json.candidates[0].content.parts[0].text; + } else if (json.choices?.[0]?.message?.content) { + raw = json.choices[0].message.content; + } else { + throw new Error('Unexpected Gemma response shape'); + } + const cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/gi, '').trim(); + return JSON.parse(cleaned); +} + +// ---------- public entrypoints ---------- + +/** + * Daily Brief: connections + pattern + question across captures from + * the last `windowDays` days (default 7 days for connections, 1 day + * for the pattern feed). + */ +export async function dailyBrief(captures, opts = {}) { + const { useGemma = isGemmaConfigured(), windowDays = 7 } = opts; + const list = normalizeBucket(captures).filter((c) => Date.now() - c.ts <= windowDays * DAY_MS); + + // Each section is wrapped so a single bad regex or malformed capture + // can't tank the whole brief — render whatever sections we can. + let connections = []; + let pattern = ''; + let question = ''; + try { connections = localConnections(list, { k: 3 }); } catch (e) { console.warn('[episodicSynthesis] connections failed:', e?.message); } + try { pattern = localPattern(list); } catch (e) { console.warn('[episodicSynthesis] pattern failed:', e?.message); pattern = 'Pattern unavailable.'; } + try { question = localQuestion(list); } catch (e) { console.warn('[episodicSynthesis] question failed:', e?.message); question = 'What is one thing you have not written down yet?'; } + const local = { connections, pattern, question, source: 'local', count: list.length }; + + if (!useGemma || list.length < 3) return local; + + try { + const context = buildGemmaContext(list); + const userPrompt = `Window: last ${windowDays} days. ${list.length} captures.\n\n${context}\n\nReturn the JSON spec defined in the system prompt.`; + const out = await callGemmaJson(BRIEF_SYSTEM, userPrompt); + return { + connections: Array.isArray(out.connections) ? out.connections.slice(0, 3) : connections, + pattern: typeof out.pattern === 'string' ? out.pattern : pattern, + question: typeof out.question === 'string' ? out.question : question, + source: 'gemma', + count: list.length + }; + } catch (err) { + return { ...local, source: 'local-fallback', gemmaError: String(err?.message || err) }; + } +} + +/** + * Weekly Synthesis: thesis + contradictions + gaps + one action over + * the last `windowDays` days (default 7). + */ +export async function weeklySynthesis(captures, opts = {}) { + const { useGemma = isGemmaConfigured(), windowDays = 7 } = opts; + const list = normalizeBucket(captures).filter((c) => Date.now() - c.ts <= windowDays * DAY_MS); + + let emergingThesis = ''; + let contradictions = []; + let knowledgeGaps = []; + let oneAction = ''; + try { emergingThesis = localEmergingThesis(list); } catch (e) { console.warn('[episodicSynthesis] thesis failed:', e?.message); emergingThesis = 'Thesis unavailable for this window.'; } + try { contradictions = localContradictions(list); } catch (e) { console.warn('[episodicSynthesis] contradictions failed:', e?.message); } + try { knowledgeGaps = localKnowledgeGaps(list); } catch (e) { console.warn('[episodicSynthesis] gaps failed:', e?.message); } + try { oneAction = localOneAction(list); } catch (e) { console.warn('[episodicSynthesis] action failed:', e?.message); oneAction = 'Re-read one capture from this week.'; } + const local = { emergingThesis, contradictions, knowledgeGaps, oneAction, source: 'local', count: list.length }; + + if (!useGemma || list.length < 4) return local; + + try { + const context = buildGemmaContext(list, { maxItems: 40 }); + const userPrompt = `Window: last ${windowDays} days. ${list.length} captures.\n\n${context}\n\nReturn the JSON spec defined in the system prompt.`; + const out = await callGemmaJson(SYNTH_SYSTEM, userPrompt); + return { + emergingThesis: typeof out.emergingThesis === 'string' ? out.emergingThesis : emergingThesis, + contradictions: Array.isArray(out.contradictions) ? out.contradictions.slice(0, 3) : contradictions, + knowledgeGaps: Array.isArray(out.knowledgeGaps) ? out.knowledgeGaps.slice(0, 4) : knowledgeGaps, + oneAction: typeof out.oneAction === 'string' ? out.oneAction : oneAction, + source: 'gemma', + count: list.length + }; + } catch (err) { + return { ...local, source: 'local-fallback', gemmaError: String(err?.message || err) }; + } +} + +/** + * Lightweight idle-pass synthesis used by Dream Mode. + * + * Returns a list of "consolidation pairs" — connections strong enough + * to STDP-reinforce. Pure local, no LLM, deterministic. + */ +export function consolidationPass(captures, { topK = 3, threshold = 0.45 } = {}) { + const list = normalizeBucket(captures); + if (list.length < 2) return []; + const clusters = mineClusters(list, { threshold }); + const out = []; + for (const cl of clusters.slice(0, topK)) { + if (cl.members.length < 2) continue; + const a = cl.members[0]; + const b = cl.members[1]; + out.push({ + pairIds: [a.id, b.id], + memberIds: cl.members.map((m) => m.id), + regionKey: dominantRegionKey(cl.members), + reason: `co-active in ${cl.size}-note cluster (${cl.categories[0]?.label || ''})` + }); + } + return out; +} + +function dominantRegionKey(members) { + const tally = {}; + for (const m of members) { + for (const [r, v] of Object.entries(m.regions || {})) { + tally[r] = (tally[r] || 0) + v; + } + } + return Object.entries(tally).sort(([, a], [, b]) => b - a)[0]?.[0] || 'CTX'; +} diff --git a/brainsnn-r3f-app/src/utils/hotkeys.js b/brainsnn-r3f-app/src/utils/hotkeys.js index 5f54eab..520335d 100644 --- a/brainsnn-r3f-app/src/utils/hotkeys.js +++ b/brainsnn-r3f-app/src/utils/hotkeys.js @@ -58,6 +58,7 @@ const HOTKEYS = { be: 31, // brain Evolve ae: 32, // Attack evolve rt2: 25, // red team (fallback via palette) + ep: 101, // EPisodic Cortex }; export function listHotkeys() { diff --git a/brainsnn-r3f-app/src/utils/layerCatalog.js b/brainsnn-r3f-app/src/utils/layerCatalog.js index 110bd86..6871974 100644 --- a/brainsnn-r3f-app/src/utils/layerCatalog.js +++ b/brainsnn-r3f-app/src/utils/layerCatalog.js @@ -107,6 +107,7 @@ export const LAYER_CATALOG = [ { id: 98, name: 'Theme + A11y', group: 'view', blurb: 'Dark/light, high-contrast, reduced-motion, font scale.' }, { id: 99, name: 'Federated Community Firewall', group: 'firewall', blurb: 'Weekly-rotated community rule pack.' }, { id: 100, name: 'Milestone Dashboard', group: 'view', blurb: '100 layers shipped — synthesis + personal stats.' }, + { id: 101, name: 'Episodic Cortex', group: 'data', blurb: 'Vault that talks back — captures classified across 8 episodic categories, embedded, dream-consolidated, briefed daily.' }, ]; export const LAYER_GROUPS = { diff --git a/brainsnn-r3f-app/src/utils/mcpBridge.js b/brainsnn-r3f-app/src/utils/mcpBridge.js index e22b095..6564a2c 100644 --- a/brainsnn-r3f-app/src/utils/mcpBridge.js +++ b/brainsnn-r3f-app/src/utils/mcpBridge.js @@ -29,6 +29,13 @@ import { mergeTemplateResults } from './semanticTemplates'; import { pickTodaysChallenge } from './dailyChallenge'; import { analyzeTimeSeries } from './timeSeries'; import { issueReceipt } from './receipt'; +// Layer 101 — Episodic Cortex +import { addCapture, getCaptures, captureStats, ensureAllEmbeddings } from './episodicMemory'; +import { dailyBrief, weeklySynthesis, consolidationPass } from './episodicSynthesis'; +import { askTheVault } from './episodicAsk'; +import { detectDecisionDrifts, formatDrift } from './episodicDrift'; +import { applyEpisodicSTDP } from './episodicDream'; +import { initEmbeddings, isReady as embeddingsReady } from './embeddings'; // ---------- tool catalog ---------- @@ -232,6 +239,77 @@ export const BRAIN_TOOLS = [ properties: { text: { type: 'string' } }, required: ['text'] } + }, + { + name: 'episodic_capture', + description: 'Layer 101 — capture a note into the Episodic Cortex. Auto-classifies into 8 categories (decision/insight/question/artifact/win/project/person/incident), runs the firewall + affect decoder, lights up the 3D brain, and persists locally.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string' }, + title: { type: 'string' }, + source: { type: 'string', description: 'Optional capture source tag (e.g. agent, telegram, readwise)' } + }, + required: ['text'] + } + }, + { + name: 'episodic_brief', + description: 'Layer 101 — daily brief over recent captures. Returns connections / pattern / question. Uses Gemma when configured, deterministic local synthesis otherwise.', + inputSchema: { + type: 'object', + properties: { windowDays: { type: 'number', description: 'Window in days (default 7)' } } + } + }, + { + name: 'episodic_synthesis', + description: 'Layer 101 — weekly synthesis. Returns emerging thesis / contradictions / knowledge gaps / one action.', + inputSchema: { + type: 'object', + properties: { windowDays: { type: 'number', description: 'Window in days (default 7)' } } + } + }, + { + name: 'episodic_list', + description: 'Layer 101 — list recent episodic captures with their classification, affect, and pressure signals.', + inputSchema: { + type: 'object', + properties: { + category: { type: 'string', description: 'Optional category filter' }, + sinceDays: { type: 'number', description: 'Only return captures within the last N days' }, + limit: { type: 'number', description: 'Max results (default 20)' } + } + } + }, + { + name: 'episodic_ask', + description: 'Layer 101 — natural-language Q&A over the Episodic Cortex. Embeds the question, runs cosine retrieval over captures, returns hits + grounded answer (Gemma-augmented when configured).', + inputSchema: { + type: 'object', + properties: { question: { type: 'string' } }, + required: ['question'] + } + }, + { + name: 'episodic_drift', + description: 'Layer 101 — surface decision drifts: older `decision` captures that newer notes contradict (valence flip or incident shadow). Returns ranked drifts with similarity and days-apart.', + inputSchema: { + type: 'object', + properties: { topK: { type: 'number', description: 'Max drifts (default 3)' } } + } + }, + { + name: 'episodic_consolidate', + description: 'Layer 101 — explicitly run the consolidation pass over the last 30 days of captures. STDP-reinforces brain weights along clustered regions immediately, without waiting for Dream Mode.', + inputSchema: { + type: 'object', + properties: { topK: { type: 'number', description: 'Max clusters to consolidate (default 3)' } } + } + }, + { + name: 'episodic_warm', + description: 'Layer 101 — initialize MiniLM embeddings (Layer 24) and embed every capture in the vault. Returns when the cache is warm. No-op if already ready.', + inputSchema: { type: 'object', properties: {} } } ]; @@ -431,6 +509,118 @@ async function dispatch(name, args) { return await issueReceipt({ text: args.text, score: s }); } + case 'episodic_capture': { + if (!args.text) throw new Error('text required'); + const cap = addCapture(args.text, { title: args.title, source: args.source || 'mcp' }); + if (!cap) throw new Error('capture rejected'); + // Push into the brain so the agent's contribution is visible. + bridgeContext.setState?.((prev) => { + if (!prev?.regions) return prev; + const regions = { ...prev.regions }; + for (const [r, v] of Object.entries(cap.regions || {})) { + if (regions[r] != null) regions[r] = Math.min(0.95, Math.max(0.02, regions[r] * 0.6 + v * 0.6)); + } + return { ...prev, regions, scenario: `Episodic · ${cap.title.slice(0, 32)}`, burst: Math.max(prev.burst || 0, 3), tick: (prev.tick || 0) + 1 }; + }); + return { + id: cap.id, + title: cap.title, + primary: cap.primary, + secondary: cap.secondary, + firewallPressure: cap.firewall?.pressure, + dominantAffect: cap.affects?.dominant?.[0]?.label || null, + regions: cap.regions + }; + } + + case 'episodic_brief': { + const all = getCaptures(); + return await dailyBrief(all, { windowDays: args.windowDays || 7 }); + } + + case 'episodic_synthesis': { + const all = getCaptures(); + return await weeklySynthesis(all, { windowDays: args.windowDays || 7 }); + } + + case 'episodic_list': { + const filter = {}; + if (args.category) filter.category = args.category; + if (args.sinceDays) filter.since = Date.now() - args.sinceDays * 24 * 60 * 60 * 1000; + const list = getCaptures(filter).slice(0, args.limit || 20); + const stats = captureStats(); + return { + count: list.length, + totalInVault: stats.total, + captures: list.map((c) => ({ + id: c.id, + ts: c.ts, + title: c.title, + primary: c.primary, + secondary: c.secondary, + pressure: c.firewall?.pressure, + dominantAffect: c.affects?.dominant?.[0]?.label || null, + urls: c.urls, + tags: c.tags, + mentions: c.mentions, + piiFlags: c.pii?.total > 0 ? c.pii.kinds : [] + })) + }; + } + + case 'episodic_ask': { + if (!args.question) throw new Error('question required'); + const ans = await askTheVault(args.question); + return { + ok: ans.ok, + answer: ans.answer || null, + source: ans.source || null, + mode: ans.mode || null, + hits: (ans.hits || []).map((h) => ({ + id: h.capture.id, + title: h.capture.title, + score: h.score, + primary: h.capture.primary, + ts: h.capture.ts + })) + }; + } + + case 'episodic_drift': { + const all = getCaptures(); + const drifts = detectDecisionDrifts(all, { topK: args.topK || 3 }); + return { + count: drifts.length, + drifts: drifts.map((d) => ({ ...d, ...formatDrift(d) })) + }; + } + + case 'episodic_consolidate': { + const all = getCaptures({ since: Date.now() - 30 * 24 * 60 * 60 * 1000 }); + const pairs = consolidationPass(all, { topK: args.topK || 3, threshold: 0.42 }); + if (pairs.length) { + bridgeContext.setState?.((prev) => applyEpisodicSTDP(prev, pairs)); + } + return { + clustersConsolidated: pairs.length, + pairs: pairs.map((p) => ({ regionKey: p.regionKey, members: p.memberIds.length, reason: p.reason })) + }; + } + + case 'episodic_warm': { + const wasReady = embeddingsReady(); + if (!wasReady) await initEmbeddings(); + const res = await ensureAllEmbeddings(); + return { + wasReady, + readyNow: embeddingsReady(), + ok: res.ok, + done: res.done || 0, + total: res.total || 0, + reason: res.reason || null + }; + } + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/brainsnn-r3f-app/src/utils/privacyBudget.js b/brainsnn-r3f-app/src/utils/privacyBudget.js index 08835b5..15e9701 100644 --- a/brainsnn-r3f-app/src/utils/privacyBudget.js +++ b/brainsnn-r3f-app/src/utils/privacyBudget.js @@ -23,6 +23,11 @@ const KEY_LABELS = { 'brainsnn_last_room_v1': { label: 'Last session room', layer: 77 }, 'brainsnn_bypass_submitted_v1':{ label: 'Bypass-submit badge flag', layer: 56 }, 'brainsnn_polyglot_seen_v1': { label: 'Polyglot badge flag', layer: 56 }, + 'brainsnn_episodic_v1': { label: 'Episodic Cortex captures', layer: 101 }, + 'brainsnn_episodic_emb_v1': { label: 'Episodic embeddings cache', layer: 101 }, + 'brainsnn_episodic_auto_v1': { label: 'Auto-brief schedule + history', layer: 101 }, + 'brainsnn_episodic_streak_v1': { label: 'Episodic capture streak', layer: 101 }, + 'brainsnn_episodic_autorun_v1':{ label: 'Episodic auto-run toggle', layer: 101 }, }; const DYNAMIC_PREFIXES = [ diff --git a/brainsnn-r3f-app/tests/_localStorage.mjs b/brainsnn-r3f-app/tests/_localStorage.mjs new file mode 100644 index 0000000..59b5217 --- /dev/null +++ b/brainsnn-r3f-app/tests/_localStorage.mjs @@ -0,0 +1,24 @@ +/** + * Tiny localStorage shim for Node test runs. + * + * Each module that imports this gets a fresh, in-memory store — + * tests should reset it explicitly between cases via clear(). + */ + +class LocalStorageShim { + constructor() { this.s = new Map(); } + get length() { return this.s.size; } + getItem(k) { return this.s.has(k) ? this.s.get(k) : null; } + setItem(k, v) { this.s.set(k, String(v)); } + removeItem(k) { this.s.delete(k); } + clear() { this.s.clear(); } + key(i) { return Array.from(this.s.keys())[i] || null; } +} + +if (typeof globalThis.localStorage === 'undefined') { + globalThis.localStorage = new LocalStorageShim(); +} + +export function resetLocalStorage() { + globalThis.localStorage.clear(); +} diff --git a/brainsnn-r3f-app/tests/episodic.test.mjs b/brainsnn-r3f-app/tests/episodic.test.mjs new file mode 100644 index 0000000..1110676 --- /dev/null +++ b/brainsnn-r3f-app/tests/episodic.test.mjs @@ -0,0 +1,180 @@ +/** + * Layer 101 — Smoke tests for the pure episodic modules. + * + * Run with: node --test tests/episodic.test.mjs + * + * Scope: only modules that have NO Vite-specific imports (no + * import.meta.env, no transformers.js, no localStorage at import + * time). episodicPII and episodicTaxonomy qualify. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { detectPII, redactPII, piiLabel } from '../src/utils/episodicPII.js'; +import { + EPISODIC_CATEGORIES, + EPISODIC_IDS, + EPISODIC_CLUSTERS, + categoryColor, + clusterFor, + DEFAULT_REGIONS +} from '../src/data/episodicTaxonomy.js'; + +describe('episodicTaxonomy', () => { + it('exposes 8 categories', () => { + assert.equal(EPISODIC_IDS.length, 8); + }); + + it('every category has triggers and region affinities', () => { + for (const id of EPISODIC_IDS) { + const cat = EPISODIC_CATEGORIES[id]; + assert.ok(cat, `category ${id} exists`); + assert.ok(Array.isArray(cat.triggers) && cat.triggers.length >= 2, `${id} has ≥2 triggers`); + assert.ok(cat.regions && Object.keys(cat.regions).length >= 2, `${id} has region affinities`); + assert.ok(EPISODIC_CLUSTERS[cat.cluster], `${id} cluster ${cat.cluster} exists`); + assert.ok(typeof cat.color === 'string' && /^#/.test(cat.color), `${id} color is hex`); + } + }); + + it('decision triggers fire on canonical phrases', () => { + const dec = EPISODIC_CATEGORIES.decision.triggers; + const text = 'We decided to go with Postgres because of query flexibility.'; + const fired = dec.some((rx) => rx.test(text)); + assert.equal(fired, true); + }); + + it('incident triggers fire on canonical phrases', () => { + const inc = EPISODIC_CATEGORIES.incident.triggers; + const text = 'Production outage at 14:02 — auth service crashed.'; + const fired = inc.some((rx) => rx.test(text)); + assert.equal(fired, true); + }); + + it('artifact triggers fire on a URL', () => { + const art = EPISODIC_CATEGORIES.artifact.triggers; + const text = 'Saving https://example.com/post for later.'; + const fired = art.some((rx) => rx.test(text)); + assert.equal(fired, true); + }); + + it('categoryColor falls back to grey for unknown ids', () => { + assert.equal(categoryColor('does-not-exist'), '#7c8aa1'); + }); + + it('clusterFor returns the right cluster for each category', () => { + assert.equal(clusterFor('decision').label, 'From you'); + assert.equal(clusterFor('insight').label, 'From you'); + assert.equal(clusterFor('artifact').label, 'From the world'); + assert.equal(clusterFor('person').label, 'About people'); + assert.equal(clusterFor('incident').label, 'Pivots & shocks'); + }); + + it('clusterFor falls back to external for unknown ids', () => { + assert.equal(clusterFor('nope').label, 'From the world'); + }); + + it('DEFAULT_REGIONS sums to a reasonable diffuse activation', () => { + const sum = Object.values(DEFAULT_REGIONS).reduce((s, v) => s + v, 0); + assert.ok(sum > 0.5 && sum < 1.5, `default regions sum is ${sum}`); + }); + + it('every category cluster id is a known cluster', () => { + const clusterIds = new Set(Object.keys(EPISODIC_CLUSTERS)); + for (const id of EPISODIC_IDS) { + const cat = EPISODIC_CATEGORIES[id]; + assert.ok(clusterIds.has(cat.cluster), `${id} cluster ${cat.cluster} is known`); + } + }); +}); + +describe('episodicPII', () => { + it('detects an email', () => { + const pii = detectPII('Reach me at alice@example.com please'); + assert.equal(pii.total >= 1, true); + assert.equal(pii.first, 'email'); + assert.deepEqual(pii.kinds.email, ['alice@example.com']); + }); + + it('detects an OpenAI-style API key', () => { + const pii = detectPII('export OPENAI_KEY=sk-abcd1234efgh5678ijkl9012mnop3456qrst'); + assert.equal(pii.total >= 1, true); + assert.equal(pii.first, 'apiKey'); + }); + + it('detects an SSN-shaped string', () => { + const pii = detectPII('His SSN is 123-45-6789, do not share'); + const ssn = pii.kinds.ssn || []; + assert.equal(ssn.length, 1); + }); + + it('detects an IPv4', () => { + const pii = detectPII('Server is at 192.168.1.42 right now'); + assert.equal((pii.kinds.ipv4 || []).length, 1); + }); + + it('does not crash on empty input', () => { + const pii = detectPII(''); + assert.equal(pii.total, 0); + assert.equal(pii.first, null); + }); + + it('does not crash on non-string input', () => { + const pii = detectPII(undefined); + assert.equal(pii.total, 0); + }); + + it('caps hits per kind at 5', () => { + const text = 'a@a.com b@b.com c@c.com d@d.com e@e.com f@f.com g@g.com'; + const pii = detectPII(text); + assert.equal((pii.kinds.email || []).length, 5); + }); + + it('strips URLs before long-secret scan', () => { + const pii = detectPII('see https://example.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa for details'); + // The path looks like a long token but should be excluded due to URL strip. + assert.equal((pii.kinds.longSecret || []).length, 0); + }); + + it('redactPII replaces detected values with typed placeholders', () => { + const out = redactPII('email me at bob@example.com or call 555-123-4567'); + assert.equal(/<EMAIL_REDACTED>/.test(out), true); + assert.equal(/<PHONE_REDACTED>/.test(out), true); + assert.equal(out.includes('bob@example.com'), false); + }); + + it('piiLabel returns a known label', () => { + assert.equal(piiLabel('email'), 'email'); + assert.equal(piiLabel('apiKey'), 'API key'); + }); + + it('detects a GitHub PAT', () => { + const pii = detectPII('GIT_TOKEN=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa rest'); + assert.equal((pii.kinds.apiKey || []).length, 1); + }); + + it('detects an AWS access key', () => { + const pii = detectPII('AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE for testing'); + assert.equal((pii.kinds.apiKey || []).length, 1); + }); + + it('detects a Stripe live-prefixed secret shape', () => { + // Synthetic — high-entropy real Stripe keys are flagged by the + // remote secret scanner; this string is a flat repeat that still + // matches the 20+ alnum body of the prefix regex. + const fake = 'sk_live_' + 'X'.repeat(28); + const pii = detectPII(`use ${fake} now`); + assert.equal((pii.kinds.apiKey || []).length, 1); + }); + + it('phone hits include common North American formats', () => { + const pii = detectPII('Call me at (555) 123-4567 or 555-987-6543'); + assert.equal((pii.kinds.phone || []).length >= 1, true); + }); + + it('total counts across multiple kinds', () => { + const pii = detectPII('email a@b.com and SSN 111-22-3333 in one note'); + assert.ok(pii.total >= 2); + assert.ok(Object.keys(pii.kinds).length >= 2); + }); +}); diff --git a/brainsnn-r3f-app/tests/episodicAutoBrief.test.mjs b/brainsnn-r3f-app/tests/episodicAutoBrief.test.mjs new file mode 100644 index 0000000..41e0d19 --- /dev/null +++ b/brainsnn-r3f-app/tests/episodicAutoBrief.test.mjs @@ -0,0 +1,119 @@ +/** + * Smoke tests for episodicAutoBrief. + * Uses an in-memory localStorage shim and synthesizes captures. + */ + +import './_localStorage.mjs'; +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { resetLocalStorage } from './_localStorage.mjs'; + +import { + shouldRunBrief, + shouldRunSynthesis, + recordBrief, + recordSynthesis, + getAutoBriefState, + clearAutoBrief, + nextBriefRelative, + nextSynthesisRelative +} from '../src/utils/episodicAutoBrief.js'; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +function captureAt(tsOffsetMs) { + return { id: `cap_${Math.random()}`, ts: Date.now() + tsOffsetMs, text: 'x', firewall: { pressure: 0.1 } }; +} + +beforeEach(() => { + resetLocalStorage(); + clearAutoBrief(); +}); + +describe('shouldRunBrief', () => { + it('rejects when too few captures', () => { + const captures = [captureAt(-1 * HOUR), captureAt(-2 * HOUR)]; + const r = shouldRunBrief(captures); + assert.equal(r.ok, false); + assert.equal(r.reason, 'too-few-captures'); + assert.equal(r.have, 2); + }); + + it('approves with 3 fresh captures and never-run history', () => { + const captures = [captureAt(-1 * HOUR), captureAt(-2 * HOUR), captureAt(-3 * HOUR)]; + const r = shouldRunBrief(captures); + assert.equal(r.ok, true); + assert.equal(r.freshCount, 3); + }); + + it('rejects when last brief was too recent', () => { + const captures = [captureAt(-1 * HOUR), captureAt(-2 * HOUR), captureAt(-3 * HOUR)]; + recordBrief({ count: 3, source: 'local', pattern: 'p', question: 'q' }, 3); + const r = shouldRunBrief(captures); + assert.equal(r.ok, false); + assert.equal(r.reason, 'too-soon'); + }); + + it('rejects when no fresh captures since last brief', () => { + // brief recorded with a window of captures, then no new ones + const captures = [captureAt(-25 * HOUR), captureAt(-26 * HOUR), captureAt(-27 * HOUR)]; + recordBrief({ count: 3 }, 3); + const r = shouldRunBrief(captures); + // captures are all > 24h old → eligible filter empties → too-few-captures + assert.equal(r.ok, false); + }); +}); + +describe('shouldRunSynthesis', () => { + it('rejects when too few captures in 7d', () => { + const captures = [captureAt(-1 * DAY), captureAt(-2 * DAY)]; + const r = shouldRunSynthesis(captures); + assert.equal(r.ok, false); + assert.equal(r.reason, 'too-few-captures'); + }); + + it('approves with 4+ captures in 7d when never run', () => { + const captures = Array.from({ length: 4 }, (_, i) => captureAt(-(i + 1) * DAY)); + const r = shouldRunSynthesis(captures); + assert.equal(r.ok, true); + assert.equal(r.weeklyCount, 4); + }); + + it('rejects when synth ran recently', () => { + const captures = Array.from({ length: 4 }, (_, i) => captureAt(-(i + 1) * DAY)); + recordSynthesis({ count: 4, source: 'local', emergingThesis: 't', oneAction: 'a' }); + const r = shouldRunSynthesis(captures); + assert.equal(r.ok, false); + assert.equal(r.reason, 'too-soon'); + }); +}); + +describe('history persistence', () => { + it('keeps brief history across reads', () => { + recordBrief({ count: 3, source: 'local', pattern: 'p1', question: 'q1' }, 3); + recordBrief({ count: 4, source: 'local', pattern: 'p2', question: 'q2' }, 4); + const s = getAutoBriefState(); + assert.equal(s.briefHistory.length, 2); + assert.equal(s.briefHistory[0].pattern, 'p2'); + }); + + it('caps history at 12', () => { + for (let i = 0; i < 20; i++) recordBrief({ count: i, pattern: `p${i}` }, i); + const s = getAutoBriefState(); + assert.equal(s.briefHistory.length, 12); + }); +}); + +describe('relative timers', () => { + it('reports never-run when no history', () => { + assert.equal(nextBriefRelative(), 'never run'); + assert.equal(nextSynthesisRelative(), 'never run'); + }); + + it('reports a relative window after recording', () => { + recordBrief({ count: 3, source: 'local', pattern: 'p', question: 'q' }, 3); + const t = nextBriefRelative(); + assert.match(t, /^(?:ready now|\d+h \d+m|\d+m)$/); + }); +}); diff --git a/brainsnn-r3f-app/tests/episodicStreak.test.mjs b/brainsnn-r3f-app/tests/episodicStreak.test.mjs new file mode 100644 index 0000000..df96b83 --- /dev/null +++ b/brainsnn-r3f-app/tests/episodicStreak.test.mjs @@ -0,0 +1,108 @@ +/** + * Smoke tests for episodicStreak. + * + * Uses the localStorage shim and synthesizes captures with explicit + * timestamps so streak math is deterministic regardless of the clock. + */ + +import './_localStorage.mjs'; +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { resetLocalStorage } from './_localStorage.mjs'; + +import { computeStreak, streakLabel, clearStreak } from '../src/utils/episodicStreak.js'; + +const DAY = 24 * 60 * 60 * 1000; + +function dayKey(ts) { + return new Date(ts).toISOString().slice(0, 10); +} + +function makeCapturesAtDays(daysAgoArr) { + // daysAgoArr is e.g. [0, 1, 2] meaning today, yesterday, day before + return daysAgoArr.map((d, i) => ({ id: `c${i}`, ts: Date.now() - d * DAY })); +} + +beforeEach(() => { + resetLocalStorage(); + clearStreak(); +}); + +describe('computeStreak', () => { + it('returns 0 streak with empty captures', () => { + const s = computeStreak([]); + assert.equal(s.current, 0); + assert.equal(s.todayCaptures, 0); + }); + + it('counts a single capture today as a 1-day streak', () => { + const s = computeStreak(makeCapturesAtDays([0])); + assert.equal(s.current, 1); + assert.equal(s.todayCaptures, 1); + }); + + it('counts consecutive days as a multi-day streak', () => { + const s = computeStreak(makeCapturesAtDays([0, 1, 2, 3])); + assert.equal(s.current, 4); + }); + + it('treats yesterday-only as a still-alive streak (grace period)', () => { + const s = computeStreak(makeCapturesAtDays([1])); + assert.equal(s.current, 1); + }); + + it('resets when there is a gap', () => { + // Today, then 5 days ago — gap breaks the streak + const s = computeStreak(makeCapturesAtDays([0, 5, 6, 7])); + assert.equal(s.current, 1); + }); + + it('multiple captures on the same day count as one day', () => { + const captures = [ + { id: 'a', ts: Date.now() }, + { id: 'b', ts: Date.now() - 60 * 1000 }, + { id: 'c', ts: Date.now() - 60 * 60 * 1000 } + ]; + const s = computeStreak(captures); + assert.equal(s.current, 1); + assert.equal(s.todayCaptures, 3); + assert.equal(s.totalDays, 1); + }); + + it('persists longest across calls', () => { + computeStreak(makeCapturesAtDays([0, 1, 2, 3, 4])); // 5 + const next = computeStreak(makeCapturesAtDays([0])); // 1 + assert.equal(next.longest, 5); + }); + + it('totalDays equals the number of distinct UTC days', () => { + const s = computeStreak(makeCapturesAtDays([0, 0, 1, 2, 5, 5, 10])); + assert.equal(s.totalDays, 5); // {today, today-1, today-2, today-5, today-10} + }); +}); + +describe('streakLabel', () => { + it('handles no streak', () => { + assert.equal(streakLabel({ current: 0 }), 'no streak yet'); + }); + + it('handles 1-day', () => { + assert.equal(streakLabel({ current: 1 }), '1-day streak'); + }); + + it('plain text under 7', () => { + assert.equal(streakLabel({ current: 3 }), '3-day streak'); + }); + + it('one fire under 30', () => { + assert.match(streakLabel({ current: 12 }), /🔥 12-day streak/); + }); + + it('two fires under 100', () => { + assert.match(streakLabel({ current: 50 }), /🔥🔥 50-day streak/); + }); + + it('three fires at 100+', () => { + assert.match(streakLabel({ current: 365 }), /🔥🔥🔥 365-day streak/); + }); +});