diff --git a/.env.example b/.env.example index 0614d74..21e12cc 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,8 @@ GITHUB_TOKEN=github_pat_... GITHUB_REPO_OWNER=LikeDreamwalker GITHUB_REPO_NAME=Aftrbrez -# Demo mode: redirect memory/ reads to a pre-seeded persona dataset. -# Set to "true" to use demo data (memory/demo/personal_14/). -# Leave unset or "false" for normal operation. -DEMO_MODE=false +# Benchmark base URL for demo data (optional). +# When set, demo mode reads personas via HTTP from a public repo. +# When unset, demo mode reads from a local benchmark-data directory. +# Ignored when GITHUB_TOKEN is present (your own memory takes priority). +# BENCHMARK_BASE_URL=https://raw.githubusercontent.com/previously-lab/benchmark-data/main diff --git a/README.md b/README.md index 86c4b04..e59c2bf 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Thanks to [Vercel AI SDK](https://sdk.vercel.ai), [shadcn/ui](https://ui.shadcn.

Website · - GitHub + GitHub · Email

diff --git a/messages/en.json b/messages/en.json index c292bb0..f1fcdb7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -29,7 +29,17 @@ "switchTooltip": "Switch language" }, "demo": { - "banner": "Demo mode · changes aren't saved" + "banner": "Browsing demo data — changes aren't saved", + "badgeLabel": "Demo", + "badgeTitle": "You're in demo mode", + "badgeDesc": "You're browsing a read-only preview with pre-seeded personas. Connect your own GitHub repository to save memories, edit settings, and run background loops.", + "setupAction": "Set up your own →", + "toastTitle": "You're browsing demo data", + "toastDesc": "This is a read-only preview with pre-seeded personas.", + "dismissAction": "Got it" + }, + "persona": { + "dialogTitle": "Choose a Persona" }, "chat": { "input": { @@ -184,6 +194,11 @@ "error": { "title": "Failed to load settings", "message": "Could not load settings." + }, + "demo": { + "heading": "Demo Mode", + "description": "You are browsing a read-only demo with pre-seeded persona data. Connect your own GitHub repository to unlock full memory features — profile editing, persistent settings, and background loops.", + "setupLink": "Setup guide →" } }, "memory": { diff --git a/messages/zh.json b/messages/zh.json index bbd6d04..bc1e0ca 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -29,7 +29,17 @@ "switchTooltip": "切换语言" }, "demo": { - "banner": "演示模式 · 所有更改不会被保存" + "banner": "正在浏览演示数据 · 所有更改不会被保存", + "badgeLabel": "体验", + "badgeTitle": "你正在体验模式", + "badgeDesc": "你正在浏览一个只读预览,包含预设角色数据。连接你自己的 GitHub 仓库即可保存记忆、编辑设置并运行后台任务。", + "setupAction": "配置自己的仓库 →", + "toastTitle": "你正在浏览演示数据", + "toastDesc": "这是一个只读预览,包含预设角色数据。", + "dismissAction": "知道了" + }, + "persona": { + "dialogTitle": "选择角色" }, "chat": { "input": { @@ -184,6 +194,11 @@ "error": { "title": "加载设置失败", "message": "无法加载设置。" + }, + "demo": { + "heading": "演示模式", + "description": "你正在浏览一个只读演示,包含预设角色数据。连接你自己的 GitHub 仓库即可解锁完整记忆功能——个人资料编辑、持久化设置和后台任务。", + "setupLink": "配置指南 →" } }, "memory": { diff --git a/scripts/apply-enrichment.mjs b/scripts/apply-enrichment.mjs new file mode 100644 index 0000000..7f222aa --- /dev/null +++ b/scripts/apply-enrichment.mjs @@ -0,0 +1,333 @@ +/** + * Apply Haiku-generated tag enrichment + quality fixes to a persona. + * + * Reads a JSON enrichment file produced by the Haiku review agent, then: + * 1. Updates tags / focus / summary in each slice's YAML frontmatter + * 2. Regenerates _index.json entries + * 3. Regenerates strands.json + * 4. Writes a quality report to the persona root + * + * Usage: + * node scripts/apply-enrichment.mjs + * + * persona-id e.g. "personal_14" + * enrichment.json Haiku agent output (see schema below) + * + * Enrichment JSON schema: + * { + * "personaId": "personal_14", + * "reviewedAt": "2026-07-20T...", + * "model": "claude-haiku-4-5", + * "overallNotes": "any global observations about this persona", + * "slices": { + * "2025-01-08-1130": { + * "tags": ["flood-mitigation", "personal-intake", "career-history"], + * "focus": "Caleb's comprehensive personal intake", // optional fix + * "summary": "Caleb completed a full personal intake…", // optional fix + * "emotional_tone": "positive", + * "open_loops": ["Follow up on mother's health", ...], // optional + * "decisions": ["Save complete profile", ...], // optional + * "quality": { + * "focus_accurate": true, + * "summary_accurate": true, + * "tags_relevant": true, + * "notes": "any observations about this slice" + * } + * } + * } + * } + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); +const BENCHMARK_DIR = path.join(ROOT, "..", "benchmark-data"); + +// ─── Helpers ───────────────────────────────────────────────────────────── + +function personaDir(personaId) { + return path.join(BENCHMARK_DIR, personaId); +} + +function slicesDir(personaId) { + return path.join(personaDir(personaId), "episodic", "slices"); +} + +function slicePath(personaId, sliceId) { + // sliceId = YYYY-MM-DD-HHMM → YYYY/MM/DD/HHMM.md + const [y, m, d, hhmm] = sliceId.split("-"); + return path.join(slicesDir(personaId), y, m, d, `${hhmm}.md`); +} + +/** Parse a slice .md file → { frontmatter (raw string), body (raw string) } */ +function readSliceRaw(filePath) { + const raw = fs.readFileSync(filePath, "utf-8"); + const fmEnd = raw.indexOf("---", 3); + if (fmEnd === -1) throw new Error(`No frontmatter in ${filePath}`); + return { + frontmatter: raw.slice(0, fmEnd + 3), + body: raw.slice(fmEnd + 3), + }; +} + +/** Replace or add a YAML list field in the frontmatter string. */ +function setYamlList(fm, key, values) { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`\n${escapedKey}:[\n\r](?: - [^\n]*[\n\r]?)*`); + const block = values.length > 0 + ? `\n${key}:\n${values.map((v) => ` - ${v}`).join("\n")}` + : `\n${key}: []`; + if (re.test(fm)) { + return fm.replace(re, block); + } + // Key doesn't exist — insert before closing --- + return fm.replace(/\n---$/, `${block}\n---`); +} + +/** Replace or add a single YAML string field in the frontmatter. */ +function setYamlString(fm, key, value) { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`\n${escapedKey}:.*`); + const line = `\n${key}: ${JSON.stringify(value)}`; + if (re.test(fm)) { + return fm.replace(re, line); + } + return fm.replace(/\n---$/, `${line}\n---`); +} + +function writeSliceFile(personaId, sliceId, fm, body) { + const fp = slicePath(personaId, sliceId); + fs.writeFileSync(fp, fm + body, "utf-8"); +} + +// ─── Index + strands regeneration ──────────────────────────────────────── + +function rebuildIndexes(personaId) { + const dir = slicesDir(personaId); + const byMonth = {}; + + // Walk: YYYY → MM → DD → HHMM.md + const yearDirs = fs.readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && /^\d{4}$/.test(e.name)); + for (const yDir of yearDirs) { + const yPath = path.join(dir, yDir.name); + const monthDirs = fs.readdirSync(yPath, { withFileTypes: true }) + .filter((e) => e.isDirectory() && /^\d{2}$/.test(e.name)); + for (const mDir of monthDirs) { + const mPath = path.join(yPath, mDir.name); + const dayDirs = fs.readdirSync(mPath, { withFileTypes: true }) + .filter((e) => e.isDirectory() && /^\d{2}$/.test(e.name)); + for (const dDir of dayDirs) { + const dPath = path.join(mPath, dDir.name); + const mdFiles = fs.readdirSync(dPath).filter((f) => f.endsWith(".md")); + for (const mf of mdFiles) { + const raw = readSliceRaw(path.join(dPath, mf)); + const sidMatch = raw.frontmatter.match(/slice_id:\s*(\S+)/); + if (!sidMatch) continue; + const sliceId = sidMatch[1]; + const [y, mo] = sliceId.split("-"); + const key = `${y}-${mo}`; + if (!byMonth[key]) byMonth[key] = []; + + const focus = (raw.frontmatter.match(/focus:\s*(.+)/) ?? [])[1] ?? ""; + const summary = (raw.frontmatter.match(/summary:\s*(.+)/) ?? [])[1] ?? ""; + const status = (raw.frontmatter.match(/status:\s*(\S+)/) ?? [])[1] ?? "closed"; + const start = (raw.frontmatter.match(/start:\s*"([^"]+)"/) ?? [])[1] ?? ""; + const tagsMatch = raw.frontmatter.match(/tags:\n((?: - [^\n]+\n?)*)/); + const tags = tagsMatch + ? tagsMatch[1].split("\n").filter(Boolean).map((l) => l.replace(/^\s*-\s*/, "").replace(/^"(.*)"$/, "$1")) + : []; + + const openLoops = []; + const decisions = []; + let inLoops = false, inDecisions = false; + for (const line of raw.frontmatter.split("\n")) { + if (line.startsWith("open_loops:")) { inLoops = true; inDecisions = false; continue; } + if (line.startsWith("decisions:")) { inDecisions = true; inLoops = false; continue; } + if (inLoops && line.match(/^\s*-\s*(.+)/)) { + openLoops.push(line.match(/^\s*-\s*"?(.+?)"?\s*$/)?.[1] ?? ""); + } else if (inLoops && !line.startsWith(" ")) { inLoops = false; } + if (inDecisions && line.match(/^\s*-\s*(.+)/)) { + decisions.push(line.match(/^\s*-\s*"?(.+?)"?\s*$/)?.[1] ?? ""); + } else if (inDecisions && !line.startsWith(" ")) { inDecisions = false; } + } + + byMonth[key].push({ + id: sliceId, focus: focus.replace(/^"/, "").replace(/"$/, ""), + summary: summary.replace(/^"/, "").replace(/"$/, ""), + tags, status, start: start.replace(/^"/, "").replace(/"$/, ""), + open_loops: openLoops, decisions, + }); + } + } + } + } + + for (const [monthKey, entries] of Object.entries(byMonth)) { + const [year, month] = monthKey.split("-"); + entries.sort((a, b) => a.id.localeCompare(b.id)); + const indexPath = path.join(dir, year, month, "_index.json"); + fs.writeFileSync( + indexPath, + JSON.stringify({ month: monthKey, slices: entries }, null, 2), + "utf-8" + ); + } + + return Object.keys(byMonth).length; +} + +function rebuildStrands(personaId) { + const dir = slicesDir(personaId); + const strands = {}; + + // Walk: YYYY → MM → DD → HHMM.md + const years = fs.readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && /^\d{4}$/.test(e.name)); + for (const yDir of years) { + const months = fs.readdirSync(path.join(dir, yDir.name), { withFileTypes: true }) + .filter((e) => e.isDirectory() && /^\d{2}$/.test(e.name)); + for (const mDir of months) { + const mPath = path.join(dir, yDir.name, mDir.name); + const days = fs.readdirSync(mPath, { withFileTypes: true }) + .filter((e) => e.isDirectory() && /^\d{2}$/.test(e.name)); + for (const dDir of days) { + const dPath = path.join(mPath, dDir.name); + const mdFiles = fs.readdirSync(dPath).filter((f) => f.endsWith(".md")); + for (const mf of mdFiles) { + const raw = readSliceRaw(path.join(dPath, mf)); + const sidMatch = raw.frontmatter.match(/slice_id:\s*(\S+)/); + if (!sidMatch) continue; + const sliceId = sidMatch[1]; + const [y, m, d, hhmm] = sliceId.split("-"); + const relPath = `${y}/${m}/${d}/${hhmm}`; + const tagsMatch = raw.frontmatter.match(/tags:\n((?: - [^\n]+\n?)*)/); + const tags = tagsMatch + ? tagsMatch[1].split("\n").filter(Boolean).map((l) => l.replace(/^\s*-\s*/, "")) + : []; + for (const tag of tags) { + if (!strands[tag]) strands[tag] = []; + if (!strands[tag].includes(relPath)) strands[tag].push(relPath); + } + } + } + } + } + + const strandsPath = path.join(personaDir(personaId), "episodic", "strands.json"); + fs.writeFileSync(strandsPath, JSON.stringify(strands, null, 2), "utf-8"); + return Object.keys(strands).length; +} + +// ─── Main ───────────────────────────────────────────────────────────────── + +const personaId = process.argv[2]; +const enrichmentPath = process.argv[3]; + +if (!personaId || !enrichmentPath) { + console.error("Usage: node scripts/apply-enrichment.mjs "); + process.exit(1); +} + +if (!fs.existsSync(enrichmentPath)) { + console.error(`Enrichment file not found: ${enrichmentPath}`); + process.exit(1); +} + +const enrichment = JSON.parse(fs.readFileSync(enrichmentPath, "utf-8")); + +if (enrichment.personaId !== personaId) { + console.warn(`WARNING: enrichment.personaId (${enrichment.personaId}) ≠ ${personaId}`); +} + +console.log(`=== Apply Enrichment: ${personaId} ===`); +console.log(`Model: ${enrichment.model}`); +console.log(`Slices to update: ${Object.keys(enrichment.slices).length}`); + +let updatedTags = 0; +let updatedFocus = 0; +let updatedSummary = 0; +let updatedOpenLoops = 0; +let updatedDecisions = 0; +const qualityIssues = []; + +for (const [sliceId, update] of Object.entries(enrichment.slices)) { + const fp = slicePath(personaId, sliceId); + if (!fs.existsSync(fp)) { + console.warn(` SKIP ${sliceId}: file not found at ${fp}`); + continue; + } + + const { frontmatter, body } = readSliceRaw(fp); + + let fm = frontmatter; + + if (update.tags && Array.isArray(update.tags) && update.tags.length > 0) { + fm = setYamlList(fm, "tags", update.tags); + updatedTags++; + } + + if (update.focus && typeof update.focus === "string") { + fm = setYamlString(fm, "focus", update.focus); + updatedFocus++; + } + + if (update.summary && typeof update.summary === "string") { + fm = setYamlString(fm, "summary", update.summary); + updatedSummary++; + } + + if (update.open_loops && Array.isArray(update.open_loops)) { + fm = setYamlList(fm, "open_loops", update.open_loops); + updatedOpenLoops++; + } + + if (update.decisions && Array.isArray(update.decisions)) { + fm = setYamlList(fm, "decisions", update.decisions); + updatedDecisions++; + } + + if (update.emotional_tone && typeof update.emotional_tone === "string") { + fm = setYamlString(fm, "emotional_tone", update.emotional_tone); + } + + writeSliceFile(personaId, sliceId, fm, body); + + if (update.quality) { + const q = update.quality; + if (!q.focus_accurate || !q.summary_accurate || !q.tags_relevant || q.notes) { + qualityIssues.push({ sliceId, ...q }); + } + } +} + +// Rebuild indexes + strands +const indexCount = rebuildIndexes(personaId); +const strandCount = rebuildStrands(personaId); + +// Write quality report +if (qualityIssues.length > 0 || enrichment.overallNotes) { + const report = { + personaId, + reviewedAt: enrichment.reviewedAt, + model: enrichment.model, + overallNotes: enrichment.overallNotes ?? "", + issues: qualityIssues, + }; + const reportPath = path.join(personaDir(personaId), "quality-report.json"); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf-8"); + console.log(`Quality report: ${reportPath} (${qualityIssues.length} issues)`); +} + +console.log(`\nDone:`); +console.log(` Tags updated: ${updatedTags}`); +console.log(` Focus updated: ${updatedFocus}`); +console.log(` Summary updated: ${updatedSummary}`); +console.log(` Open loops: ${updatedOpenLoops}`); +console.log(` Decisions: ${updatedDecisions}`); +console.log(` Monthly indexes: ${indexCount}`); +console.log(` Strands: ${strandCount}`); +console.log(` Quality issues: ${qualityIssues.length}`); diff --git a/scripts/batch-convert.mjs b/scripts/batch-convert.mjs new file mode 100644 index 0000000..3d475af --- /dev/null +++ b/scripts/batch-convert.mjs @@ -0,0 +1,497 @@ +/** + * Batch Convert WorldMemArena → Previously On Benchmark Data + * + * Reads all personal_*.json files from a _raw/ directory and converts each into + * the Previously On episodic memory format. Output goes directly into the + * benchmark-data repo structure. + * + * Usage: + * node scripts/batch-convert.mjs [--raw ] [--out ] + * + * --raw Directory containing personal_*.json files + * (default: ../benchmark-data/_raw) + * --out Output root for converted personas + * (default: ../benchmark-data) + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); + +// ─── CLI args ──────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +function getArg(flag) { + const idx = args.indexOf(flag); + return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : null; +} +const RAW_DIR = getArg("--raw") ?? path.join(ROOT, "..", "benchmark-data", "_raw"); +const OUT_DIR = getArg("--out") ?? path.join(ROOT, "..", "benchmark-data"); + +// ─── Helpers ───────────────────────────────────────────────────────────── + +function parseDate(str) { return new Date(str); } + +function toSliceId(date) { + if (isNaN(date.getTime())) return null; + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, "0"); + const d = String(date.getUTCDate()).padStart(2, "0"); + const hh = String(date.getUTCHours()).padStart(2, "0"); + const mm = String(date.getUTCMinutes()).padStart(2, "0"); + return `${y}-${m}-${d}-${hh}${mm}`; +} + +function toISO(date) { return date.toISOString(); } + +function extractSummary(text) { + const firstSentence = text.split(/[.!?][\s\n]/)[0]; + if (!firstSentence) return text.slice(0, 100); + return firstSentence.slice(0, 100); +} + +/** Regex-based tag extraction — serviceable baseline. Replace with Haiku later. */ +function extractTags(text) { + const patterns = [ + { regex: /\b(work|job|career|promotion|project|deadline|meeting|boss|colleague|office|salary)\b/i, tag: "work" }, + { regex: /\b(family|mom|dad|son|daughter|husband|wife|parent|kid|child|brother|sister)\b/i, tag: "family" }, + { regex: /\b(health|doctor|hospital|sick|pain|injury|surgery|medicine|therapy|mental|depression|anxiety)\b/i, tag: "health" }, + { regex: /\b(money|budget|finance|debt|loan|salary|income|expense|rent|mortgage|bills|savings)\b/i, tag: "finance" }, + { regex: /\b(relationship|dating|boyfriend|girlfriend|partner|marriage|divorce|breakup)\b/i, tag: "relationship" }, + { regex: /\b(travel|trip|vacation|holiday|flight|hotel|visit)\b/i, tag: "travel" }, + { regex: /\b(study|school|college|university|degree|course|class|exam|learn|education)\b/i, tag: "education" }, + { regex: /\b(move|relocate|apartment|house|rent|lease|neighbor|neighborhood)\b/i, tag: "housing" }, + { regex: /\b(hobby|sport|soccer|running|hiking|gym|exercise|fitness|game|music)\b/i, tag: "leisure" }, + { regex: /\b(goal|plan|future|dream|ambition|five.year|career.change)\b/i, tag: "goals" }, + { regex: /\b(flood|mitigation|outreach|climate|disaster|resilience|adaptation|weather|storm|hurricane)\b/i, tag: "environment" }, + { regex: /\b(community|public.service|civic|council|neighborhood|outreach)\b/i, tag: "civic" }, + ]; + const tagSet = new Set(); + for (const { regex, tag } of patterns) { + if (regex.test(text)) tagSet.add(tag); + } + return [...tagSet].slice(0, 8); +} + +function deriveFocus(dialogue) { + const firstUser = dialogue.find((t) => t.role === "user"); + if (!firstUser) return "conversation"; + const cleaned = firstUser.content.replace(/^Hello[^.]*\.\s*/, "").trim(); + return cleaned.slice(0, 80) || firstUser.content.slice(0, 80); +} + +function deriveSummary(dialogue) { + const userMessages = dialogue + .filter((t) => t.role === "user") + .map((t) => t.content); + const combined = userMessages.slice(1, 4).join(" "); + return extractSummary(combined) || extractSummary(userMessages[0] || ""); +} + +/** Extract a persona name from early session dialogue (heuristic). */ +function derivePersonaName(dialogue) { + const allText = dialogue.map((t) => t.content).join(" "); + // Try to find "My name is X" patterns + const nameMatch = allText.match(/My name is ([A-Z][a-z]+(?: [A-Z][a-z]+)+)/); + return nameMatch ? nameMatch[1] : "Unknown"; +} + +function toYamlFrontmatter(obj) { + const lines = ["---"]; + for (const [key, value] of Object.entries(obj)) { + if (value === undefined || value === null) continue; + if (value === "" || (Array.isArray(value) && value.length === 0)) continue; + if (typeof value === "object" && !Array.isArray(value)) continue; + if (Array.isArray(value)) { + lines.push(`${key}:`); + for (const item of value) { + const str = String(item); + if (/[":{}#&*!|>'"%@`[\]\n,]/.test(str)) { + lines.push(` - ${JSON.stringify(str)}`); + } else { + lines.push(` - ${str}`); + } + } + } else { + const str = String(value); + if (/[":{}#&*!|>'"%@`[\]\n,]/.test(str)) { + lines.push(`${key}: ${JSON.stringify(str)}`); + } else { + lines.push(`${key}: ${str}`); + } + } + } + lines.push("---"); + lines.push(""); + return lines.join("\n"); +} + +function serializeTimeSlice(slice) { + const fm = { + slice_id: slice.slice_id, + focus: slice.focus, + status: slice.status, + start: slice.start, + end: slice.end, + timezone: slice.timezone, + summary: slice.summary, + open_loops: slice.open_loops, + decisions: slice.decisions, + tags: slice.tags, + related_slices: slice.related_slices, + emotional_tone: slice.emotional_tone, + }; + const frontmatter = toYamlFrontmatter(fm); + const body = slice.turns + .map( + (turn, i) => + `## Turn ${i + 1} — ${turn.timestamp} (${turn.role})\n\n${turn.content}` + ) + .join("\n\n"); + return frontmatter + body + "\n"; +} + +// ─── Profile generation ────────────────────────────────────────────────── + +function generateProfile(personaId, name, allDialogue) { + const allText = allDialogue.map((t) => t.content).join(" "); + // Very rough persona description from first few user messages + const userMessages = allDialogue.filter((t) => t.role === "user").map((t) => t.content); + const body = userMessages.slice(0, 3).join(" ").slice(0, 500); + + const frontmatter = `--- +name: ${name} +timezone: America/Chicago +locale: en +address_as: ${name.split(" ")[0]} +--- +`; + return frontmatter + body + "\n"; +} + +// ─── Convert one persona ───────────────────────────────────────────────── + +function convertPersona(personaId, rawPath, outDir) { + console.log(`\n=== ${personaId} ===`); + + const raw = fs.readFileSync(rawPath, "utf-8"); + const data = JSON.parse(raw); + + console.log(` Sessions: ${data.sessions?.length ?? 0}`); + console.log(` Memory point groups: ${data.memory_points?.length ?? 0}`); + + // Clear existing output + const personaDir = path.join(outDir, personaId); + if (fs.existsSync(personaDir)) { + fs.rmSync(personaDir, { recursive: true }); + } + + // ── Convert sessions → time slices ──────────────────────────── + const allSlices = []; + let personaName = "Unknown"; + + for (const session of data.sessions ?? []) { + const sessionId = session._v2_session_id; + const dialogue = session.dialogue; + if (!dialogue || dialogue.length === 0) continue; + + // Try to extract persona name from the first session + if (personaName === "Unknown" && sessionId === data.sessions[0]._v2_session_id) { + personaName = derivePersonaName(dialogue); + } + + const firstTimestamp = parseDate(dialogue[0].timestamp); + const lastTimestamp = parseDate(dialogue[dialogue.length - 1].timestamp); + const sliceId = toSliceId(firstTimestamp); + if (!sliceId) { + console.warn(` SKIP ${sessionId}: invalid timestamp "${dialogue[0].timestamp}"`); + continue; + } + + const turns = dialogue.map((turn) => ({ + timestamp: toISO(parseDate(turn.timestamp)), + role: turn.role === "assistant" ? "agent" : "user", + content: turn.content, + })); + + const focus = deriveFocus(dialogue); + const summary = deriveSummary(dialogue); + const allText = dialogue.map((t) => t.content).join(" "); + const tags = extractTags(allText); + + // Load memory points for this session + const sessionMemPoints = []; + for (const mpGroup of data.memory_points ?? []) { + if (mpGroup.session_id === sessionId) { + sessionMemPoints.push(...mpGroup.memory_points); + } + } + + const openLoops = sessionMemPoints + .filter((mp) => mp.is_update === "False" && mp.memory_source === "primary") + .map((mp) => mp.memory_content.slice(0, 120)) + .slice(0, 5); + + const decisions = sessionMemPoints + .filter((mp) => mp.is_update === "True") + .map((mp) => mp.memory_content.slice(0, 120)) + .slice(0, 5); + + const slice = { + slice_id: sliceId, + focus, + status: "closed", + start: turns[0].timestamp, + end: turns[turns.length - 1].timestamp, + timezone: "America/Chicago", + summary, + open_loops: openLoops, + decisions, + tags, + related_slices: [], + emotional_tone: "neutral", + turns, + }; + + allSlices.push(slice); + } + + // ── Write time slices ───────────────────────────────────────── + // Path: YYYY/MM/DD/HHMM.md — derived from slice_id + // Collision guard: if two sessions land on the same HHMM, append -2, -3, etc. + + const sliceFilePaths = []; + const usedHhmms = new Set(); + + for (let i = 0; i < allSlices.length; i++) { + const slice = allSlices[i]; + const parts = slice.slice_id.split("-"); + // slice_id = YYYY-MM-DD-HHMM + let [year, month, day, hhmm] = parts; + + const dir = path.join(personaDir, "episodic", "slices", year, month, day); + fs.mkdirSync(dir, { recursive: true }); + + // Avoid HHMM collisions on the same day (rare with personal data) + const dirKey = `${year}/${month}/${day}`; + let candidateHhmm = hhmm; + let suffix = 2; + while (usedHhmms.has(`${dirKey}/${candidateHhmm}`)) { + const base = String(suffix).padStart(4, "0"); + candidateHhmm = String(parseInt(hhmm, 10) + suffix).padStart(4, "0"); + suffix++; + } + usedHhmms.add(`${dirKey}/${candidateHhmm}`); + + const fileName = `${candidateHhmm}.md`; + const filePath = path.join(dir, fileName); + fs.writeFileSync(filePath, serializeTimeSlice(slice), "utf-8"); + + sliceFilePaths.push({ year, month, day, hhmm: candidateHhmm }); + } + + console.log(` Wrote ${allSlices.length} slice files`); + + // ── Write monthly indices ───────────────────────────────────── + + const byMonth = {}; + const monthSlices = {}; + + for (let i = 0; i < allSlices.length; i++) { + const slice = allSlices[i]; + const [year, month] = slice.slice_id.split("-"); + const key = `${year}-${month}`; + if (!byMonth[key]) byMonth[key] = []; + + const sp = sliceFilePaths[i]; + byMonth[key].push({ + slice, + relDay: sp.day, + hhmm: sp.hhmm, + }); + } + + for (const [monthKey, entries] of Object.entries(byMonth)) { + const [year, monthNum] = monthKey.split("-"); + const indexEntries = entries + .map(({ slice, relDay, hhmm }) => ({ + id: slice.slice_id, + focus: slice.focus, + summary: slice.summary, + tags: slice.tags, + status: slice.status, + start: slice.start, + open_loops: slice.open_loops, + decisions: slice.decisions, + })) + .sort((a, b) => a.id.localeCompare(b.id)); + + const dir = path.join(personaDir, "episodic", "slices", year, monthNum); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "_index.json"), + JSON.stringify({ month: monthKey, slices: indexEntries }, null, 2), + "utf-8" + ); + } + + console.log(` Wrote ${Object.keys(byMonth).length} monthly indexes`); + + // ── Write strands ───────────────────────────────────────────── + + const strands = {}; + for (let i = 0; i < allSlices.length; i++) { + const slice = allSlices[i]; + const sp = sliceFilePaths[i]; + const relPath = `${sp.year}/${sp.month}/${sp.day}/${sp.hhmm}`; + for (const tag of slice.tags) { + if (!strands[tag]) strands[tag] = []; + if (!strands[tag].includes(relPath)) { + strands[tag].push(relPath); + } + } + } + + const strandsDir = path.join(personaDir, "episodic"); + fs.mkdirSync(strandsDir, { recursive: true }); + fs.writeFileSync( + path.join(strandsDir, "strands.json"), + JSON.stringify(strands, null, 2), + "utf-8" + ); + + console.log(` Wrote strands.json (${Object.keys(strands).length} strands)`); + + // ── Write user profile ──────────────────────────────────────── + + const profileDir = path.join(personaDir, "user"); + fs.mkdirSync(profileDir, { recursive: true }); + const allDialogue = allSlices.flatMap((s) => s.turns); + const profile = generateProfile(personaId, personaName, allDialogue); + fs.writeFileSync(path.join(profileDir, "profile.md"), profile, "utf-8"); + + // ── Return manifest fragment ────────────────────────────────── + + const allTags = [ + ...new Set(allSlices.flatMap((s) => s.tags)), + ]; + const dateRange = allSlices.length > 0 + ? [allSlices[0].slice_id.slice(0, 7), allSlices[allSlices.length - 1].slice_id.slice(0, 7)] + : []; + + return { + personaId, + name: personaName, + topics: allTags, + sliceCount: allSlices.length, + dateRange, + strands: Object.keys(strands), + }; +} + +// ─── Build manifest.json ────────────────────────────────────────────────── + +function buildManifest(personas, outDir) { + const manifest = { version: 1, personas: {} }; + + for (const p of personas) { + // Build a lightweight tree from the actual file listing + const tree = {}; + const personaDir = path.join(outDir, p.personaId); + + function scanTree(dirPath, node) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory() && !e.name.startsWith(".")) { + node[e.name] = {}; + scanTree(path.join(dirPath, e.name), node[e.name]); + } else if (e.isFile() && !e.name.startsWith(".")) { + if (!node._files) node._files = []; + node._files.push(e.name); + } + } + } + + scanTree(personaDir, tree); + + manifest.personas[p.personaId] = { + name: p.name, + description: `${p.sliceCount} sessions across ${p.dateRange[0]} → ${p.dateRange[1]}`, + topics: p.topics.slice(0, 12), + sliceCount: p.sliceCount, + dateRange: p.dateRange, + strands: p.strands, + tree, + }; + } + + const manifestPath = path.join(outDir, "manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"); + console.log(`\nWrote manifest.json (${Object.keys(manifest.personas).length} personas)`); +} + +// ─── Main ───────────────────────────────────────────────────────────────── + +console.log("=== Batch Convert WorldMemArena → Previously On ===\n"); +console.log(`Raw dir: ${RAW_DIR}`); +console.log(`Out dir: ${OUT_DIR}`); + +if (!fs.existsSync(RAW_DIR)) { + console.error(`\nERROR: Raw directory not found: ${RAW_DIR}`); + console.error("Download WorldMemArena personal samples first:"); + console.error(" pip install huggingface_hub"); + console.error(" huggingface-cli download LCZZZZ/WorldMemArena --repo-type dataset \\"); + console.error(" --local-dir ./WorldMemArena \\"); + console.error(' --include "WorldMemArena/lifelong/personal/personal_*.json"'); + console.error(`Then copy the JSON files to: ${RAW_DIR}`); + process.exit(1); +} + +const rawFiles = fs.readdirSync(RAW_DIR) + .filter((f) => f.match(/^personal_\d+\.json$/)) + .sort(); + +if (rawFiles.length === 0) { + console.error(`\nERROR: No personal_*.json files found in ${RAW_DIR}`); + process.exit(1); +} + +console.log(`\nFound ${rawFiles.length} persona files:\n ${rawFiles.join("\n ")}`); + +const allMetas = []; + +for (const fileName of rawFiles) { + const personaId = fileName.replace(".json", ""); + const rawPath = path.join(RAW_DIR, fileName); + const meta = convertPersona(personaId, rawPath, OUT_DIR); + allMetas.push(meta); +} + +// ─── Build manifest.json ────────────────────────────────────────────────── + +buildManifest(allMetas, OUT_DIR); + +// ─── Summary ────────────────────────────────────────────────────────────── + +function getDirSize(dir) { + let size = 0; + if (!fs.existsSync(dir)) return 0; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fp = path.join(dir, entry.name); + size += entry.isDirectory() ? getDirSize(fp) : fs.statSync(fp).size; + } + return size; +} + +const totalSize = getDirSize(OUT_DIR); +console.log(`\n=== Done ===`); +console.log(`Output: ${OUT_DIR}`); +console.log(`Size: ${(totalSize / 1024).toFixed(1)} KB`); +console.log(`Personas: ${allMetas.length}`); +for (const m of allMetas) { + console.log(` ${m.personaId}: ${m.sliceCount} slices, ${m.strands.length} strands — ${m.name}`); +} diff --git a/scripts/regen-manifest.mjs b/scripts/regen-manifest.mjs new file mode 100644 index 0000000..7502995 --- /dev/null +++ b/scripts/regen-manifest.mjs @@ -0,0 +1,88 @@ +/** Regenerate manifest.json for benchmark-data repo. */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OUT = path.join(__dirname, "..", "..", "benchmark-data"); + +const personas = fs.readdirSync(OUT) + .filter((e) => e.startsWith("personal_") && fs.statSync(path.join(OUT, e)).isDirectory()) + .sort(); + +const manifest = { version: 1, personas: {} }; + +for (const p of personas) { + const tree = {}; + function scanTree(dirPath, node) { + for (const e of fs.readdirSync(dirPath, { withFileTypes: true })) { + if (e.isDirectory() && !e.name.startsWith(".")) { + node[e.name] = {}; + scanTree(path.join(dirPath, e.name), node[e.name]); + } else if (e.isFile() && !e.name.startsWith(".")) { + if (!node._files) node._files = []; + node._files.push(e.name); + } + } + } + scanTree(path.join(OUT, p), tree); + + let name = p, sliceCount = 0, dateRange = []; + const strandsPath = path.join(OUT, p, "episodic", "strands.json"); + let topics = []; + if (fs.existsSync(strandsPath)) { + const strands = JSON.parse(fs.readFileSync(strandsPath, "utf-8")); + topics = Object.keys(strands).slice(0, 12); + const allPaths = Object.values(strands).flat(); + for (const rp of allPaths) { + const parts = rp.split("/"); + if (parts.length >= 2) { + const mKey = parts[0] + "-" + parts[1]; + if (!dateRange[0] || mKey < dateRange[0]) dateRange[0] = mKey; + if (!dateRange[1] || mKey > dateRange[1]) dateRange[1] = mKey; + } + } + sliceCount = new Set(allPaths).size; + } + + const profilePath = path.join(OUT, p, "user", "profile.md"); + let blurb = ""; + if (fs.existsSync(profilePath)) { + const raw = fs.readFileSync(profilePath, "utf-8"); + const fmMatch = raw.match(/---\r?\n([\s\S]*?)---/); + if (fmMatch) { + const nm = fmMatch[1].match(/name:\s*(.+)/); + if (nm) name = nm[1].trim(); + } + // Extract narrative blurb: prefer quality-report overallNotes, fallback to profile body + const qrPath = path.join(OUT, p, "quality-report.json"); + if (fs.existsSync(qrPath)) { + try { + const qr = JSON.parse(fs.readFileSync(qrPath, "utf-8")); + if (qr.overallNotes) { + blurb = qr.overallNotes.slice(0, 280).replace(/\n/g, " ").trim(); + if (qr.overallNotes.length > 280) blurb += "…"; + } + } catch { /* fall through */ } + } + if (!blurb) { + let body = raw.split("---").slice(2).join("---").trim(); + body = body.replace(/^Hello,?\s*I'd like to share my personal information[^.]*\.\s*/i, ""); + blurb = body.slice(0, 250).replace(/\n/g, " ").trim(); + if (body.length > 250) blurb += "…"; + } + } + + manifest.personas[p] = { + name, + description: sliceCount + " sessions across " + (dateRange[0] || "?") + " → " + (dateRange[1] || "?"), + blurb, + topics, + sliceCount, + dateRange, + tree, + }; +} + +fs.writeFileSync(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2)); +console.log("manifest.json updated: " + Object.keys(manifest.personas).length + " personas"); diff --git a/scripts/shift-all-dates.mjs b/scripts/shift-all-dates.mjs new file mode 100644 index 0000000..00a3c10 --- /dev/null +++ b/scripts/shift-all-dates.mjs @@ -0,0 +1,185 @@ +/** + * Shift all persona dates by N years. Pure file operation — no LLM. + * + * Safer approach: copy-to-new → shift-content → verify → delete-old. + * + * Usage: + * node scripts/shift-all-dates.mjs [--years 3] [--data-dir ] [--reverse] + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); + +const args = process.argv.slice(2); +function getArg(f) { const i = args.indexOf(f); return i >= 0 && i + 1 < args.length ? args[i + 1] : null; } +const SHIFT = parseInt(getArg("--years") ?? "3", 10); +const DATA_DIR = getArg("--data-dir") ?? path.join(ROOT, "..", "benchmark-data"); +const REVERSE = args.includes("--reverse"); +const years = REVERSE ? SHIFT : -SHIFT; + +console.log(`=== Shift dates: ${years > 0 ? "+" : ""}${years} years ===`); +console.log(`Data dir: ${DATA_DIR}\n`); + +function sy(y) { return String(parseInt(y, 10) + years); } +function shiftYearInStr(s) { return s.replace(/(^|[\\/])(\d{4})([\\/])/g, (_, a, y, b) => a + sy(y) + b); } +function shiftDateISO(iso) { return iso.replace(/(\d{4})(-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)/g, (_, y, r) => sy(y) + r); } + +// ─── Phase 1: Copy all files ────────────────────────────────────────────── + +const personas = fs.readdirSync(DATA_DIR) + .filter(e => e.startsWith("personal_") && fs.statSync(path.join(DATA_DIR, e)).isDirectory()) + .sort(); + +// Collect ALL file copy operations first, then execute. +// Also track which source years we read from, so we can safely delete only those. +const copies = []; // { oldPath, newPath, type } +const sourceYears = new Set(); + +for (const p of personas) { + const base = path.join(DATA_DIR, p); + const slicesDir = path.join(base, "episodic", "slices"); + const strandsPath = path.join(base, "episodic", "strands.json"); + const profilePath = path.join(base, "user", "profile.md"); + + // Walk slices directory — only collect, don't modify yet + function walk(dirPath) { + if (!fs.existsSync(dirPath)) return; + for (const e of fs.readdirSync(dirPath, { withFileTypes: true })) { + const fp = path.join(dirPath, e.name); + if (e.isDirectory()) { walk(fp); continue; } + const rel = path.relative(slicesDir, fp); + // Track source year (first segment of rel path) + const srcYear = rel.split(/[\\/]/)[0]; + if (/^\d{4}$/.test(srcYear)) sourceYears.add(srcYear); + const newRel = shiftYearInStr(rel); + const newFp = path.join(slicesDir, newRel); + const type = e.name === "_index.json" ? "index" : "slice"; + copies.push({ oldPath: fp, newPath: newFp, type }); + } + } + walk(slicesDir); + + // Strands + if (fs.existsSync(strandsPath)) { + copies.push({ oldPath: strandsPath, newPath: strandsPath, type: "strands" }); + } + // Profile + if (fs.existsSync(profilePath)) { + copies.push({ oldPath: profilePath, newPath: profilePath, type: "profile" }); + } +} + +console.log(`Files to process: ${copies.length}`); + +// ─── Phase 2: Copy + shift content (no deletion yet) ────────────────────── + +let slices = 0, indexes = 0, strandsFiles = 0, profiles = 0; + +for (const { oldPath, newPath, type } of copies) { + // Ensure directory exists + const dir = path.dirname(newPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + let raw = fs.readFileSync(oldPath, "utf-8"); + + if (type === "index") { + const idx = JSON.parse(raw); + if (idx.month) idx.month = idx.month.replace(/^(\d{4})/, (_, y) => sy(y)); + for (const s of idx.slices ?? []) { + if (s.id) s.id = s.id.replace(/^(\d{4})/, (_, y) => sy(y)); + if (s.start) s.start = shiftDateISO(s.start); + } + fs.writeFileSync(newPath, JSON.stringify(idx, null, 2), "utf-8"); + indexes++; + continue; + } + + if (type === "strands") { + const strands = JSON.parse(raw); + for (const [tag, paths] of Object.entries(strands)) { + strands[tag] = paths.map(p => shiftYearInStr(p)); + } + fs.writeFileSync(newPath, JSON.stringify(strands, null, 2), "utf-8"); + strandsFiles++; + continue; + } + + if (type === "profile") { + raw = raw.replace(/(\d{4})(-\d{2}-\d{2})/g, (_, y, r) => sy(y) + r); + fs.writeFileSync(newPath, raw, "utf-8"); + profiles++; + continue; + } + + // Type: slice — YAML frontmatter + Markdown turns + const fmEnd = raw.indexOf("---", 3); + if (fmEnd === -1) continue; + + let fm = raw.slice(0, fmEnd + 3); + let body = raw.slice(fmEnd + 3); + + fm = fm.replace(/^slice_id:\s*(\d{4})(-\d{2}-\d{2}-\d{4})$/gm, (_, y, r) => + `slice_id: ${sy(y)}${r}` + ); + fm = fm.replace(/^(start|end):\s*"(\d{4})(-\d{2}-\d{2}[^"]*)"/gm, (_, k, y, r) => + `${k}: "${sy(y)}${r}"` + ); + body = body.replace( + /(\d{4})(-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)/g, + (_, y, r) => sy(y) + r + ); + + fs.writeFileSync(newPath, fm + body, "utf-8"); + slices++; +} + +console.log(`Slices: ${slices} Indexes: ${indexes} Strands: ${strandsFiles} Profiles: ${profiles}`); + +// ─── Phase 3: Remove source-year directories ────────────────────────────── +// We tracked every source year seen during Phase 1. After Phase 2 copied them +// to shifted locations, delete ONLY those exact source years. + +console.log("Source years found:", [...sourceYears].sort().join(", ")); + +for (const p of personas) { + const slicesDir = path.join(DATA_DIR, p, "episodic", "slices"); + for (const yr of sourceYears) { + const srcDir = path.join(slicesDir, yr); + if (fs.existsSync(srcDir)) { + fs.rmSync(srcDir, { recursive: true, force: true }); + } + } +} + +console.log("Source year directories removed."); + +// ─── Phase 4: Update manifest.json ──────────────────────────────────────── + +const manifestPath = path.join(DATA_DIR, "manifest.json"); +if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + for (const [, p] of Object.entries(manifest.personas)) { + if (p.dateRange) p.dateRange = p.dateRange.map(d => d.replace(/^(\d{4})/, (_, y) => sy(y))); + function shiftTree(n) { + if (!n || typeof n !== "object") return; + for (const k of Object.keys(n)) { + if (/^\d{4}$/.test(k)) { + const sk = sy(k); + if (sk !== k) { n[sk] = n[k]; delete n[k]; } + shiftTree(n[sk]); + } else { shiftTree(n[k]); } + } + } + if (p.tree) shiftTree(p.tree); + } + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"); + console.log("manifest.json updated."); +} + +console.log("\n=== Done ==="); +console.log(`All dates shifted by ${years} years.`); +if (!REVERSE) console.log("To restore: node scripts/shift-all-dates.mjs --reverse"); diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 9be38b4..c570866 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -2,8 +2,8 @@ import { NextIntlClientProvider, hasLocale } from "next-intl"; import { getMessages, setRequestLocale } from "next-intl/server"; import { notFound } from "next/navigation"; import { routing } from "@/i18n/routing"; -import { DemoBanner } from "@/components/demo-banner"; import { AppHeader } from "@/components/layout/app-header"; +import { resolveDataSource } from "@/lib/data-source/resolve"; type Props = { children: React.ReactNode; @@ -23,12 +23,11 @@ export default async function LocaleLayout({ children, params }: Props) { setRequestLocale(locale); const messages = await getMessages(); - const isDemo = process.env.DEMO_MODE === "true"; + const isDemo = resolveDataSource() === "demo"; return ( - {isDemo && } - + {children} ); diff --git a/src/app/[locale]/loading.tsx b/src/app/[locale]/loading.tsx deleted file mode 100644 index 5413c92..0000000 --- a/src/app/[locale]/loading.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
- - -
- - - -
- -
- - - -
-
- ); -} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index a3e6b57..9bf836c 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,29 +1,45 @@ +import { Suspense } from "react"; import { setRequestLocale } from "next-intl/server"; -import { getEpisodicState } from "@/lib/episodic/actions"; +import { setDemoPersona } from "@/lib/demo/demo-fs"; +import { resolveDataSource } from "@/lib/data-source/resolve"; import { ChatPage } from "@/components/chat/chat-page"; import { HeroSection } from "@/components/chat/hero-section"; -import { TimelinePanel } from "@/components/chat/timeline-panel"; +import { TimelineSection } from "@/components/chat/timeline-section"; +import { TimelineSkeleton } from "@/components/chat/timeline-skeleton"; + +type SearchParams = Promise<{ persona?: string }>; export default async function HomePage({ params, + searchParams, }: { params: Promise<{ locale: string }>; + searchParams: SearchParams; }) { const { locale } = await params; setRequestLocale(locale); - const episodicData = await getEpisodicState(); + const { persona } = await searchParams; + if (resolveDataSource() === "demo") { + setDemoPersona(persona || "personal_14"); + } return ( - - + {/* Static title — never flashes, always visible */} +
+
+ Previously on +
+ }> +
+ +
+
+
+ }> + +
); } diff --git a/src/app/[locale]/settings/page.tsx b/src/app/[locale]/settings/page.tsx index e833685..b5522c0 100644 --- a/src/app/[locale]/settings/page.tsx +++ b/src/app/[locale]/settings/page.tsx @@ -2,6 +2,7 @@ import { getTranslations, setRequestLocale } from "next-intl/server"; import { SettingsForm } from "@/components/settings/settings-form"; import { loadUserProfile } from "@/lib/identity"; import { loadUserConfig } from "@/lib/config/loader"; +import { resolveDataSource, isWritable } from "@/lib/data-source/resolve"; export default async function SettingsPage({ params, @@ -13,6 +14,8 @@ export default async function SettingsPage({ const t = await getTranslations("settings"); const profile = await loadUserProfile(); const config = await loadUserConfig(); + const source = resolveDataSource(); + const canWrite = isWritable(source); return (
@@ -20,7 +23,12 @@ export default async function SettingsPage({

{t("pageSubtitle")}

- +
); } diff --git a/src/app/api/agent/tool-executors.ts b/src/app/api/agent/tool-executors.ts index d5813b6..061dd32 100644 --- a/src/app/api/agent/tool-executors.ts +++ b/src/app/api/agent/tool-executors.ts @@ -24,6 +24,11 @@ import { listFilesLocal, writeFileLocal, } from "@/lib/tools/local-fs"; +import { + readFileDemo, + listFilesDemo, + writeFileDemo, +} from "@/lib/demo/demo-fs"; import { isPathAllowed, isProtectedSystemPath } from "@/lib/whitelist"; import { applyProfilePatch } from "@/lib/identity/profile-writer"; import { searchViaFlash, type WebSearchResult } from "@/lib/search/flash-search"; @@ -44,6 +49,8 @@ export interface ToolContext { owner: string; /** Whether GitHub token is configured. Off → local filesystem. */ useGithub: boolean; + /** Whether demo mode is active (remote benchmark data, read-only). */ + useDemo: boolean; /** The current time-slice id (for startLoop to record the link). */ sliceId: string; } @@ -95,6 +102,7 @@ export async function readMemoryExecute( ): Promise { "use step"; try { + if (ctx.useDemo) return await readFileDemo(path); return ctx.useGithub ? await readFile(path, ctx.repo, ctx.owner) : await readFileLocal(path); @@ -114,6 +122,7 @@ export async function listMemoryExecute( ): Promise | { error: string }> { "use step"; try { + if (ctx.useDemo) return await listFilesDemo(path); return ctx.useGithub ? await listFiles(path, ctx.repo, ctx.owner) : await listFilesLocal(path); @@ -135,9 +144,11 @@ export async function readIndexExecute( const mm = String(month).padStart(2, "0"); const path = `memory/episodic/slices/${year}/${mm}/_index.json`; try { - const raw = ctx.useGithub - ? await readFile(path, ctx.repo, ctx.owner) - : await readFileLocal(path); + const raw = ctx.useDemo + ? await readFileDemo(path) + : ctx.useGithub + ? await readFile(path, ctx.repo, ctx.owner) + : await readFileLocal(path); return JSON.parse(raw); } catch { return { exists: false, month: `${year}-${mm}`, slices: [] }; @@ -156,9 +167,11 @@ export async function writeMemoryExecute( }; } try { - const res = ctx.useGithub - ? await writeFile(path, content, ctx.repo, ctx.owner, `[agent] ${reason}`) - : await writeFileLocal(path, content); + const res = ctx.useDemo + ? await writeFileDemo(path, content) + : ctx.useGithub + ? await writeFile(path, content, ctx.repo, ctx.owner, `[agent] ${reason}`) + : await writeFileLocal(path, content); return { ok: true, path: res.path, created: res.created }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "write failed" }; diff --git a/src/app/api/agent/tools.ts b/src/app/api/agent/tools.ts index 21e885a..0e1a0db 100644 --- a/src/app/api/agent/tools.ts +++ b/src/app/api/agent/tools.ts @@ -33,6 +33,7 @@ const toolContextSchema = z.object({ repo: z.string(), owner: z.string(), useGithub: z.boolean(), + useDemo: z.boolean(), sliceId: z.string(), }); diff --git a/src/app/api/chat/steps.ts b/src/app/api/chat/steps.ts index 2da3fbf..f5345f8 100644 --- a/src/app/api/chat/steps.ts +++ b/src/app/api/chat/steps.ts @@ -57,7 +57,11 @@ import type { TurnOutcome, } from "@/lib/chat/turn-types"; -const USE_GITHUB = !!process.env.GITHUB_TOKEN; +import { resolveDataSource } from "@/lib/data-source/resolve"; + +const DATA_SOURCE = resolveDataSource(); +const USE_GITHUB = DATA_SOURCE === "github"; +const USE_DEMO = DATA_SOURCE === "demo"; // ─── Context assembly helpers (moved verbatim from the inline route) ───── @@ -450,6 +454,7 @@ export async function prepareGenerate( repo, owner, useGithub: USE_GITHUB, + useDemo: USE_DEMO, sliceId: slice.slice_id, }, }; diff --git a/src/app/api/loops/steps.ts b/src/app/api/loops/steps.ts index e8939f5..5c790f9 100644 --- a/src/app/api/loops/steps.ts +++ b/src/app/api/loops/steps.ts @@ -29,7 +29,11 @@ import type { import { readLoopRun, serializeLoop, writeLoopFile } from "@/lib/loops/store"; import type { ToolContext } from "@/app/api/agent/tool-executors"; -const USE_GITHUB = !!process.env.GITHUB_TOKEN; +import { resolveDataSource } from "@/lib/data-source/resolve"; + +const DATA_SOURCE = resolveDataSource(); +const USE_GITHUB = DATA_SOURCE === "github"; +const USE_DEMO = DATA_SOURCE === "demo"; function getRepoConfig(): { owner: string; repo: string } { const owner = process.env.GITHUB_REPO_OWNER ?? "local"; @@ -117,6 +121,7 @@ export async function initLoop( repo, owner, useGithub: USE_GITHUB, + useDemo: USE_DEMO, sliceId: input.sliceOrigin ?? "", }, }; diff --git a/src/app/globals.css b/src/app/globals.css index 963a0de..7573fdb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,6 +3,7 @@ @import "shadcn/tailwind.css"; @custom-variant dark (&:is(.dark *)); +@custom-variant hover (&:hover); @theme inline { --color-background: var(--background); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5d3e2b9..0a8bb29 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -32,7 +32,7 @@ export default async function RootLayout({ disableTransitionOnChange > {children} - + diff --git a/src/components/chat/chat-page.tsx b/src/components/chat/chat-page.tsx index 2795188..e2c9d98 100644 --- a/src/components/chat/chat-page.tsx +++ b/src/components/chat/chat-page.tsx @@ -54,16 +54,16 @@ function Inner({ children }: { children: React.ReactNode }) { const [lastUserMessageAt, setLastUserMessageAt] = useState(null); const { snapshot } = useLoadedIds(); - // A run left mid-stream by a previous mount (tab closed during a response)? - // Its id was persisted on send; resume it once on mount. Read only at mount - // so a completion during this session doesn't retrigger resume. - const initialActiveRunId = useMemo(() => { - if (typeof window === "undefined") return undefined; - return localStorage.getItem(ACTIVE_RUN_KEY) ?? undefined; - }, []); + // FIXME(#localStorage-resume): disabled — stale run ids from previous + // mounts were causing the chat to get stuck in streaming state (red stop + // button). Re-enable once the resume path is cleaned up. + // const initialActiveRunId = useMemo(() => { + // if (typeof window === "undefined") return undefined; + // return localStorage.getItem(ACTIVE_RUN_KEY) ?? undefined; + // }, []); const { messages, sendMessage, status, stop, error } = useChat({ - resume: !!initialActiveRunId, + resume: false, // was: !!initialActiveRunId // Every turn runs inside a durable Workflow run. WorkflowChatTransport reads // the x-workflow-run-id header, auto-reconnects on same-session drops, and // resumes post-reload via /api/chat/{runId}/stream. Created inline (like the @@ -85,27 +85,10 @@ function Inner({ children }: { children: React.ReactNode }) { loadedSliceIds: snapshot(), }, }), - onChatSendMessage: (response) => { - const runId = response.headers.get("x-workflow-run-id"); - if (runId && typeof window !== "undefined") { - localStorage.setItem(ACTIVE_RUN_KEY, runId); - } - }, - onChatEnd: () => { - if (typeof window !== "undefined") { - localStorage.removeItem(ACTIVE_RUN_KEY); - } - }, - prepareReconnectToStreamRequest: (config) => { - const runId = - typeof window !== "undefined" - ? localStorage.getItem(ACTIVE_RUN_KEY) - : null; - return { - ...config, - api: runId ? `/api/chat/${encodeURIComponent(runId)}/stream` : config.api, - }; - }, + // FIXME(#localStorage-resume): localStorage writes disabled — see above. + onChatSendMessage: (_response) => {}, + onChatEnd: () => {}, + prepareReconnectToStreamRequest: (config) => config, }), }); diff --git a/src/components/chat/hero-section.tsx b/src/components/chat/hero-section.tsx index 5be9f03..e4c43d0 100644 --- a/src/components/chat/hero-section.tsx +++ b/src/components/chat/hero-section.tsx @@ -1,8 +1,28 @@ import { getUserName } from "@/lib/identity"; +import { getDemoPersona, listDemoPersonas } from "@/lib/demo/demo-fs"; +import { resolveDataSource } from "@/lib/data-source/resolve"; import { HeroText } from "./hero-text"; +import { PersonaDialogWrapper } from "@/components/persona/persona-dialog-wrapper"; -export async function HeroSection() { - // Name comes from memory/user/profile.md (loaded live); falls back to "You". +export async function HeroSection({ personaId }: { personaId?: string }) { + const source = resolveDataSource(); + + if (source === "demo") { + const currentId = personaId || getDemoPersona(); + const personas = await listDemoPersonas().catch(() => []); + const current = personas.find((p) => p.id === currentId); + const name = current?.name ?? currentId; + + return ( + + ); + } + + // Normal mode — user's own name const name = await getUserName(); return ; } diff --git a/src/components/chat/hero-text.tsx b/src/components/chat/hero-text.tsx index 9cee3ca..117b50f 100644 --- a/src/components/chat/hero-text.tsx +++ b/src/components/chat/hero-text.tsx @@ -1,29 +1,51 @@ "use client"; +import { ArrowLeftRight } from "lucide-react"; import { TextGenerateEffect } from "@/components/ui/text-generate-effect"; -export function HeroText({ name }: { name: string }) { +export function HeroText({ + name, + onNameClick, + clickable = false, +}: { + name: string; + onNameClick?: () => void; + clickable?: boolean; +}) { return ( -
- {/* 0.3s stillness → "Previously" → 0.25s beat → "on" */} - - {/* Title settles → name announced word by word, deliberate pace */} -
- -
- -
+ <> + {clickable ? ( +
+ + + +
+ ) : ( +
+ +
+ )} + ); } diff --git a/src/components/chat/timeline-section.tsx b/src/components/chat/timeline-section.tsx new file mode 100644 index 0000000..c05ad34 --- /dev/null +++ b/src/components/chat/timeline-section.tsx @@ -0,0 +1,16 @@ +import { getEpisodicState } from "@/lib/episodic/actions"; +import { TimelinePanel } from "./timeline-panel"; + +export async function TimelineSection({ personaId }: { personaId?: string }) { + const episodicData = await getEpisodicState(); + + return ( + + ); +} diff --git a/src/components/chat/timeline-skeleton.tsx b/src/components/chat/timeline-skeleton.tsx new file mode 100644 index 0000000..2f57906 --- /dev/null +++ b/src/components/chat/timeline-skeleton.tsx @@ -0,0 +1,13 @@ +export function TimelineSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/components/demo-banner.tsx b/src/components/demo-banner.tsx deleted file mode 100644 index 06a380f..0000000 --- a/src/components/demo-banner.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { Info } from "lucide-react"; - -/** - * Persistent top banner shown only when DEMO_MODE is on. The public demo has no - * auth and is strictly read-only, so this tells everyone plainly that nothing - * they write is saved. - */ -export function DemoBanner() { - const t = useTranslations("demo"); - return ( -
- - {t("banner")} -
- ); -} diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx index 045bc3a..cc26a46 100644 --- a/src/components/layout/app-header.tsx +++ b/src/components/layout/app-header.tsx @@ -5,8 +5,9 @@ import { BookOpen, Settings } from "lucide-react"; import { ThemeToggle } from "@/components/chat/theme-toggle"; import { LocaleToggle } from "@/components/chat/locale-toggle"; import { VersionBadge } from "@/components/layout/version-badge"; +import { DemoBadge } from "@/components/layout/demo-badge"; -export function AppHeader() { +export function AppHeader({ isDemo = false }: { isDemo?: boolean }) { return (