From 35131420bf6c254ed97d10d3d2aafbfde95a4f83 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Sat, 17 Oct 2026 20:11:42 +0800
Subject: [PATCH 1/2] feat(site): read native Explore logs in Time Machine
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../site/scripts/time-machine.test.mjs | 81 ++++++++++
.../site/src/time-machine/LocalLogReader.tsx | 148 ++++++++++++++++++
.../site/src/time-machine/TimeMachine.tsx | 31 +++-
.../site/src/time-machine/local-log.ts | 73 +++++++++
.../site/src/time-machine/time-machine.css | 54 +++++++
apps/presentation/site/tsconfig.json | 1 +
.../dashboard-frontstage-browser-smoke.mjs | 88 +++++++++++
7 files changed, 472 insertions(+), 4 deletions(-)
create mode 100644 apps/presentation/site/src/time-machine/LocalLogReader.tsx
create mode 100644 apps/presentation/site/src/time-machine/local-log.ts
diff --git a/apps/presentation/site/scripts/time-machine.test.mjs b/apps/presentation/site/scripts/time-machine.test.mjs
index f3fabe662c..2a67c13366 100644
--- a/apps/presentation/site/scripts/time-machine.test.mjs
+++ b/apps/presentation/site/scripts/time-machine.test.mjs
@@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import {createHash} from "node:crypto";
+import {spawnSync} from "node:child_process";
import {readFile} from "node:fs/promises";
import test from "node:test";
import {readPinnedCapture} from "../src/time-machine/records.ts";
+import {LOCAL_LOG_MAX_BYTES, LOCAL_LOG_MAX_RECORDS, LocalLogError, readLocalExploreLog, selectLocalLogEntries} from "../src/time-machine/local-log.ts";
const scenes = JSON.parse(await readFile(new URL("../src/time-machine/scenes.json", import.meta.url)));
const buffer = bytes => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
@@ -62,3 +64,82 @@ test("a record reader preserves append order rather than inventing historical st
assert.deepEqual(rows, data.events);
assert.equal(rows[0].recorded_at, "2030-01-01T00:00:00Z");
});
+
+// Public synthetic v0 records: the oracle is the original event/append contract,
+// independent of the file adapter. Real native-builder bytes also enter the
+// packaged browser qualification; this fixture is not a research result.
+const localEvent = (extra = {}) => ({schema_version: "loopx_explore_result_event_v0", goal_id: "local-reader-example",
+ event_id: "question-open", result_id: "question", event_kind: "node", node_kind: "question", status: "open",
+ title: "合成问题 / Synthetic question", summary: "A question, not an experiment count.", recorded_at: "2026-01-01T00:00:00Z", ...extra});
+const localRows = () => [localEvent(),
+ localEvent({event_id: "early-negative", result_id: "finding", event_kind: "finding", node_id: "question", status: "refuted", title: "Counterevidence",
+ summary: "原始反例 literal source text", evidence_refs: ["artifact:input-v1"], recorded_at: "2026-01-01T04:00:00Z"}),
+ localEvent({event_id: "late-revision", result_id: "finding", event_kind: "finding", node_id: "question", status: "tentative", title: "Later revision",
+ summary: "The later input is still uncertain.", evidence_refs: ["artifact:input-v2"], recorded_at: "2026-01-01T18:00:00Z"}),
+ localEvent({event_id: "backfill", status: "exploring", recorded_at: "2026-01-01T03:00:00Z"}),
+ localEvent({event_id: "unattached", event_kind: "finding", result_id: "unattached", node_id: "missing", title: "Unattached finding", status: "refuted", recorded_at: "unknown"}),
+];
+const encoded = text => new TextEncoder().encode(text).buffer;
+const jsonl = rows => encoded(rows.map(row => JSON.stringify(row)).join("\n"));
+const issue = (code, line = null) => error => error instanceof LocalLogError && error.issue === code && error.line === line;
+
+test("local native-shaped JSONL retains the entire span, append corrections, negatives and unattached records", () => {
+ const rows = localRows();
+ rows[1].source_extra = {note: "Uninterpreted metadata remains inspectable"};
+ const log = readLocalExploreLog(encoded(`\n${rows.map(row => JSON.stringify(row)).join("\r\n\n")}\n`));
+ assert.deepEqual(log.entries.map(entry => entry.source), rows);
+ assert.deepEqual(log.entries.map(entry => entry.line), [2, 4, 6, 8, 10]);
+ assert.deepEqual(log.entries.map(entry => entry.event.summary), rows.map(row => row.summary));
+ assert.equal(log.timeline.end - log.timeline.start, 18 * 3_600_000);
+ assert.equal(log.history.nodes[0].latest.status, "exploring", "append order owns the latest node label");
+ assert.equal(log.history.nodes[0].revisions.some(row => row.status === "refuted"), true);
+ assert.equal(log.history.findings.some(row => row.result_id === "unattached"), true);
+ assert.equal(log.history.unknownTimeCount, 1);
+ assert.equal(log.history.edges.length, 0, "record-time adjacency is not a relationship");
+});
+
+test("local search and time/label filters preserve source order, exact labels and original text", () => {
+ const log = readLocalExploreLog(jsonl(localRows()));
+ assert.deepEqual(selectLocalLogEntries(log, " ARTIFACT:INPUT ", null, null).map(row => row.line), [2, 3]);
+ assert.equal(selectLocalLogEntries(log, "原始反例", "refuted", null)[0].event.summary, "原始反例 literal source text");
+ assert.deepEqual(selectLocalLogEntries(log, "", "refuted", null).map(row => row.line), [2, 5]);
+ assert.deepEqual(selectLocalLogEntries(log, "", null, 0).map(row => row.line), [1, 2, 4]);
+ assert.deepEqual(selectLocalLogEntries(log, "", null, 2).map(row => row.line), [3]);
+ assert.deepEqual(selectLocalLogEntries(log, "", null, "unknown").map(row => row.line), [5]);
+ assert.equal(selectLocalLogEntries(log, "absent text", null, null).length, 0);
+ const futureLabel = readLocalExploreLog(jsonl([localEvent({status: "all"})]));
+ assert.equal(selectLocalLogEntries(futureLabel, "", "all", null).length, 1, "a source label is data, not a reserved UI option");
+});
+
+test("bad local files are rejected as a whole, with a line when the fault belongs to one record", () => {
+ assert.throws(() => readLocalExploreLog(encoded("\n \r\n")), issue("empty"));
+ assert.throws(() => readLocalExploreLog(new Uint8Array([0xff]).buffer), issue("encoding"));
+ assert.throws(() => readLocalExploreLog(encoded(`${JSON.stringify(localEvent())}\n{broken`)), issue("json", 2));
+ for (const value of [null, [], 1, {}, localEvent({goal_id: ""}), localEvent({schema_version: "not-v0"}), localEvent({confidence: 2}), localEvent({evidence_refs: [123]})]) {
+ assert.throws(() => readLocalExploreLog(jsonl([value])), issue("record", 1));
+ }
+ assert.throws(() => readLocalExploreLog(jsonl([localEvent(), localEvent({goal_id: "another-goal"})])), issue("record", 2));
+ assert.throws(() => readLocalExploreLog(jsonl([localEvent(), localEvent({summary: "Conflicting event identity"})])), issue("identity"));
+ const repeated = readLocalExploreLog(jsonl([localEvent(), localEvent()]));
+ assert.equal(repeated.entries.length, 2, "identical source rows remain readable");
+ assert.equal(repeated.history.events.length, 1, "the existing common owner deduplicates only its display projection");
+});
+
+test("local browser bounds admit the full supported log and reject overflow rather than returning a prefix", () => {
+ assert.throws(() => readLocalExploreLog(new ArrayBuffer(LOCAL_LOG_MAX_BYTES + 1)), issue("size"));
+ const rows = Array.from({length: LOCAL_LOG_MAX_RECORDS}, (_, index) => localEvent({event_id: `event-${index}`, result_id: `node-${index}`}));
+ assert.equal(readLocalExploreLog(jsonl(rows)).entries.length, LOCAL_LOG_MAX_RECORDS);
+ assert.throws(() => readLocalExploreLog(jsonl([...rows, localEvent({event_id: "overflow"})])), issue("size"));
+});
+
+test("a byte-limit file of blank lines reports empty without a line-array memory explosion", () => {
+ const source = JSON.stringify(new URL("../src/time-machine/local-log.ts", import.meta.url).href);
+ const code = `import {readLocalExploreLog, LOCAL_LOG_MAX_BYTES, LocalLogError} from ${source};
+ const bytes = new Uint8Array(LOCAL_LOG_MAX_BYTES).fill(10);
+ try { readLocalExploreLog(bytes.buffer); process.exit(2); }
+ catch(error) { if (!(error instanceof LocalLogError) || error.issue !== "empty") throw error; }`;
+ // The child protects the test host; this is an intake regression, not a
+ // general browser-memory or latency promise for all supported records.
+ const result = spawnSync(process.execPath, ["--max-old-space-size=96", "--input-type=module", "-e", code], {encoding: "utf8", timeout: 5000});
+ assert.equal(result.status, 0, result.stderr || String(result.error ?? result.signal));
+});
diff --git a/apps/presentation/site/src/time-machine/LocalLogReader.tsx b/apps/presentation/site/src/time-machine/LocalLogReader.tsx
new file mode 100644
index 0000000000..0ada2916f6
--- /dev/null
+++ b/apps/presentation/site/src/time-machine/LocalLogReader.tsx
@@ -0,0 +1,148 @@
+import {useEffect, useMemo, useRef, useState} from "react";
+import {ArrowLeft, ArrowRight, FileText, FolderOpen, X} from "lucide-react";
+import {LocalLogError, LOCAL_LOG_MAX_BYTES, readLocalExploreLog, selectLocalLogEntries, type LocalExploreLog, type LocalLogIssue} from "./local-log";
+
+const copy = {
+ en: {
+ title: "Your research record",
+ privacy: "Local reading · No file upload or storage.",
+ choose: "Choose another file", close: "Back to the three episodes", reading: "Reading your file…",
+ span: "Recorded between", unknown: "Unknown record time", unknownEvents: "distinct events have unknown record times.",
+ search: "Find in the original record", placeholder: "A question, a phrase, an evidence reference…",
+ status: "Source label", allLabels: "All labels", emptyLabel: "Unrecorded label", time: "Record time", allTime: "Whole recording",
+ phases: ["First third", "Middle third", "Last third"], match: "Matching records", of: "of", line: "Source line",
+ previous: "Previous record", next: "Next record", previousPage: "Previous 50", nextPage: "Next 50", page: "Page",
+ noMatch: "No matching record.", reset: "Clear filters", original: "Original text · untranslated", structured: "Source sections · untranslated",
+ observation: "Source observation", applicability: "Tested scope · source", interpretation: "Source interpretation", fullOriginal: "Read the complete original text",
+ details: "Source details and reading limits", references: "Evidence references · text only", raw: "Original JSON record",
+ limits: "Explore JSONL, UTF-8, one Goal per file. Up to 20 MiB and 20,000 records; larger files are rejected in full.",
+ boundary: "Source labels are claims in your file, not independently verified findings. The time strip uses recorded_at, not execution time. Reading does not restore a past state or start an Agent.",
+ coverage: "Every source line remains readable, including identical repeated events. The time strip reuses Explore’s unique-event projection; unknown times and missing links remain unknown.",
+ failed: "No records have been loaded from this file.", retry: "Choose a file to try again.",
+ issues: {size: "This file exceeds the reading limit.", empty: "This file has no records.", encoding: "This file is not valid UTF-8.", json: "This line is not valid JSON.", record: "This is not a supported Explore record, or it belongs to another Goal.", identity: "One event ID has conflicting records.", read: "The browser could not read this file."},
+ },
+ zh: {
+ title: "你的研究记录",
+ privacy: "本地阅读 · 文件内容不上传、不保存。",
+ choose: "换一份文件", close: "回到三个案例", reading: "正在读取你的文件…",
+ span: "记录跨度", unknown: "记录时间未知", unknownEvents: "个事件的记录时间未知(按事件 ID 去重)。",
+ search: "在原记录里找一找", placeholder: "一个问题、一段话、一条证据引用…",
+ status: "来源标记", allLabels: "全部标记", emptyLabel: "未记录标记", time: "记录时间", allTime: "完整记录跨度",
+ phases: ["前段", "中段", "后段"], match: "匹配记录", of: "/", line: "来源行",
+ previous: "上一条记录", next: "下一条记录", previousPage: "前 50 条", nextPage: "后 50 条", page: "页",
+ noMatch: "没有找到匹配的记录。", reset: "清除筛选", original: "原文 · 未翻译", structured: "来源分段 · 未翻译",
+ observation: "来源中的观察", applicability: "测试范围 · 来源原文", interpretation: "来源中的解释", fullOriginal: "展开完整原文",
+ details: "来源详情与阅读范围", references: "证据引用 · 仅文本", raw: "原始 JSON 记录",
+ limits: "支持 UTF-8 Explore JSONL,每份文件一个 Goal。最多 20 MiB、20,000 条记录;超出时整份拒绝,不截取部分。",
+ boundary: "来源标记是文件中的声明,未在这里独立核验。时间条使用记录时间 recorded_at,不是执行耗时。阅读不会恢复历史状态或启动 Agent。",
+ coverage: "每条来源记录均可阅读,包括相同事件的重复行。时间条复用 Explore 的去重事件投影;未知时间和缺失关联仍保持未知。",
+ failed: "当前未加载这份文件中的任何记录。", retry: "重新选择文件即可重试。",
+ issues: {size: "这份文件超过了阅读上限。", empty: "这份文件没有记录。", encoding: "这份文件不是有效的 UTF-8 文本。", json: "这一行不是有效的 JSON。", record: "这不是支持的 Explore 记录,或记录来自另一个 Goal。", identity: "同一个事件 ID 出现了冲突的记录。", read: "浏览器无法读取这份文件。"},
+ },
+};
+type ReadState = {kind: "reading"} | {kind: "ready"; log: LocalExploreLog} | {kind: "error"; issue: LocalLogIssue; line: number | null};
+type Props = {file: File; language: "en" | "zh"; onChoose: () => void; onClose: () => void};
+
+export default function LocalLogReader({file, language, onChoose, onClose}: Props) {
+ const c = copy[language];
+ const [state, setState] = useState({kind: "reading"});
+ const title = useRef(null);
+ useEffect(() => {
+ let active = true;
+ title.current?.focus({preventScroll: true});
+ void (async () => {
+ if (file.size > LOCAL_LOG_MAX_BYTES) throw new LocalLogError("size");
+ const bytes = await file.arrayBuffer();
+ if (!active) return;
+ const log = readLocalExploreLog(bytes);
+ if (active) setState({kind: "ready", log});
+ })().catch(error => {
+ if (active) setState({kind: "error", issue: error instanceof LocalLogError ? error.issue : "read", line: error instanceof LocalLogError ? error.line : null});
+ });
+ return () => {active = false;};
+ }, [file]);
+
+ return
+
+
{file.name}
+
+
+
+ {c.privacy}
+ {state.kind === "reading" ? {c.reading}
: state.kind === "error" ?
+ {c.issues[state.issue]} {state.line !== null && {c.line} {state.line}.}
+
{c.failed} {c.retry}
{c.limits}
:
+ }
+ ;
+}
+
+function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh"}) {
+ const c = copy[language];
+ const [query, setQuery] = useState("");
+ const [status, setStatus] = useState(null);
+ const [phase, setPhase] = useState(null);
+ const [selectedLine, setSelectedLine] = useState(null);
+ const original = useRef(null);
+ const search = useRef(null);
+ const entries = useMemo(() => selectLocalLogEntries(log, query, status, phase), [log, query, status, phase]);
+ const labels = useMemo(() => [...new Set(log.entries.map(entry => entry.event.status))], [log]);
+ const selected = entries.find(entry => entry.line === selectedLine) ?? entries[0];
+ const position = selected ? entries.indexOf(selected) : -1;
+ const page = Math.max(0, Math.floor(position / 50));
+ const pages = Math.ceil(entries.length / 50);
+ const {timeline, history} = log;
+ // Reuse the existing codec for Explore-authored section labels only. Other
+ // summaries stay raw; the complete source text is always available unchanged.
+ const sections = selected ? timeline.findings.find(item => item.event.event_id === selected.event.event_id)?.sections : null;
+ const maxBin = Math.max(1, ...timeline.bins.map(bin => bin.events));
+ const date = (value: number | null) => value === null ? c.unknown : new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB",
+ {year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "UTC", timeZoneName: "short"}).format(value);
+ function move(index: number) {
+ setSelectedLine(entries[index]!.line);
+ window.requestAnimationFrame(() => original.current?.focus());
+ }
+ function reset() { setQuery(""); setStatus(null); setPhase(null); search.current?.focus(); }
+
+ return <>
+
+
{c.span}: {timeline.start === null ? c.unknown : <> — >}
+ {timeline.bins.length > 0 &&
}
+
+
+
+
+
+
+ {c.match}: {entries.length} {c.of} {log.entries.length}
+ {(query || status !== null || phase !== null) && }
+ {!selected ? :
+
+
+ {sections ? c.structured : c.original} · {selected.event.status || c.emptyLabel} · {selected.event.event_kind}
+ {selected.event.title}
+ {sections ? {c.observation}
{sections.observation}
+
{c.applicability}
{sections.applicability}
+
{c.interpretation}
{sections.interpretation}
+
{c.fullOriginal}
{selected.event.summary}
: {selected.event.summary}
}
+ {c.details}
+ - {c.line}
- {selected.line}
- Goal
- {log.goalId}
- Result
- {selected.event.result_id}
- Event
- {selected.event.event_id}
+ {selected.event.evidence_refs.length > 0 && <>{c.references}
{selected.event.evidence_refs.map((ref, index) => {ref} )}
>}
+ {c.boundary}
{c.coverage}
{c.limits}
+ {c.raw}
{JSON.stringify(selected.source, null, 2)}
+
+
+
{position + 1}/{entries.length}
+
+ {entries.slice(page * 50, (page + 1) * 50).map(entry => )}
+
{pages > 1 &&
+ {c.page} {page + 1}/{pages}
}
+
}
+ {c.boundary}{history.unknownTimeCount > 0 && <> {history.unknownTimeCount} {c.unknownEvents}>}
+ >;
+}
diff --git a/apps/presentation/site/src/time-machine/TimeMachine.tsx b/apps/presentation/site/src/time-machine/TimeMachine.tsx
index 69ec43e2d0..f9140c5721 100644
--- a/apps/presentation/site/src/time-machine/TimeMachine.tsx
+++ b/apps/presentation/site/src/time-machine/TimeMachine.tsx
@@ -1,8 +1,9 @@
import {useEffect, useRef, useState} from "react";
-import {ArrowLeft, ArrowRight, ArrowUpRight} from "lucide-react";
+import {ArrowLeft, ArrowRight, ArrowUpRight, FolderOpen} from "lucide-react";
import {usePublicPageNavigation} from "../usePublicPageNavigation";
import {captureRevision, datasetUrl, readPinnedCapture, type CapturedRecord} from "./records";
import scenes from "./scenes.json";
+import LocalLogReader from "./LocalLogReader";
const strings = {
en: {
@@ -20,6 +21,7 @@ const strings = {
sources: "Fixed public source", fixture: "This exact capture", totals: "76 + 134 + 105 = 315 Explore records. Record counts are not experiment counts.",
close: "Back to the question", noJs: "Enable JavaScript to make choices and browse records. The fixed public archive is also available directly.",
lesson: "What stays with the next decision?",
+ openLog: "Open your research log", localStage: "Your research record",
},
zh: {
back: "LoopX 首页", name: "时光机",
@@ -33,6 +35,7 @@ const strings = {
cursor: "原始记录序号", record: "记录", previous: "上一条记录", following: "下一条记录", original: "原文 · 未翻译", identifiers: "来源标识",
sources: "固定版本公开来源", fixture: "这份原始记录", totals: "76 + 134 + 105 = 315 条 Explore 记录,记录数量不是实验次数。",
close: "回到问题", noJs: "启用 JavaScript 后可做选择、浏览原记录,也可以直接打开固定版本的公开来源。", lesson: "下一次判断,能留下什么?",
+ openLog: "打开你的研究日志", localStage: "你的研究记录",
},
};
const readCase = () => {
@@ -48,6 +51,11 @@ export default function TimeMachine() {
const [failedFor, setFailed] = useState(null);
const [attempt, setAttempt] = useState(0);
const [position, setPosition] = useState(0);
+ const [upload, setUpload] = useState<{file: File; version: number} | null>(null);
+ const fileInput = useRef(null);
+ const openLog = useRef(null);
+ const fileVersion = useRef(0);
+ const localReading = upload !== null;
const resultTitle = useRef(null);
const questionTitle = useRef(null);
const reader = useRef(null);
@@ -75,6 +83,7 @@ export default function TimeMachine() {
let active = true;
setLoaded(null); setFailed(null); setPosition(0);
if (reader.current) reader.current.open = false;
+ if (localReading) return () => {active = false; abort.abort();};
void (async () => {
const response = await fetch(fileUrl, {signal: abort.signal});
if (!response.ok) throw new Error(`Capture HTTP ${response.status}`);
@@ -82,7 +91,7 @@ export default function TimeMachine() {
if (active) setLoaded({id: scene.id, rows});
})().catch(() => {if (active) setFailed(scene.id);});
return () => {active = false; abort.abort();};
- }, [fileUrl, scene, attempt]);
+ }, [fileUrl, scene, attempt, localReading]);
useEffect(() => {
if (chosen && records && pendingFocus.current) {
@@ -116,7 +125,7 @@ export default function TimeMachine() {
}
return
- IF YOU WERE THERE
{c.title}
{c.deck}
+
{
+ const file = event.target.files?.[0];
+ event.target.value = ""; // Selecting the same file again is a real retry.
+ if (!file) return; // Cancelling the chooser leaves the current reading intact.
+ fileVersion.current += 1;
+ setUpload({file, version: fileVersion.current});
+ window.scrollTo({top: 0, behavior: "instant"});
+ }}/>
+ {upload ?
fileInput.current?.click()} onClose={() => {
+ setUpload(null);
+ window.requestAnimationFrame(() => openLog.current?.focus());
+ }}/> : <>
+ IF YOU WERE THERE
{c.title}
{c.deck}
+
{c.count} {Object.keys(answers).length} / 3
;
}
diff --git a/apps/presentation/site/src/time-machine/local-log.ts b/apps/presentation/site/src/time-machine/local-log.ts
new file mode 100644
index 0000000000..14284f90fb
--- /dev/null
+++ b/apps/presentation/site/src/time-machine/local-log.ts
@@ -0,0 +1,73 @@
+/** Local-file adapter to Explore's existing display owners. This accepts records
+ * for reading; it does not verify their claims or grant curated scene authority. */
+import {parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent} from "../../../../../loopx/control_plane/presentation/explore_history.ts";
+import {buildExploreTimeline, exploreTimelinePhaseAt} from "../../../../../loopx/control_plane/presentation/explore_timeline.ts";
+
+// Bound synchronous browser parsing and the shared timeline's in-memory arrays.
+// Reject the entire file at the limit; never publish a silently shortened log.
+export const LOCAL_LOG_MAX_BYTES = 20 * 1024 * 1024;
+export const LOCAL_LOG_MAX_RECORDS = 20_000;
+// File-intake UI errors only; these do not extend Explore's event/status vocabulary.
+export type LocalLogIssue = "size" | "empty" | "encoding" | "json" | "record" | "identity" | "read";
+export class LocalLogError extends Error {
+ readonly issue: LocalLogIssue;
+ readonly line: number | null;
+ constructor(issue: LocalLogIssue, line: number | null = null) {
+ super(issue); this.issue = issue; this.line = line;
+ }
+}
+export type LocalLogEntry = {line: number; event: ExploreHistoryEvent; source: Record};
+export type LocalExploreLog = ReturnType;
+
+export function readLocalExploreLog(bytes: ArrayBuffer) {
+ if (bytes.byteLength > LOCAL_LOG_MAX_BYTES) throw new LocalLogError("size");
+ let text: string;
+ try { text = new TextDecoder("utf-8", {fatal: true}).decode(bytes); }
+ catch { throw new LocalLogError("encoding"); }
+ const entries: LocalLogEntry[] = [];
+ let goalId = "";
+ let offset = 0, lineNumber = 0;
+ // Blank lines are legal. Walk them without allocating an array containing
+ // millions of empty entries for a newline-only file within the byte limit.
+ while (offset <= text.length) {
+ const end = text.indexOf("\n", offset);
+ const line = text.slice(offset, end < 0 ? text.length : end);
+ offset = end < 0 ? text.length + 1 : end + 1;
+ lineNumber++;
+ if (!line.trim()) continue;
+ if (entries.length === LOCAL_LOG_MAX_RECORDS) throw new LocalLogError("size");
+ let source: Record;
+ try { source = JSON.parse(line); }
+ catch { throw new LocalLogError("json", lineNumber); }
+ if (!source || Array.isArray(source) || typeof source !== "object" ||
+ typeof source.goal_id !== "string" || !source.goal_id.trim()) throw new LocalLogError("record", lineNumber);
+ if (!goalId) goalId = source.goal_id;
+ let event: ExploreHistoryEvent;
+ try {
+ event = parseExploreHistoryPage({ok: true, goal_id: goalId, items: [source], total: 1, next_cursor: null}, goalId).items[0]!;
+ } catch { throw new LocalLogError("record", lineNumber); }
+ entries.push({line: lineNumber, event, source});
+ }
+ if (!entries.length) throw new LocalLogError("empty");
+ let history: ReturnType;
+ try { history = projectExploreHistory(entries.map(entry => entry.event), goalId); }
+ catch { throw new LocalLogError("identity"); }
+ const timeline = buildExploreTimeline(history.nodes, history.edges, history.events);
+ return {goalId, entries, history, timeline};
+}
+
+/** Filter source records without changing append order, labels or time semantics. */
+export function selectLocalLogEntries(log: LocalExploreLog, query: string, status: string | null, phase: number | "unknown" | null) {
+ const needle = query.trim().toLowerCase();
+ return log.entries.filter(entry => {
+ const event = entry.event;
+ if (status !== null && event.status !== status) return false;
+ if (phase !== null) {
+ const parsed = Date.parse(event.recorded_at);
+ const at = Number.isFinite(parsed) ? parsed : null;
+ if (phase === "unknown" ? at !== null : exploreTimelinePhaseAt(log.timeline.phases, at) !== phase) return false;
+ }
+ return !needle || [event.title, event.summary, event.result_id, event.event_id, ...event.evidence_refs]
+ .some(value => value.toLowerCase().includes(needle));
+ });
+}
diff --git a/apps/presentation/site/src/time-machine/time-machine.css b/apps/presentation/site/src/time-machine/time-machine.css
index d6bed02d9b..f743f3d116 100644
--- a/apps/presentation/site/src/time-machine/time-machine.css
+++ b/apps/presentation/site/src/time-machine/time-machine.css
@@ -65,6 +65,45 @@
.tm-takeaway a { color: var(--blue); display: inline-flex; gap: 8px; align-items: center; min-height: 44px; font-size: 14px; }
.tm-footer { display: flex; gap: 20px; justify-content: space-between; align-items: center; padding: 20px 0 0; border-top: 1px solid var(--border); color: var(--body); font-size: 12px; }
.tm-footer a { display: inline-flex; align-items: center; gap: 4px; min-height: 44px; text-decoration: underline; text-underline-offset: 3px; }
+.tm-open-log, .tm-log button { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; border: 0; background: none; color: var(--blue); font: inherit; font-size: 13px; text-align: left; cursor: pointer; padding: 8px 0; }
+.tm-open-log { margin-top: 12px; }
+.tm-log-format { font: 12px/1.5 var(--font-mono); color: var(--body); }
+.tm-log-heading { display: flex; justify-content: space-between; align-items: start; gap: 32px; }
+.tm-log h1 { font-size: 24px; font-weight: 500; letter-spacing: -.025em; line-height: 1.4; margin: 0; min-width: 0; }
+.tm-log h1:focus { outline: none; }
+.tm-log-file { display: flex; align-items: baseline; gap: 8px; overflow-wrap: anywhere; color: var(--ink); }
+.tm-log-file svg { flex-shrink: 0; }
+.tm-log-actions { display: flex; align-items: center; gap: 24px; flex-shrink: 0; }
+.tm-log-actions button:last-child { color: var(--body); }
+.tm-log-privacy { font-size: 12px; color: var(--body); margin: 20px 0 0; }
+.tm-log-overview { border-block: 1px solid var(--border); padding: 20px 0 0; margin-top: 20px; }
+.tm-log-overview>p { font-size: 13px; color: var(--body); margin: 0 0 8px; }
+.tm-log-strip { display: block; width: 100%; height: 40px; margin-top: 16px; fill: var(--body); opacity: .65; }
+.tm-log-filters { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding-top: 24px; }
+.tm-log-filters label { display: grid; gap: 8px; font-size: 12px; color: var(--body); min-width: 0; }
+.tm-log-filters :is(input, select) { font: inherit; font-size: 14px; width: 100%; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 10px 12px; }
+.tm-log-match { display: flex; flex-wrap: wrap; align-items: center; gap: 0 6px; font-size: 12px; color: var(--body); margin: 12px 0; min-height: 44px; }
+.tm-log-match button { margin-left: auto; }
+.tm-log-reading { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 28px; align-items: start; }
+.tm-log-index { min-width: 0; grid-column: 1; grid-row: 1; }
+.tm-log-index ol { list-style: none; padding: 0; margin: 0; max-height: 520px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
+.tm-log-index li { border-bottom: 1px solid var(--border); }
+.tm-log-index li button { display: grid; gap: 4px; width: 100%; color: var(--body); padding: 12px; border-left: 2px solid transparent; overflow-wrap: anywhere; }
+.tm-log-index li button[aria-current=true] { border-left-color: var(--ink); background: var(--soft); color: var(--ink); }
+.tm-log-row-meta { font: 12px/1.6 var(--font-mono); color: var(--body); }
+.tm-log-detail { min-width: 0; grid-column: 2; grid-row: 1; }
+.tm-log-detail h2 { font-size: 20px; line-height: 1.5; margin: 12px 0; scroll-margin-top: 24px; }
+.tm-log-finding>p { font-size: 14px; line-height: 1.8; color: var(--body); margin: 8px 0 20px; white-space: pre-wrap; }
+.tm-log-finding>p.tm-log-field-label { font-size: 12px; margin: 16px 0 4px; }
+.tm-log-finding>p.tm-log-observation { color: var(--ink); }
+.tm-log-source dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; font: 12px/1.7 var(--font-mono); }
+.tm-log-source dd { margin: 0; }
+.tm-log-source ul { padding-left: 20px; }
+.tm-log-source pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.8 var(--font-mono); }
+.tm-log-record-nav, .tm-log-pagination { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--body); font: 12px/1.5 var(--font-mono); }
+.tm-log-message { padding: 24px 0; font-size: 14px; color: var(--body); }
+.tm-log-error [role=alert] { color: var(--danger); }
+.tm-log-note { font-size: 12px; color: var(--body); border-top: 1px solid var(--border); padding-top: 16px; margin-top: 24px; }
@keyframes tm-appear { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .tm-page *, html:has(.tm-page) { animation: none; scroll-behavior: auto; } }
@media (max-width: 700px) {
@@ -86,4 +125,19 @@
.tm-boundary { flex-direction: column; gap: 12px; }
.tm-footer { flex-direction: column; align-items: start; gap: 4px; }
.tm-original { padding: 20px; }
+ .tm-log-heading { display: flex; gap: 12px; }
+ .tm-log-actions { flex-direction: row; gap: 0; }
+ .tm-log-actions button { min-width: 44px; justify-content: center; }
+ .tm-log-actions button>span { display: none; }
+ .tm-log-privacy { margin-top: 12px; }
+ .tm-log-overview { padding-top: 12px; margin-top: 16px; }
+ .tm-log-strip { height: 24px; margin-top: 12px; }
+ .tm-log-filters { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; padding-top: 16px; }
+ .tm-log-match { margin-block: 8px; }
+ .tm-log-filters>label:first-child { grid-column: 1 / -1; }
+ .tm-log-reading { grid-template-columns: minmax(0, 1fr); gap: 20px; }
+ .tm-log-index, .tm-log-detail { grid-column: auto; grid-row: auto; }
+ .tm-log h1 { font-size: 20px; }
+ .tm-log-index ol { max-height: 200px; }
+ .tm-log-overview .tm-log-span time { display: inline-block; }
}
diff --git a/apps/presentation/site/tsconfig.json b/apps/presentation/site/tsconfig.json
index 86df527c8a..cfe3b0de3d 100644
--- a/apps/presentation/site/tsconfig.json
+++ b/apps/presentation/site/tsconfig.json
@@ -12,6 +12,7 @@
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
diff --git a/examples/dashboard-frontstage-browser-smoke.mjs b/examples/dashboard-frontstage-browser-smoke.mjs
index dbdbc9384a..d36d47abe3 100644
--- a/examples/dashboard-frontstage-browser-smoke.mjs
+++ b/examples/dashboard-frontstage-browser-smoke.mjs
@@ -118,6 +118,94 @@ async function assertTimeMachineJourney(page, lang) {
await page.locator(".tm-choices button:not([disabled])").first().click();
await page.locator(".tm-revealed").waitFor();
assert.match(await page.locator(".tm-revealed").innerText(), /13\.84/);
+ await assertLocalTimeMachineLog(page, lang);
+}
+
+async function assertLocalTimeMachineLog(page, lang) {
+ const row = (extra = {}) => ({schema_version: "loopx_explore_result_event_v0", goal_id: "public-local-example",
+ event_id: "question", result_id: "question", event_kind: "node", node_kind: "question", status: "open",
+ title: "Synthetic question", summary: "Source text, not a scored result.", recorded_at: "2026-01-01T00:00:00Z", ...extra});
+ const negative = "Original counterevidence literal, not HTML";
+ const records = [row(), row({event_id: "early-negative", result_id: "finding", event_kind: "finding", node_id: "question", status: "refuted",
+ title: "Earlier counterevidence", summary: negative, evidence_refs: ["https://example.invalid/do-not-fetch-local-evidence"], recorded_at: "2026-01-01T04:00:00Z"}),
+ row({event_id: "revision", result_id: "finding", event_kind: "finding", node_id: "question", title: "Later tentative revision", status: "tentative", recorded_at: "2026-01-01T18:00:00Z"}),
+ row({event_id: "backfill", status: "exploring", recorded_at: "2026-01-01T03:00:00Z"}),
+ row({event_id: "unattached", result_id: "unattached", event_kind: "finding", node_id: "missing", title: "Unattached finding", status: "refuted", recorded_at: "unknown"})];
+ const file = (name, rows = records) => ({name, mimeType: "application/x-ndjson", buffer: Buffer.from(rows.map(row => JSON.stringify(row)).join("\n"))});
+ const input = page.locator("#tm-log-file");
+ await page.evaluate(() => {
+ window.__tmLocalAudit = {requests: [], writes: []};
+ const audit = window.__tmLocalAudit;
+ const fetch = window.fetch;
+ window.fetch = function (...args) { audit.requests.push(String(args[0])); return fetch.apply(this, args); };
+ const send = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.send = function (...args) { audit.requests.push("xhr"); return send.apply(this, args); };
+ const beacon = navigator.sendBeacon.bind(navigator);
+ navigator.sendBeacon = (...args) => { audit.requests.push(String(args[0])); return beacon(...args); };
+ const set = Storage.prototype.setItem;
+ Storage.prototype.setItem = function (...args) { audit.writes.push(String(args[0])); return set.apply(this, args); };
+ const open = indexedDB.open.bind(indexedDB);
+ indexedDB.open = (...args) => { audit.writes.push("indexedDB"); return open(...args); };
+ });
+ // The ordinary entry opens the actual browser file chooser, not a command-copy path.
+ const chooser = page.waitForEvent("filechooser");
+ await page.locator("#tm-open-log").focus();
+ await page.locator("#tm-open-log").press("Enter");
+ await (await chooser).setFiles(file("research.jsonl"));
+ await page.locator(".tm-log-reading").waitFor();
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "5");
+ assert.match(await page.locator(".tm-log-span").textContent(), /00:00.*18:00/);
+ assert.equal(await page.locator(".tm-revealed,.tm-metrics,.tm-source").count(), 0, "local bytes inherit no curated claim");
+ await page.locator("#tm-log-label").selectOption(JSON.stringify("refuted"));
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "2");
+ assert.equal(await page.locator(".tm-log-detail .tm-record-body").textContent(), negative);
+ assert.equal(await page.locator(".tm-record-body b").count(), 0);
+ await page.locator(".tm-log-source > summary").click();
+ assert.equal(await page.locator(".tm-log-source a").count(), 0, "external evidence references remain text");
+ await page.locator(".tm-log-source > summary").click();
+ await page.locator("#tm-log-search").fill("absent phrase");
+ assert.equal(await page.locator(".tm-log-reading").count(), 0, "no stale original when filters have no match");
+ await page.locator(".tm-log-match button").click();
+ assert.equal(await page.evaluate(() => document.activeElement.id), "tm-log-search");
+ await page.locator("#tm-log-time").selectOption("unknown");
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "1");
+ assert.equal(await page.locator(".tm-log-detail h2").textContent(), "Unattached finding");
+ await input.setInputFiles({name: "bad.jsonl", mimeType: "application/x-ndjson", buffer: Buffer.from(`${JSON.stringify(records[0])}\n{broken`)});
+ await page.locator(".tm-log-error [role=alert]").waitFor();
+ assert.match(await page.locator(".tm-log-error [role=alert]").textContent(), /2/);
+ assert.equal(await page.locator(".tm-log-reading").count(), 0);
+ await input.setInputFiles(file("research.jsonl"));
+ await page.locator(".tm-log-reading").waitFor();
+ await input.setInputFiles(file("research.jsonl"));
+ await page.locator(".tm-log-reading").waitFor();
+ await page.evaluate(() => {
+ const read = File.prototype.arrayBuffer;
+ File.prototype.arrayBuffer = function () {
+ if (this.name === "slow.jsonl") return new Promise((resolve, reject) => { window.__tmReleaseSlow = () => read.call(this).then(resolve, reject); });
+ return read.call(this);
+ };
+ });
+ await input.setInputFiles(file("slow.jsonl"));
+ await page.waitForFunction(() => typeof window.__tmReleaseSlow === "function");
+ await input.setInputFiles(file("newer.jsonl", [row({title: "New file only"})]));
+ await page.locator(".tm-log-reading").waitFor();
+ await page.evaluate(() => window.__tmReleaseSlow());
+ assert.equal(await page.locator(".tm-log-detail h2").textContent(), "New file only");
+ const structured = "Input revision: synthetic-v2 Applicability: this fixture only Observation: The new input refutes the earlier route. Interpretation: Outcome: continue\nretained:\n- original scope\nstopped:\n- the failed route";
+ await input.setInputFiles(file("structured.jsonl", [row({event_id: "structured", event_kind: "finding", status: "refuted", summary: structured})]));
+ await page.locator(".tm-log-observation").waitFor();
+ assert.equal(await page.locator(".tm-log-observation").textContent(), "The new input refutes the earlier route.");
+ assert.match(await page.locator(".tm-log-finding").textContent(), /this fixture only/);
+ await page.locator(".tm-log-finding > details > summary").click();
+ assert.equal(await page.locator(".tm-record-body").textContent(), structured, "structured reading does not replace the complete original text");
+ assert.deepEqual(await page.evaluate(() => window.__tmLocalAudit), {requests: [], writes: []});
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), "local reader fits the viewport");
+ await page.locator(".tm-log-actions button").last().click();
+ await page.waitForFunction(() => document.activeElement.id === "tm-open-log");
+ await page.locator(".tm-revealed").waitFor();
+ assert.equal(await page.locator(".tm-log").count(), 0);
+ assert.match(await page.locator(".tm-revealed").innerText(), /13\.84/);
+ assert.equal(await page.locator("html").getAttribute("lang"), lang === "zh" ? "zh-CN" : "en");
}
let browser;
From f08dfcccfff1b4c7460e45359d2a85d6b6b8bda1 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:21:37 +0800
Subject: [PATCH 2/2] feat(site): surface source-recorded research decisions
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../site/scripts/time-machine.test.mjs | 42 ++++++++++++
.../site/src/time-machine/LocalLogReader.tsx | 65 +++++++++++++++----
.../site/src/time-machine/local-log.ts | 14 +++-
.../site/src/time-machine/time-machine.css | 26 ++++++++
.../dashboard-frontstage-browser-smoke.mjs | 52 +++++++++++++--
5 files changed, 178 insertions(+), 21 deletions(-)
diff --git a/apps/presentation/site/scripts/time-machine.test.mjs b/apps/presentation/site/scripts/time-machine.test.mjs
index 2a67c13366..0b82496d02 100644
--- a/apps/presentation/site/scripts/time-machine.test.mjs
+++ b/apps/presentation/site/scripts/time-machine.test.mjs
@@ -111,6 +111,48 @@ test("local search and time/label filters preserve source order, exact labels an
assert.equal(selectLocalLogEntries(futureLabel, "", "all", null).length, 1, "a source label is data, not a reserved UI option");
});
+// Independently declared Explore writeback semantics: only a finding with the
+// owner's complete section envelope and an explicit leading Outcome is a
+// decision. Status labels and incidental words do not confer that meaning.
+const decisionSummary = interpretation => `Input revision: input-v2\nApplicability: Synthetic source only.\nObservation: The earlier route missed counterevidence.\nInterpretation: ${interpretation}`;
+const decisionRows = () => {
+ const replan = localEvent({event_id: "decision-replan", event_kind: "finding", node_id: "question", result_id: "decision-1", status: "tentative",
+ summary: decisionSummary("Outcome: replan changed: - Read the early counterexample. stopped: - Retry the same input. retained: - Keep source references."), recorded_at: "2026-01-01T18:00:00Z"});
+ return [localEvent({summary: decisionSummary("Outcome: stop"), status: "replan"}), replan,
+ localEvent({event_id: "plain-mention", event_kind: "finding", result_id: "plain", summary: "Discuss Outcome: replan, changed: and retained: without a structured decision."}),
+ localEvent({event_id: "decision-continue", event_kind: "finding", result_id: "decision-2", summary: decisionSummary("Outcome: continue retained: - Keep the negative result."), recorded_at: "2026-01-01T03:00:00Z"}),
+ localEvent({event_id: "unknown-outcome", event_kind: "finding", result_id: "decision-3", summary: decisionSummary("Outcome: __proto__")}), replan,
+ localEvent({event_id: "unknown-time-decision", event_kind: "finding", result_id: "decision-4", recorded_at: "unknown", summary: decisionSummary("Outcome: all")})];
+};
+
+test("decision reading uses explicit source outcomes, keeps repeated rows and never invents chronology or links", () => {
+ const rows = decisionRows();
+ const log = readLocalExploreLog(jsonl(rows));
+ assert.deepEqual(selectLocalLogEntries(log, "", null, null).map(row => row.line), [1, 2, 3, 4, 5, 6, 7]);
+ assert.deepEqual(selectLocalLogEntries(log, "", null, null, null).map(row => row.line), [2, 4, 5, 6, 7]);
+ assert.deepEqual(selectLocalLogEntries(log, "", null, null, "replan").map(row => row.line), [2, 6]);
+ assert.deepEqual(selectLocalLogEntries(log, "", null, null, "continue").map(row => row.line), [4]);
+ assert.deepEqual(selectLocalLogEntries(log, "", null, null, "__proto__").map(row => row.line), [5]);
+ assert.deepEqual(selectLocalLogEntries(log, "", null, null, "all").map(row => row.line), [7]);
+ assert.equal(selectLocalLogEntries(log, "", null, null, "stop").length, 0, "a node is not a finding decision");
+ assert.equal(log.timeline.decisions.length, 4, "the shared owner deduplicates its projection, not source rows");
+ assert.equal(log.timeline.routes.length, 0);
+ assert.equal(log.history.edges.length, 0);
+ const sections = log.findingsByEventId.get("decision-replan").sections;
+ assert.deepEqual(sections.decision, {outcome: "replan", changed: ["Read the early counterexample."], stopped: ["Retry the same input."], retained: ["Keep source references."]});
+ assert.equal(sections.inputRevision, "input-v2");
+ assert.equal(selectLocalLogEntries(log, "", null, null, "replan")[0].source.summary, rows[1].summary, "the complete original is never rewritten");
+});
+
+test("decision filters compose with original search, source labels and unknown record times", () => {
+ const log = readLocalExploreLog(jsonl(decisionRows()));
+ assert.deepEqual(selectLocalLogEntries(log, "early counterexample", "tentative", 2, "replan").map(row => row.line), [2, 6]);
+ assert.equal(selectLocalLogEntries(log, "", "replan", null, null).length, 0, "a source status is separate from an outcome");
+ assert.deepEqual(selectLocalLogEntries(log, "", null, "unknown", null).map(row => row.line), [7]);
+ assert.equal(selectLocalLogEntries(log, "absent", null, null, "replan").length, 0);
+ assert.equal(selectLocalLogEntries(readLocalExploreLog(jsonl(localRows())), "", null, null, null).length, 0, "a file without decisions stays empty in this view");
+});
+
test("bad local files are rejected as a whole, with a line when the fault belongs to one record", () => {
assert.throws(() => readLocalExploreLog(encoded("\n \r\n")), issue("empty"));
assert.throws(() => readLocalExploreLog(new Uint8Array([0xff]).buffer), issue("encoding"));
diff --git a/apps/presentation/site/src/time-machine/LocalLogReader.tsx b/apps/presentation/site/src/time-machine/LocalLogReader.tsx
index 0ada2916f6..0c36a4d4e6 100644
--- a/apps/presentation/site/src/time-machine/LocalLogReader.tsx
+++ b/apps/presentation/site/src/time-machine/LocalLogReader.tsx
@@ -8,9 +8,14 @@ const copy = {
privacy: "Local reading · No file upload or storage.",
choose: "Choose another file", close: "Back to the three episodes", reading: "Reading your file…",
span: "Recorded between", unknown: "Unknown record time", unknownEvents: "distinct events have unknown record times.",
- search: "Find in the original record", placeholder: "A question, a phrase, an evidence reference…",
+ search: "Find in the original record", filters: "Search and source filters", placeholder: "A question, a phrase, an evidence reference…",
status: "Source label", allLabels: "All labels", emptyLabel: "Unrecorded label", time: "Record time", allTime: "Whole recording",
phases: ["First third", "Middle third", "Last third"], match: "Matching records", of: "of", line: "Source line",
+ readingView: "Read your record", allRecords: "All records", decisions: "Decision records", outcome: "Recorded decision", allOutcomes: "All decisions",
+ outcomes: {continue: "Continue", no_change: "No change", replan: "Replan", stop: "Stop", wait: "Wait", ask_human: "Ask the owner"},
+ changed: "Changed", stopped: "Stopped", retained: "Retained", noChanges: "No path changes were listed.",
+ decisionBoundary: "Decisions recorded by the source, not a new assessment.", noDecisions: "This file contains no explicitly recorded decisions.",
+ context: "Source context", inputRevision: "Input revision", allRecordsAction: "Read all records",
previous: "Previous record", next: "Next record", previousPage: "Previous 50", nextPage: "Next 50", page: "Page",
noMatch: "No matching record.", reset: "Clear filters", original: "Original text · untranslated", structured: "Source sections · untranslated",
observation: "Source observation", applicability: "Tested scope · source", interpretation: "Source interpretation", fullOriginal: "Read the complete original text",
@@ -26,9 +31,14 @@ const copy = {
privacy: "本地阅读 · 文件内容不上传、不保存。",
choose: "换一份文件", close: "回到三个案例", reading: "正在读取你的文件…",
span: "记录跨度", unknown: "记录时间未知", unknownEvents: "个事件的记录时间未知(按事件 ID 去重)。",
- search: "在原记录里找一找", placeholder: "一个问题、一段话、一条证据引用…",
+ search: "在原记录里找一找", filters: "搜索与来源筛选", placeholder: "一个问题、一段话、一条证据引用…",
status: "来源标记", allLabels: "全部标记", emptyLabel: "未记录标记", time: "记录时间", allTime: "完整记录跨度",
phases: ["前段", "中段", "后段"], match: "匹配记录", of: "/", line: "来源行",
+ readingView: "阅读记录", allRecords: "全部记录", decisions: "决策记录", outcome: "已记录决策", allOutcomes: "全部决策",
+ outcomes: {continue: "继续", no_change: "不变", replan: "调整方向", stop: "停止", wait: "等待", ask_human: "请本人决定"},
+ changed: "改变了什么", stopped: "停止了什么", retained: "保留了什么", noChanges: "来源未列出路径变更。",
+ decisionBoundary: "这里展示来源已记录的决策,不作新的判断。", noDecisions: "这份文件没有明确记录的决策。",
+ context: "来源上下文", inputRevision: "输入版本", allRecordsAction: "阅读全部记录",
previous: "上一条记录", next: "下一条记录", previousPage: "前 50 条", nextPage: "后 50 条", page: "页",
noMatch: "没有找到匹配的记录。", reset: "清除筛选", original: "原文 · 未翻译", structured: "来源分段 · 未翻译",
observation: "来源中的观察", applicability: "测试范围 · 来源原文", interpretation: "来源中的解释", fullOriginal: "展开完整原文",
@@ -81,10 +91,15 @@ function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh"
const [query, setQuery] = useState("");
const [status, setStatus] = useState(null);
const [phase, setPhase] = useState(null);
+ // UI-only filter over the shared owner's decoded outcomes; unknown source
+ // values remain selectable. undefined reads all rows, null all decisions.
+ const [outcome, setOutcome] = useState(() => log.timeline.decisions.length ? null : undefined);
const [selectedLine, setSelectedLine] = useState(null);
const original = useRef(null);
const search = useRef(null);
- const entries = useMemo(() => selectLocalLogEntries(log, query, status, phase), [log, query, status, phase]);
+ const entries = useMemo(() => selectLocalLogEntries(log, query, status, phase, outcome), [log, query, status, phase, outcome]);
+ const decisionEntries = useMemo(() => selectLocalLogEntries(log, "", null, null, null), [log]);
+ const outcomes = useMemo(() => [...new Set(log.timeline.decisions.map(item => item.sections!.decision!.outcome))], [log]);
const labels = useMemo(() => [...new Set(log.entries.map(entry => entry.event.status))], [log]);
const selected = entries.find(entry => entry.line === selectedLine) ?? entries[0];
const position = selected ? entries.indexOf(selected) : -1;
@@ -93,7 +108,9 @@ function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh"
const {timeline, history} = log;
// Reuse the existing codec for Explore-authored section labels only. Other
// summaries stay raw; the complete source text is always available unchanged.
- const sections = selected ? timeline.findings.find(item => item.event.event_id === selected.event.event_id)?.sections : null;
+ const sections = selected ? log.findingsByEventId.get(selected.event.event_id)?.sections : null;
+ const decision = sections?.decision;
+ const outcomeLabel = (value: string) => Object.hasOwn(c.outcomes, value) ? c.outcomes[value as keyof typeof c.outcomes] : value;
const maxBin = Math.max(1, ...timeline.bins.map(bin => bin.events));
const date = (value: number | null) => value === null ? c.unknown : new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB",
{year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "UTC", timeZoneName: "short"}).format(value);
@@ -101,7 +118,12 @@ function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh"
setSelectedLine(entries[index]!.line);
window.requestAnimationFrame(() => original.current?.focus());
}
- function reset() { setQuery(""); setStatus(null); setPhase(null); search.current?.focus(); }
+ function changeView(value: undefined | null) {
+ const visible = selectLocalLogEntries(log, query, status, phase, value);
+ setSelectedLine(visible.some(entry => entry.line === selected?.line) ? selected!.line : visible[0]?.line ?? null);
+ setOutcome(value);
+ }
+ function reset() { setQuery(""); setStatus(null); setPhase(null); setOutcome(outcome === undefined ? undefined : null); setSelectedLine(null); search.current?.focus(); }
return <>
@@ -110,24 +132,39 @@ function LoadedLog({log, language}: {log: LocalExploreLog; language: "en" | "zh"
{timeline.bins.map((bin, index) => )}}
-
{c.match}: {entries.length} {c.of} {log.entries.length}
- {(query || status !== null || phase !== null) && }
- {!selected ? :
+ {(query || status !== null || phase !== null || typeof outcome === "string") && }
+ {!selected ? {outcome !== undefined && !decisionEntries.length ? c.noDecisions : c.noMatch}
+ {outcome !== undefined && !decisionEntries.length ?
:
}
:
{sections ? c.structured : c.original} · {selected.event.status || c.emptyLabel} · {selected.event.event_kind}
{selected.event.title}
- {sections ? {c.observation}
{sections.observation}
+ {decision &&
+ {c.outcome} · {outcomeLabel(decision.outcome)}
+ {(["changed", "stopped", "retained"] as const).map(key => decision[key].length > 0 && - {c[key]}
{decision[key].map((item, index) => - {item}
)}
)}
+ {![...decision.changed, ...decision.stopped, ...decision.retained].length && {c.noChanges}
}
+ {c.decisionBoundary}
+ }
+ {sections ?
+ {decision ?
{c.context}
- {c.inputRevision}
- {sections.inputRevision}
- {c.applicability}
- {sections.applicability}
- {c.observation}
- {sections.observation}
: <>
{c.observation}
{sections.observation}
{c.applicability}
{sections.applicability}
-
{c.interpretation}
{sections.interpretation}
+
{c.interpretation}
{sections.interpretation}
>}
{c.fullOriginal}
{selected.event.summary}
:
{selected.event.summary}
}
{c.details}
- {c.line}
- {selected.line}
- Goal
- {log.goalId}
- Result
- {selected.event.result_id}
- Event
- {selected.event.event_id}
diff --git a/apps/presentation/site/src/time-machine/local-log.ts b/apps/presentation/site/src/time-machine/local-log.ts
index 14284f90fb..4d92b322e5 100644
--- a/apps/presentation/site/src/time-machine/local-log.ts
+++ b/apps/presentation/site/src/time-machine/local-log.ts
@@ -53,14 +53,22 @@ export function readLocalExploreLog(bytes: ArrayBuffer) {
try { history = projectExploreHistory(entries.map(entry => entry.event), goalId); }
catch { throw new LocalLogError("identity"); }
const timeline = buildExploreTimeline(history.nodes, history.edges, history.events);
- return {goalId, entries, history, timeline};
+ const findingsByEventId = new Map(timeline.findings.map(item => [item.event.event_id, item]));
+ return {goalId, entries, history, timeline, findingsByEventId};
}
-/** Filter source records without changing append order, labels or time semantics. */
-export function selectLocalLogEntries(log: LocalExploreLog, query: string, status: string | null, phase: number | "unknown" | null) {
+/** Filter source records without changing append order, labels or time semantics.
+ * decisionOutcome is a local reading filter: undefined reads every record,
+ * null reads every explicitly decoded decision, and a string matches its
+ * source outcome verbatim. This does not classify prose or node updates. */
+export function selectLocalLogEntries(log: LocalExploreLog, query: string, status: string | null, phase: number | "unknown" | null, decisionOutcome?: string | null) {
const needle = query.trim().toLowerCase();
return log.entries.filter(entry => {
const event = entry.event;
+ if (decisionOutcome !== undefined) {
+ const decision = log.findingsByEventId.get(event.event_id)?.sections?.decision;
+ if (!decision || (decisionOutcome !== null && decision.outcome !== decisionOutcome)) return false;
+ }
if (status !== null && event.status !== status) return false;
if (phase !== null) {
const parsed = Date.parse(event.recorded_at);
diff --git a/apps/presentation/site/src/time-machine/time-machine.css b/apps/presentation/site/src/time-machine/time-machine.css
index f743f3d116..f9b29e6abf 100644
--- a/apps/presentation/site/src/time-machine/time-machine.css
+++ b/apps/presentation/site/src/time-machine/time-machine.css
@@ -79,6 +79,15 @@
.tm-log-overview { border-block: 1px solid var(--border); padding: 20px 0 0; margin-top: 20px; }
.tm-log-overview>p { font-size: 13px; color: var(--body); margin: 0 0 8px; }
.tm-log-strip { display: block; width: 100%; height: 40px; margin-top: 16px; fill: var(--body); opacity: .65; }
+.tm-log-views { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
+.tm-log-views>button { padding-inline: 12px; color: var(--body); border-radius: 6px; }
+.tm-log-views>button[aria-pressed=true] { color: var(--ink); background: var(--soft); }
+.tm-log-views>button span { font: 12px/1.5 var(--font-mono); }
+.tm-log-views>label { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: 12px; color: var(--body); min-width: 0; }
+.tm-log-views select { font: inherit; font-size: 14px; min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 8px; max-width: 100%; }
+.tm-log-filter-disclosure { margin-top: 12px; font-size: 13px; color: var(--body); }
+.tm-log-filter-disclosure>summary { min-height: 44px; align-content: center; cursor: pointer; width: fit-content; }
+.tm-log-filter-disclosure .tm-log-filters { padding-top: 8px; }
.tm-log-filters { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding-top: 24px; }
.tm-log-filters label { display: grid; gap: 8px; font-size: 12px; color: var(--body); min-width: 0; }
.tm-log-filters :is(input, select) { font: inherit; font-size: 14px; width: 100%; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 10px 12px; }
@@ -96,6 +105,19 @@
.tm-log-finding>p { font-size: 14px; line-height: 1.8; color: var(--body); margin: 8px 0 20px; white-space: pre-wrap; }
.tm-log-finding>p.tm-log-field-label { font-size: 12px; margin: 16px 0 4px; }
.tm-log-finding>p.tm-log-observation { color: var(--ink); }
+.tm-log-decision { border-top: 1px solid var(--border); padding-top: 12px; }
+.tm-log-decision-outcome { font-size: 14px; color: var(--body); margin: 0 0 16px; }
+.tm-log-decision-outcome strong { color: var(--ink); font-weight: 500; }
+.tm-log-decision dl { margin: 0; }
+.tm-log-decision dl>div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 16px; padding-block: 12px; }
+.tm-log-decision dt { font-size: 12px; color: var(--body); padding-top: 2px; }
+.tm-log-decision dd { margin: 0; font-size: 14px; line-height: 1.8; white-space: pre-wrap; }
+.tm-log-decision ul { padding: 0; margin: 0; list-style: none; }
+.tm-log-decision li+li { margin-top: 8px; }
+.tm-log-decision-note { font-size: 12px; color: var(--body); margin: 16px 0; }
+.tm-log-context dl { display: grid; gap: 8px; font-size: 14px; line-height: 1.8; }
+.tm-log-context dt { font-size: 12px; color: var(--body); }
+.tm-log-context dd { margin: 0 0 12px; white-space: pre-wrap; }
.tm-log-source dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; font: 12px/1.7 var(--font-mono); }
.tm-log-source dd { margin: 0; }
.tm-log-source ul { padding-left: 20px; }
@@ -135,6 +157,10 @@
.tm-log-filters { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; padding-top: 16px; }
.tm-log-match { margin-block: 8px; }
.tm-log-filters>label:first-child { grid-column: 1 / -1; }
+ .tm-log-views { gap: 4px; margin-top: 12px; }
+ .tm-log-views>label { width: 100%; margin-top: 4px; }
+ .tm-log-views select { flex: 1; min-width: 0; }
+ .tm-log-decision dl>div { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.tm-log-reading { grid-template-columns: minmax(0, 1fr); gap: 20px; }
.tm-log-index, .tm-log-detail { grid-column: auto; grid-row: auto; }
.tm-log h1 { font-size: 20px; }
diff --git a/examples/dashboard-frontstage-browser-smoke.mjs b/examples/dashboard-frontstage-browser-smoke.mjs
index d36d47abe3..b699dd8089 100644
--- a/examples/dashboard-frontstage-browser-smoke.mjs
+++ b/examples/dashboard-frontstage-browser-smoke.mjs
@@ -156,6 +156,7 @@ async function assertLocalTimeMachineLog(page, lang) {
assert.equal(await page.locator(".tm-log-match strong").textContent(), "5");
assert.match(await page.locator(".tm-log-span").textContent(), /00:00.*18:00/);
assert.equal(await page.locator(".tm-revealed,.tm-metrics,.tm-source").count(), 0, "local bytes inherit no curated claim");
+ await page.locator(".tm-log-filter-disclosure > summary").click();
await page.locator("#tm-log-label").selectOption(JSON.stringify("refuted"));
assert.equal(await page.locator(".tm-log-match strong").textContent(), "2");
assert.equal(await page.locator(".tm-log-detail .tm-record-body").textContent(), negative);
@@ -193,11 +194,54 @@ async function assertLocalTimeMachineLog(page, lang) {
assert.equal(await page.locator(".tm-log-detail h2").textContent(), "New file only");
const structured = "Input revision: synthetic-v2 Applicability: this fixture only Observation: The new input refutes the earlier route. Interpretation: Outcome: continue\nretained:\n- original scope\nstopped:\n- the failed route";
await input.setInputFiles(file("structured.jsonl", [row({event_id: "structured", event_kind: "finding", status: "refuted", summary: structured})]));
- await page.locator(".tm-log-observation").waitFor();
- assert.equal(await page.locator(".tm-log-observation").textContent(), "The new input refutes the earlier route.");
- assert.match(await page.locator(".tm-log-finding").textContent(), /this fixture only/);
- await page.locator(".tm-log-finding > details > summary").click();
+ await page.locator(".tm-log-decision").waitFor();
+ assert.match(await page.locator(".tm-log-decision").textContent(), /original scope/);
+ assert.match(await page.locator(".tm-log-decision").textContent(), /the failed route/);
+ await page.locator(".tm-log-context > summary").click();
+ assert.match(await page.locator(".tm-log-context").textContent(), /this fixture only.*The new input refutes the earlier route\./s);
+ await page.locator(".tm-log-finding > details:not(.tm-log-context) > summary").click();
assert.equal(await page.locator(".tm-record-body").textContent(), structured, "structured reading does not replace the complete original text");
+
+ const portfolio = JSON.parse(await readFile(resolve(exportSite, "lab/time-machine/data/portfolio.json"), "utf8"));
+ await input.setInputFiles(file("public-research.jsonl", portfolio.events));
+ await page.locator(".tm-log-decision").waitFor();
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "37");
+ await page.locator("#tm-log-outcome").selectOption(JSON.stringify("replan"));
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "29");
+ await page.locator("#tm-log-outcome").selectOption(JSON.stringify("continue"));
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "8");
+ await page.locator(".tm-log-filter-disclosure > summary").click();
+ await page.locator("#tm-log-search").fill("absent-decision-text");
+ assert.equal(await page.locator(".tm-log-reading").count(), 0, "filtered decision cannot leave a stale readout");
+ await page.locator(".tm-log-match button").click();
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "37");
+ for (let index = 0; index < 37; index++) {
+ await page.locator(".tm-log-index li button").nth(index).click();
+ await page.waitForFunction(() => document.querySelector(".tm-log-detail h2") === document.activeElement);
+ const source = page.locator(".tm-log-source");
+ if (!await source.getAttribute("open").then(value => value !== null)) await source.locator(":scope > summary").click();
+ const line = Number(await source.locator("dd").first().textContent());
+ const original = page.locator(".tm-log-finding > details:not(.tm-log-context)");
+ if (!await original.getAttribute("open").then(value => value !== null)) await original.locator(":scope > summary").click();
+ assert.equal(await page.locator(".tm-record-body").textContent(), portfolio.events[line - 1].summary, "each decision returns to its exact source row");
+ }
+ const allRecords = page.locator(".tm-log-views > button").first();
+ await allRecords.focus(); await allRecords.press("Enter");
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "76");
+ assert.equal(await page.locator("#tm-log-outcome").count(), 0);
+ const unknown = structured.replace("Outcome: continue", "Outcome: __proto__");
+ await input.setInputFiles(file("unknown.jsonl", [row({event_id: "unknown-decision", event_kind: "finding", summary: unknown})]));
+ await page.locator(".tm-log-decision").waitFor();
+ assert.match(await page.locator(".tm-log-decision-outcome").textContent(), /__proto__/);
+ await page.locator("#tm-log-outcome").selectOption(JSON.stringify("__proto__"));
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "1");
+ await input.setInputFiles(file("no-decisions.jsonl"));
+ await page.locator(".tm-log-reading").waitFor();
+ await page.locator(".tm-log-views > button").nth(1).click();
+ assert.equal(await page.locator(".tm-log-reading").count(), 0);
+ assert.equal(await page.locator("#tm-log-outcome").count(), 0);
+ await page.locator(".tm-log-message button").click();
+ assert.equal(await page.locator(".tm-log-match strong").textContent(), "5");
assert.deepEqual(await page.evaluate(() => window.__tmLocalAudit), {requests: [], writes: []});
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), "local reader fits the viewport");
await page.locator(".tm-log-actions button").last().click();