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}
-
+