From bafbb213e04f2040c53b2c55197c5ef292313649 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 13:36:07 -0800
Subject: [PATCH 01/15] refactor(server): share Claude and Pi transcript
projections
Co-Authored-By: Claude Fable 5.2
---
.../server/src/project/AgentSessionScanner.ts | 164 ++--------------
.../src/project/agentTranscripts.test.ts | 57 ++++++
apps/server/src/project/agentTranscripts.ts | 179 ++++++++++++++++++
3 files changed, 254 insertions(+), 146 deletions(-)
create mode 100644 apps/server/src/project/agentTranscripts.test.ts
create mode 100644 apps/server/src/project/agentTranscripts.ts
diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts
index a3ebadf8d1122..b5a507c6c1073 100644
--- a/apps/server/src/project/AgentSessionScanner.ts
+++ b/apps/server/src/project/AgentSessionScanner.ts
@@ -1,3 +1,16 @@
+import {
+ TranscriptRecord,
+ conversationItem,
+ activePiBranch,
+ emptyClaudeFields,
+ extractText,
+ foldClaudeFields,
+ isHiddenClaudeRecord,
+ isPiConversationRecord,
+ isTextContentBlock,
+ type ClaudeFields,
+ type DecodedTranscriptRecord,
+} from "./agentTranscripts.ts";
import { resolveClaudeRuntime } from "../provider/Drivers/ClaudeHome.ts";
import * as NodeOS from "node:os";
import * as NodeSqlite from "node:sqlite";
@@ -103,52 +116,10 @@ const GIT_FILE_READ_CONCURRENCY = 16;
const BLOCKED_GIT_FILE_RETRY_MS = 5 * 60 * 1000;
const MAX_BLOCKED_GIT_FILE_PATHS = 1024;
-const TranscriptContentBlock = Schema.Struct({
- type: Schema.optional(Schema.String),
- text: Schema.optional(Schema.String),
-});
-
-const TranscriptMessage = Schema.Struct({
- role: Schema.optional(Schema.String),
- content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])),
- model: Schema.optional(Schema.String),
-});
-
const CodexTurnMetadata = Schema.Struct({
turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])),
});
-const TranscriptRecord = Schema.Struct({
- type: Schema.optional(Schema.String),
- // Pi entries form a tree through id/parentId; session_info entries carry its name.
- id: Schema.optional(Schema.String),
- parentId: Schema.optional(Schema.NullOr(Schema.String)),
- name: Schema.optional(Schema.String),
- timestamp: Schema.optional(Schema.String),
- cwd: Schema.optional(Schema.String),
- sessionId: Schema.optional(Schema.String),
- aiTitle: Schema.optional(Schema.String),
- customTitle: Schema.optional(Schema.String),
- isSidechain: Schema.optional(Schema.Boolean),
- isMeta: Schema.optional(Schema.Boolean),
- isCompactSummary: Schema.optional(Schema.Boolean),
- message: Schema.optional(TranscriptMessage),
- payload: Schema.optional(
- Schema.Struct({
- id: Schema.optional(Schema.String),
- session_id: Schema.optional(Schema.String),
- type: Schema.optional(Schema.String),
- role: Schema.optional(Schema.String),
- message: Schema.optional(Schema.String),
- model: Schema.optional(Schema.String),
- cwd: Schema.optional(Schema.String),
- history_base: Schema.optional(Schema.Unknown),
- content: Schema.optional(Schema.Array(TranscriptContentBlock)),
- internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown),
- }),
- ),
-});
-
const CodexSessionIndexEntry = Schema.Struct({
id: Schema.String,
thread_name: Schema.String,
@@ -185,8 +156,6 @@ const decodeCodexSessionIndexEntry = Schema.decodeUnknownOption(
Schema.fromJsonString(CodexSessionIndexEntry),
);
-type DecodedTranscriptRecord = typeof TranscriptRecord.Type;
-
interface AgentSessionTranscriptMetadata {
readonly source: AgentSessionSource;
readonly providerInstanceId: ProviderInstanceId;
@@ -290,22 +259,6 @@ function splitTranscriptRecords(contents: string, limit: number): string[] {
return records.split("\n", limit);
}
-function isTextContentBlock(block: typeof TranscriptContentBlock.Type): boolean {
- return block.type === "text" || block.type === "input_text" || block.type === "output_text";
-}
-
-function extractText(
- content: string | ReadonlyArray | undefined,
-): string {
- if (typeof content === "string") return content.trim();
- if (content === undefined) return "";
- return content
- .filter(isTextContentBlock)
- .map((block) => block.text?.trim() ?? "")
- .filter((text) => text.length > 0)
- .join("\n");
-}
-
function normalizeTimestamp(value: string | undefined, fallback: string): string {
if (value === undefined) return fallback;
const parsed = DateTime.make(value);
@@ -324,40 +277,6 @@ function codexTurnId(metadata: unknown): string | null {
return decoded.value.turn_id;
}
-function isHiddenClaudeRecord(record: DecodedTranscriptRecord): boolean {
- return record.isSidechain === true || record.isMeta === true || record.isCompactSummary === true;
-}
-
-// Claude repeats session fields on records that need not be retained.
-interface ClaudeFields {
- readonly sessionId: string | undefined;
- readonly title: string | undefined;
- readonly customTitle: string | null;
- readonly model: string | undefined;
-}
-
-const emptyClaudeFields: ClaudeFields = {
- sessionId: undefined,
- title: undefined,
- customTitle: null,
- model: undefined,
-};
-
-function foldClaudeFields(fields: ClaudeFields, record: DecodedTranscriptRecord): ClaudeFields {
- if (isHiddenClaudeRecord(record)) return fields;
- const model = record.message?.model?.trim();
- return {
- sessionId: record.sessionId?.trim() || fields.sessionId,
- title: record.aiTitle?.trim() || fields.title,
- // Claude flushes the AI title after the user's rename; an empty rename clears it.
- customTitle:
- record.customTitle === undefined ? fields.customTitle : record.customTitle.trim() || null,
- // Claude uses this sentinel for local error responses. It is not a
- // model ID that can be selected when the imported session resumes.
- model: model && model !== "" ? model : fields.model,
- };
-}
-
// Codex records setup context as user text ahead of the real request. Skip
// these leading blocks when deriving a title; imported messages keep them.
const CODEX_TITLE_PREAMBLE =
@@ -394,39 +313,6 @@ function deriveTitle(source: AgentSessionSource, text: string): string | undefin
return request.trim().split("\n")[0]?.slice(0, 100).trim() || undefined;
}
-// Pi keeps abandoned branches in the file; its active branch runs from the last entry to the root.
-function activePiBranch(
- records: ReadonlyArray,
-): ReadonlyArray {
- const entries = records.filter((record) => record.type !== "session");
- const leaf = entries.at(-1);
- // Version 1 sessions predate the tree and are linear.
- if (leaf?.id === undefined) return entries;
- const byId = new Map(
- entries.flatMap((entry) => (entry.id === undefined ? [] : [[entry.id, entry] as const])),
- );
- const branch: Array = [];
- for (
- let entry: DecodedTranscriptRecord | undefined = leaf;
- entry !== undefined && branch.length < entries.length;
- entry = entry.parentId ? byId.get(entry.parentId) : undefined
- ) {
- branch.push(entry);
- }
- return branch.toReversed();
-}
-
-function isPiConversationRecord(
- record: DecodedTranscriptRecord,
-): record is DecodedTranscriptRecord & {
- readonly message: typeof TranscriptMessage.Type & { readonly role: "user" | "assistant" };
-} {
- return (
- record.type === "message" &&
- (record.message?.role === "user" || record.message?.role === "assistant")
- );
-}
-
export function parseAgentSessionTranscript(
input: AgentSessionTranscriptMetadata & {
readonly contents: string;
@@ -556,26 +442,12 @@ function parseAgentSessionRecords(
let recordIndex = -1;
for (const record of input.source === "pi" ? activePiBranch(records) : records) {
recordIndex += 1;
- if (input.source === "pi") {
- if (!isPiConversationRecord(record)) continue;
- const text = extractText(record.message.content);
- if (text.length === 0) continue;
- retainMessage({
- role: record.message.role,
- text,
- createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
- codexResponseUser: false,
- });
- continue;
- }
- if (input.source === "claudeAgent") {
- if (isHiddenClaudeRecord(record)) continue;
- if (record.type !== "user" && record.type !== "assistant") continue;
- const text = extractText(record.message?.content);
- if (text.length === 0) continue;
+ if (input.source === "pi" || input.source === "claudeAgent") {
+ const item = conversationItem(record, input.source);
+ if (item === null) continue;
retainMessage({
- role: record.type,
- text,
+ role: item.role,
+ text: item.text,
createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
codexResponseUser: false,
});
diff --git a/apps/server/src/project/agentTranscripts.test.ts b/apps/server/src/project/agentTranscripts.test.ts
new file mode 100644
index 0000000000000..8576ef713547e
--- /dev/null
+++ b/apps/server/src/project/agentTranscripts.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import * as Schema from "effect/Schema";
+import { activePiBranch, TranscriptRecord, transcriptItems } from "./agentTranscripts.ts";
+
+const decode = Schema.decodeUnknownSync(TranscriptRecord);
+
+describe("transcript evidence", () => {
+ it("keeps native Claude identities and tool evidence without thinking or hidden records", () => {
+ const record = decode({
+ uuid: "native-uuid",
+ type: "assistant",
+ message: {
+ content: [
+ { type: "thinking", text: "private reasoning" },
+ { type: "text", text: "Visible answer" },
+ { type: "tool_use", name: "read", input: { path: "src/index.ts" } },
+ { type: "tool_result", content: [{ type: "text", text: "File contents" }] },
+ ],
+ },
+ });
+ expect(transcriptItems(record, "claudeAgent")).toEqual([
+ { id: "native-uuid", role: "assistant", text: "Visible answer" },
+ { id: "native-uuid/tool/2", role: "tool", text: 'read\n{"path":"src/index.ts"}' },
+ { id: "native-uuid/tool/3", role: "tool", text: "File contents" },
+ ]);
+ for (const flag of ["isSidechain", "isMeta", "isCompactSummary"])
+ expect(transcriptItems(decode({ ...record, [flag]: true }), "claudeAgent")).toEqual([]);
+ });
+
+ it("keeps only the active Pi lineage with its original entry IDs", () => {
+ const records = [
+ { type: "session", id: "session" },
+ {
+ type: "message",
+ id: "first",
+ parentId: null,
+ message: { role: "user", content: "Question" },
+ },
+ {
+ type: "message",
+ id: "abandoned",
+ parentId: "first",
+ message: { role: "assistant", content: "Old answer" },
+ },
+ {
+ type: "message",
+ id: "active",
+ parentId: "first",
+ message: { role: "assistant", content: "New answer" },
+ },
+ ].map(decode);
+ expect(activePiBranch(records).flatMap((r) => transcriptItems(r, "pi"))).toEqual([
+ { id: "first", role: "user", text: "Question" },
+ { id: "active", role: "assistant", text: "New answer" },
+ ]);
+ });
+});
diff --git a/apps/server/src/project/agentTranscripts.ts b/apps/server/src/project/agentTranscripts.ts
new file mode 100644
index 0000000000000..24f66e996dcd1
--- /dev/null
+++ b/apps/server/src/project/agentTranscripts.ts
@@ -0,0 +1,179 @@
+import * as Schema from "effect/Schema";
+
+export const TranscriptContentBlock = Schema.Struct({
+ type: Schema.optional(Schema.String),
+ text: Schema.optional(Schema.String),
+ name: Schema.optional(Schema.String),
+ input: Schema.optional(Schema.Unknown),
+ content: Schema.optional(
+ Schema.Union([
+ Schema.String,
+ Schema.Array(
+ Schema.Struct({
+ type: Schema.optional(Schema.String),
+ text: Schema.optional(Schema.String),
+ }),
+ ),
+ ]),
+ ),
+});
+
+export const TranscriptMessage = Schema.Struct({
+ role: Schema.optional(Schema.String),
+ content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])),
+ model: Schema.optional(Schema.String),
+});
+
+export const TranscriptRecord = Schema.Struct({
+ type: Schema.optional(Schema.String),
+ uuid: Schema.optional(Schema.String),
+ entrypoint: Schema.optional(Schema.String),
+ id: Schema.optional(Schema.String),
+ parentId: Schema.optional(Schema.NullOr(Schema.String)),
+ name: Schema.optional(Schema.String),
+ timestamp: Schema.optional(Schema.String),
+ cwd: Schema.optional(Schema.String),
+ sessionId: Schema.optional(Schema.String),
+ aiTitle: Schema.optional(Schema.String),
+ customTitle: Schema.optional(Schema.String),
+ isSidechain: Schema.optional(Schema.Boolean),
+ isMeta: Schema.optional(Schema.Boolean),
+ isCompactSummary: Schema.optional(Schema.Boolean),
+ message: Schema.optional(TranscriptMessage),
+ payload: Schema.optional(
+ Schema.Struct({
+ id: Schema.optional(Schema.String),
+ session_id: Schema.optional(Schema.String),
+ type: Schema.optional(Schema.String),
+ role: Schema.optional(Schema.String),
+ message: Schema.optional(Schema.String),
+ model: Schema.optional(Schema.String),
+ cwd: Schema.optional(Schema.String),
+ history_base: Schema.optional(Schema.Unknown),
+ content: Schema.optional(Schema.Array(TranscriptContentBlock)),
+ internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown),
+ }),
+ ),
+});
+
+export function isTextContentBlock(block: typeof TranscriptContentBlock.Type): boolean {
+ return block.type === "text" || block.type === "input_text" || block.type === "output_text";
+}
+
+export function extractText(
+ content: string | ReadonlyArray | undefined,
+): string {
+ if (typeof content === "string") return content.trim();
+ if (content === undefined) return "";
+ return content
+ .filter(isTextContentBlock)
+ .map((block) => block.text?.trim() ?? "")
+ .filter((text) => text.length > 0)
+ .join("\n");
+}
+
+export function isHiddenClaudeRecord(record: DecodedTranscriptRecord): boolean {
+ return record.isSidechain === true || record.isMeta === true || record.isCompactSummary === true;
+}
+
+export interface ClaudeFields {
+ readonly sessionId: string | undefined;
+ readonly title: string | undefined;
+ readonly customTitle: string | null;
+ readonly model: string | undefined;
+}
+
+export const emptyClaudeFields: ClaudeFields = {
+ sessionId: undefined,
+ title: undefined,
+ customTitle: null,
+ model: undefined,
+};
+
+export function foldClaudeFields(
+ fields: ClaudeFields,
+ record: DecodedTranscriptRecord,
+): ClaudeFields {
+ if (isHiddenClaudeRecord(record)) return fields;
+ const model = record.message?.model?.trim();
+ return {
+ sessionId: record.sessionId?.trim() || fields.sessionId,
+ title: record.aiTitle?.trim() || fields.title,
+ // Claude flushes the AI title after the user's rename; an empty rename clears it.
+ customTitle:
+ record.customTitle === undefined ? fields.customTitle : record.customTitle.trim() || null,
+ model: model && model !== "" ? model : fields.model,
+ };
+}
+
+// Pi keeps abandoned branches in the file; its active branch runs from the last entry to the root.
+export function activePiBranch(
+ records: ReadonlyArray,
+): ReadonlyArray {
+ const entries = records.filter((record) => record.type !== "session");
+ const leaf = entries.at(-1);
+ // Version 1 sessions predate the tree and are linear.
+ if (leaf?.id === undefined) return entries;
+ const byId = new Map(
+ entries.flatMap((entry) => (entry.id === undefined ? [] : [[entry.id, entry] as const])),
+ );
+ const branch: Array = [];
+ const seen = new Set();
+ for (
+ let entry: DecodedTranscriptRecord | undefined = leaf;
+ entry !== undefined && branch.length < entries.length;
+ entry = entry.parentId ? byId.get(entry.parentId) : undefined
+ ) {
+ if (entry.id && seen.has(entry.id)) break;
+ if (entry.id) seen.add(entry.id);
+ branch.push(entry);
+ }
+ return branch.toReversed();
+}
+
+export type DecodedTranscriptRecord = typeof TranscriptRecord.Type;
+
+export interface TranscriptItem {
+ readonly id: string;
+ readonly role: "user" | "assistant" | "tool";
+ readonly text: string;
+}
+
+export function conversationItem(record: DecodedTranscriptRecord, source: "claudeAgent" | "pi") {
+ const role = source === "pi" ? record.message?.role : record.type;
+ if (source === "claudeAgent" && isHiddenClaudeRecord(record)) return null;
+ if (source === "pi" && record.type !== "message") return null;
+ if (role !== "user" && role !== "assistant") return null;
+ const text = extractText(record.message?.content);
+ return text ? { id: source === "pi" ? record.id : record.uuid, role, text } : null;
+}
+
+export function transcriptItems(
+ record: DecodedTranscriptRecord,
+ source: "claudeAgent" | "pi",
+): ReadonlyArray {
+ if (source === "claudeAgent" && isHiddenClaudeRecord(record)) return [];
+ const nativeId = source === "pi" ? record.id : record.uuid;
+ if (!nativeId) return [];
+ const conversation = conversationItem(record, source);
+ const items: TranscriptItem[] = conversation ? [{ ...conversation, id: nativeId }] : [];
+ const content = record.message?.content;
+ if (typeof content === "string" || content === undefined) return items;
+ for (const [index, block] of content.entries()) {
+ const text =
+ block.type === "tool_result"
+ ? extractText(block.content)
+ : block.type === "tool_use"
+ ? [block.name, JSON.stringify(block.input)].filter(Boolean).join("\n")
+ : "";
+ if (text) items.push({ id: `${nativeId}/tool/${index}`, role: "tool", text });
+ }
+ return items;
+}
+
+export function isPiConversationRecord(record: DecodedTranscriptRecord): boolean {
+ return (
+ record.type === "message" &&
+ (record.message?.role === "user" || record.message?.role === "assistant")
+ );
+}
From d1ca88013b1a8e749c890150e64559bf009ff9be Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 13:38:52 -0800
Subject: [PATCH 02/15] feat(contracts): describe discovered external history
sources
Co-Authored-By: Claude Fable 5.2
---
.../contracts/src/externalHistory.test.ts | 94 +++++++++++++++++++
packages/contracts/src/externalHistory.ts | 21 ++++-
2 files changed, 113 insertions(+), 2 deletions(-)
create mode 100644 packages/contracts/src/externalHistory.test.ts
diff --git a/packages/contracts/src/externalHistory.test.ts b/packages/contracts/src/externalHistory.test.ts
new file mode 100644
index 0000000000000..b5d290c0ce234
--- /dev/null
+++ b/packages/contracts/src/externalHistory.test.ts
@@ -0,0 +1,94 @@
+import * as Schema from "effect/Schema";
+import { describe, expect, it } from "vite-plus/test";
+import { ExternalHistoryRequest, ExternalHistoryResponse } from "./externalHistory.ts";
+
+const decode = Schema.decodeUnknownSync(ExternalHistoryResponse);
+const encode = Schema.encodeSync(ExternalHistoryResponse);
+const decodeRequest = Schema.decodeUnknownSync(ExternalHistoryRequest);
+const legacySources = Schema.Struct({
+ operation: Schema.Literal("sources"),
+ enabled: Schema.Boolean,
+ sources: Schema.Array(
+ Schema.Struct({
+ id: Schema.String,
+ label: Schema.String,
+ kind: Schema.Literals(["codex", "chatgpt_export"]),
+ }),
+ ),
+});
+
+const decodeLegacy = Schema.decodeUnknownSync(legacySources);
+
+describe("external history catalogs", () => {
+ it("defaults legacy-server capabilities without inferring them from the source kind", () => {
+ expect(
+ decode({
+ operation: "sources",
+ enabled: true,
+ sources: [{ id: "old", label: "Codex", kind: "codex" }],
+ }),
+ ).toEqual({
+ operation: "sources",
+ enabled: true,
+ unsupported: [],
+ sources: [
+ {
+ id: "old",
+ label: "Codex",
+ kind: "codex",
+ status: "ready",
+ capabilities: { cwdFilter: false, archived: false },
+ },
+ ],
+ });
+ expect(decodeRequest({ operation: "sources" })).toEqual({
+ operation: "sources",
+ });
+ expect(decodeRequest({ operation: "sources", catalogVersion: 2 })).toEqual({
+ operation: "sources",
+ catalogVersion: 2,
+ });
+ });
+
+ it("round-trips the new catalog while a legacy catalog still decodes with the old source union", () => {
+ const response = decode({
+ operation: "sources",
+ enabled: true,
+ sources: [
+ {
+ id: "claude",
+ label: "Claude Code",
+ kind: "local_transcript",
+ provider: "claudeAgent",
+ capabilities: { cwdFilter: true, archived: false },
+ status: "ready",
+ },
+ {
+ id: "opencode",
+ label: "OpenCode",
+ kind: "local_transcript",
+ provider: "opencode",
+ capabilities: { cwdFilter: true, archived: false },
+ status: "unsupported_format",
+ message: "Unsupported OpenCode history format.",
+ },
+ {
+ id: "codex",
+ label: "Codex",
+ kind: "codex",
+ capabilities: { cwdFilter: true, archived: true },
+ status: "unavailable",
+ },
+ ],
+ unsupported: [{ provider: "grok", label: "Grok", reason: "No verified local reader." }],
+ });
+ expect(decode(encode(response))).toEqual(response);
+ if (response.operation !== "sources") throw new Error("Wrong response");
+ expect(
+ decodeLegacy({
+ ...response,
+ sources: response.sources.filter((s) => s.kind !== "local_transcript"),
+ }).sources,
+ ).toHaveLength(1);
+ });
+});
diff --git a/packages/contracts/src/externalHistory.ts b/packages/contracts/src/externalHistory.ts
index 7db0f00e10149..78895365246d0 100644
--- a/packages/contracts/src/externalHistory.ts
+++ b/packages/contracts/src/externalHistory.ts
@@ -1,5 +1,6 @@
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
+import { ProviderDriverKind } from "./providerInstance.ts";
import { NonNegativeInt } from "./baseSchemas.ts";
const Identifier = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(512));
@@ -23,7 +24,10 @@ export const ExternalHistorySourceRef = Schema.Struct({
export type ExternalHistorySourceRef = typeof ExternalHistorySourceRef.Type;
export const ExternalHistoryRequest = Schema.Union([
- Schema.Struct({ operation: Schema.Literal("sources") }),
+ Schema.Struct({
+ operation: Schema.Literal("sources"),
+ catalogVersion: Schema.optionalKey(Schema.Literal(2)),
+ }),
Schema.Struct({
operation: Schema.Literal("search"),
sourceId: Identifier,
@@ -46,8 +50,18 @@ export type ExternalHistoryRequest = typeof ExternalHistoryRequest.Type;
export const ExternalHistorySource = Schema.Struct({
id: Identifier,
label: Schema.String,
- kind: Schema.Literals(["codex", "chatgpt_export"]),
+ kind: Schema.Literals(["codex", "chatgpt_export", "local_transcript"]),
+ provider: Schema.optionalKey(ProviderDriverKind),
+ capabilities: Schema.Struct({ cwdFilter: Schema.Boolean, archived: Schema.Boolean }).pipe(
+ Schema.withDecodingDefault(Effect.succeed({ cwdFilter: false, archived: false })),
+ ),
+ status: Schema.Literals(["ready", "unavailable", "unsupported_format"]).pipe(
+ Schema.withDecodingDefault(Effect.succeed("ready" as const)),
+ ),
+ message: Schema.optionalKey(Schema.String),
});
+export type ExternalHistorySource = typeof ExternalHistorySource.Type;
+
export const ExternalHistoryMatch = Schema.Struct({
ref: ExternalHistorySourceRef,
threadTitle: Schema.String,
@@ -64,6 +78,9 @@ export const ExternalHistoryResponse = Schema.Union([
operation: Schema.Literal("sources"),
enabled: Schema.Boolean,
sources: Schema.Array(ExternalHistorySource),
+ unsupported: Schema.Array(
+ Schema.Struct({ provider: ProviderDriverKind, label: Schema.String, reason: Schema.String }),
+ ).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
}),
Schema.Struct({
operation: Schema.Literal("search"),
From 5477f471c94413c3b0c19386abfb346de0ef9125 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 13:45:56 -0800
Subject: [PATCH 03/15] refactor(server): unify local external history search
and paging
Co-Authored-By: Claude Fable 5.2
---
.../ExternalHistoryToolkit.test.ts | 11 +-
.../src/project/agentTranscripts.test.ts | 4 +-
.../ChatGptExportHistory.test.ts | 71 ++---
.../externalHistory/ChatGptExportHistory.ts | 281 +++++++-----------
.../externalHistory/ExternalHistory.test.ts | 2 +
.../externalHistory/ExternalHistory.ts | 121 ++++++--
.../project/externalHistory/LocalHistory.ts | 194 ++++++++++++
.../externalHistory/LocalHistoryStore.ts | 73 +++++
.../externalHistory/historySupport.test.ts | 18 ++
.../project/externalHistory/historySupport.ts | 89 ++++++
10 files changed, 618 insertions(+), 246 deletions(-)
create mode 100644 apps/server/src/project/externalHistory/LocalHistory.ts
create mode 100644 apps/server/src/project/externalHistory/LocalHistoryStore.ts
create mode 100644 apps/server/src/project/externalHistory/historySupport.test.ts
create mode 100644 apps/server/src/project/externalHistory/historySupport.ts
diff --git a/apps/server/src/mcp/toolkits/externalHistory/ExternalHistoryToolkit.test.ts b/apps/server/src/mcp/toolkits/externalHistory/ExternalHistoryToolkit.test.ts
index 110eb687419d3..f24b23153614c 100644
--- a/apps/server/src/mcp/toolkits/externalHistory/ExternalHistoryToolkit.test.ts
+++ b/apps/server/src/mcp/toolkits/externalHistory/ExternalHistoryToolkit.test.ts
@@ -51,7 +51,16 @@ it.effect(
? Effect.succeed({
operation: "sources",
enabled: true,
- sources: [{ id: "source", label: "Codex", kind: "codex" }],
+ sources: [
+ {
+ id: "source",
+ label: "Codex",
+ kind: "codex",
+ capabilities: { cwdFilter: true, archived: true },
+ status: "ready",
+ },
+ ],
+ unsupported: [],
})
: Effect.fail(
new ExternalHistoryError({
diff --git a/apps/server/src/project/agentTranscripts.test.ts b/apps/server/src/project/agentTranscripts.test.ts
index 8576ef713547e..1efced40a40dd 100644
--- a/apps/server/src/project/agentTranscripts.test.ts
+++ b/apps/server/src/project/agentTranscripts.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it } from "vite-plus/test";
import * as Schema from "effect/Schema";
import { activePiBranch, TranscriptRecord, transcriptItems } from "./agentTranscripts.ts";
@@ -48,7 +48,7 @@ describe("transcript evidence", () => {
parentId: "first",
message: { role: "assistant", content: "New answer" },
},
- ].map(decode);
+ ].map((record) => decode(record));
expect(activePiBranch(records).flatMap((r) => transcriptItems(r, "pi"))).toEqual([
{ id: "first", role: "user", text: "Question" },
{ id: "active", role: "assistant", text: "New answer" },
diff --git a/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts b/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts
index 9aa92dcfd9d81..c5f3bbb115f9a 100644
--- a/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts
+++ b/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts
@@ -5,7 +5,8 @@ import { expect, it } from "@effect/vitest";
import type { ExternalHistoryResponse } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
-import { makeChatGptExportHistory } from "./ChatGptExportHistory.ts";
+import { makeChatGptExportStore } from "./ChatGptExportHistory.ts";
+import { makeLocalHistory } from "./LocalHistory.ts";
const conversation = (texts: ReadonlyArray, id = "conversation-1") => ({
id,
@@ -30,15 +31,16 @@ const fixture = Effect.fn("ChatGptExportHistory.test.fixture")(function* (value:
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-export-" });
const filePath = `${directory}/conversations.json`;
yield* fs.writeFileString(filePath, JSON.stringify(value));
- return { history: yield* makeChatGptExportHistory, fs, filePath };
+ const history = yield* makeLocalHistory;
+ return { history, store: yield* makeChatGptExportStore(filePath, history.cache), fs, filePath };
});
it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export history", (it) => {
it.effect("finds suffix matches and pages digest-verified exact Unicode text", () =>
Effect.gen(function* () {
const text = "İ😀" + "a".repeat(3_000) + "NEEDLE [literal]";
- const { history, filePath } = yield* fixture([conversation([text])]);
- const result = yield* history.search(filePath, {
+ const { history, store } = yield* fixture([conversation([text])]);
+ const result = yield* history.search(store, {
operation: "search",
sourceId: "export",
query: "needle [literal]",
@@ -51,7 +53,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export h
let offset: number | null = 0;
while (offset !== null) {
const page: Extract = yield* history.read(
- filePath,
+ store,
{ operation: "read", ref, textOffset: offset, maxChars: 101 },
);
actual += page.text;
@@ -59,11 +61,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export h
}
expect(actual).toBe(text);
const split = yield* history
- .read(filePath, { operation: "read", ref, textOffset: 2 })
+ .read(store, { operation: "read", ref, textOffset: 2 })
.pipe(Effect.flip);
expect(split.reason).toBe("invalid_cursor");
const wrongDigest = yield* history
- .read(filePath, { operation: "read", ref: { ...ref, digest: "0".repeat(64) } })
+ .read(store, { operation: "read", ref: { ...ref, digest: "0".repeat(64) } })
.pipe(Effect.flip);
expect(wrongDigest.reason).toBe("changed");
}).pipe(Effect.scoped),
@@ -100,32 +102,33 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export h
},
conversation(["needle second conversation"], "conversation-2"),
];
- const { history, filePath } = yield* fixture(exportValue);
+ const { history, store } = yield* fixture(exportValue);
const request = { operation: "search", sourceId: "export", query: "needle" } as const;
- const page1 = yield* history.search(filePath, request);
+ const page1 = yield* history.search(store, request);
expect(page1.matches).toEqual([]);
expect(page1.examinedItems).toBe(20);
expect(page1.nextCursor).not.toBeNull();
- const page2 = yield* history.search(filePath, { ...request, cursor: page1.nextCursor! });
+ const page2 = yield* history.search(store, { ...request, cursor: page1.nextCursor! });
expect(page2.matches.map((entry) => entry.itemId)).toEqual(["message-20"]);
- const page3 = yield* history.search(filePath, { ...request, cursor: page2.nextCursor! });
- expect(page3.matches.map((entry) => entry.ref.threadId)).toEqual(["conversation-2"]);
- const end = yield* history.search(filePath, { ...request, cursor: page3.nextCursor! });
- expect(end.nextCursor).toBeNull();
+ const page3 = yield* history.search(store, { ...request, cursor: page2.nextCursor! });
+ expect(page3.matches.map((entry) => entry.ref.threadId)).toEqual(["1:conversation-2"]);
+ expect(page3.nextCursor).toBeNull();
}).pipe(Effect.scoped),
);
it.effect("binds search cursors and exact source reads to the current file", () =>
Effect.gen(function* () {
- const { history, fs, filePath } = yield* fixture([conversation(["needle"])]);
+ const { history, store, fs, filePath } = yield* fixture([
+ conversation(["needle", ...Array.from({ length: 20 }, () => "other")]),
+ ]);
const request = { operation: "search", sourceId: "export", query: "needle" } as const;
- const page = yield* history.search(filePath, request);
+ const page = yield* history.search(store, request);
const wrongQuery = yield* history
- .search(filePath, { ...request, query: "other", cursor: page.nextCursor! })
+ .search(store, { ...request, query: "other", cursor: page.nextCursor! })
.pipe(Effect.flip);
expect(wrongQuery.reason).toBe("invalid_cursor");
const wrongSource = yield* history
- .read(filePath, { operation: "read", ref: { ...page.matches[0]!.ref, sourceId: "other" } })
+ .read(store, { operation: "read", ref: { ...page.matches[0]!.ref, sourceId: "other" } })
.pipe(Effect.flip);
expect(wrongSource.reason).toBe("invalid_cursor");
yield* fs.writeFileString(
@@ -133,13 +136,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export h
JSON.stringify([conversation(["needle changed source"])]),
);
const changed = yield* history
- .read(filePath, { operation: "read", ref: page.matches[0]!.ref })
+ .read(store, { operation: "read", ref: page.matches[0]!.ref })
.pipe(Effect.flip);
expect(changed.reason).toBe("changed");
- const stalePage = yield* history
- .search(filePath, { ...request, cursor: page.nextCursor! })
- .pipe(Effect.flip);
- expect(stalePage.reason).toBe("changed");
+ const restartedPage = yield* history.search(store, { ...request, cursor: page.nextCursor! });
+ expect(restartedPage.matches[0]?.excerpt).toBe("needle changed source");
}).pipe(Effect.scoped),
);
@@ -160,25 +161,23 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export h
},
},
};
- const { history, fs, filePath } = yield* fixture([media]);
+ const { history, store, fs, filePath } = yield* fixture([media]);
const request = { operation: "search", sourceId: "export", query: "needle" } as const;
- expect((yield* history.search(filePath, request)).matches[0]?.excerpt).toBe("needle");
+ expect((yield* history.search(store, request)).matches[0]?.excerpt).toBe("needle");
yield* fs.writeFileString(
filePath,
JSON.stringify([conversation(["x".repeat(5_000_000) + "needle"])]),
);
- const error = yield* history.search(filePath, request).pipe(Effect.flip);
+ const error = yield* history.search(store, request).pipe(Effect.flip);
expect(error.reason).toBe("limit_exceeded");
}).pipe(Effect.scoped),
);
it.effect("fails explicitly for unsupported exports and ambiguous branches", () =>
Effect.gen(function* () {
- const { history, fs, filePath } = yield* fixture({ conversations: [] });
+ const { history, store, fs, filePath } = yield* fixture({ conversations: [] });
const request = { operation: "search", sourceId: "export", query: "needle" } as const;
- expect((yield* history.search(filePath, request).pipe(Effect.flip)).reason).toBe(
- "unsupported",
- );
+ expect((yield* history.search(store, request).pipe(Effect.flip)).reason).toBe("unsupported");
const source = conversation(["needle"]);
yield* fs.writeFileString(
filePath,
@@ -186,19 +185,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export h
{ ...source, mapping: { "node-0": { ...source.mapping["node-0"], parent: "node-0" } } },
]),
);
- expect((yield* history.search(filePath, request).pipe(Effect.flip)).reason).toBe(
- "unsupported",
- );
+ expect((yield* history.search(store, request).pipe(Effect.flip)).reason).toBe("unsupported");
const duplicate = conversation(["first message", "needle second message"]);
duplicate.mapping["node-1"]!.message.id = duplicate.mapping["node-0"]!.message.id;
yield* fs.writeFileString(filePath, JSON.stringify([duplicate]));
- expect((yield* history.search(filePath, request).pipe(Effect.flip)).reason).toBe(
- "unsupported",
- );
+ expect((yield* history.search(store, request).pipe(Effect.flip)).reason).toBe("unsupported");
yield* fs.writeFileString(filePath, '[{"bad":');
- expect((yield* history.search(filePath, request).pipe(Effect.flip)).reason).toBe(
- "unsupported",
- );
+ expect((yield* history.search(store, request).pipe(Effect.flip)).reason).toBe("unsupported");
}).pipe(Effect.scoped),
);
});
diff --git a/apps/server/src/project/externalHistory/ChatGptExportHistory.ts b/apps/server/src/project/externalHistory/ChatGptExportHistory.ts
index ab9750ee2ecb9..592d0b525c116 100644
--- a/apps/server/src/project/externalHistory/ChatGptExportHistory.ts
+++ b/apps/server/src/project/externalHistory/ChatGptExportHistory.ts
@@ -1,28 +1,20 @@
-import {
- ExternalHistoryError,
- type ExternalHistoryMatch,
- type ExternalHistoryRequest,
- type ExternalHistoryResponse,
-} from "@t3tools/contracts";
-import * as Digest from "effect/crypto/Digest";
+import { ExternalHistoryError } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
-import * as Hex from "effect/encoding/Hex";
import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import { createTranscriptJsonReader, TranscriptJsonLimitError } from "../AgentSessionJson.ts";
import {
- boundedTextEnd,
- isTextBoundary,
-} from "../conversationSummaries/conversationSummarySources.ts";
+ fileVersion,
+ type LocalHistoryStore,
+ type LocalThreadRef,
+ type ThreadCache,
+} from "./LocalHistoryStore.ts";
const MAX_CONVERSATION_BYTES = 8 * 1024 * 1024;
const CHUNK_BYTES = 64 * 1024;
-const PAGE_ITEMS = 20;
-const MAX_EXCERPT_CHARS = 2_000;
-const Index = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
const Message = Schema.Struct({
id: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(512)),
author: Schema.Struct({ role: Schema.String }),
@@ -49,39 +41,15 @@ const Conversation = Schema.Struct({
}),
),
});
-const SearchCursor = Schema.Struct({
- version: Schema.Literal(1),
- sourceId: Schema.String,
- file: Schema.String,
- query: Schema.String,
- conversation: Index,
- message: Index,
-});
-const ReadCursor = Schema.Struct({
- version: Schema.Literal(1),
- sourceId: Schema.String,
- file: Schema.String,
- conversation: Index,
- threadId: Schema.String,
- itemId: Schema.String,
-});
const decodeConversation = Schema.decodeUnknownEffect(Conversation);
-const decodeSearchCursor = Schema.decodeUnknownEffect(Schema.fromJsonString(SearchCursor));
-const encodeSearchCursor = Schema.encodeSync(Schema.fromJsonString(SearchCursor));
-const decodeReadCursor = Schema.decodeUnknownEffect(Schema.fromJsonString(ReadCursor));
-const encodeReadCursor = Schema.encodeSync(Schema.fromJsonString(ReadCursor));
const isHistoryError = Schema.is(ExternalHistoryError);
const isTranscriptJsonLimitError = Schema.is(TranscriptJsonLimitError);
-const encodeFileIdentity = Schema.encodeSync(
- Schema.fromJsonString(
- Schema.Struct({
- size: Schema.String,
- dev: Schema.String,
- ino: Schema.NullOr(Schema.String),
- mtime: Schema.NullOr(Schema.Number),
- birthtime: Schema.NullOr(Schema.Number),
- }),
- ),
+const decodeMetadata = Schema.decodeUnknownSync(
+ Schema.Struct({
+ id: Schema.optionalKey(Schema.String),
+ conversation_id: Schema.optionalKey(Schema.String),
+ title: Schema.String,
+ }),
);
interface VisibleMessage {
@@ -159,27 +127,11 @@ function selectExportPath(path: ReadonlyArray, index: nu
);
}
-function fileIdentity(info: FileSystem.File.Info) {
- return encodeFileIdentity({
- size: String(info.size),
- dev: String(info.dev),
- ino: Option.map(info.ino, String).pipe(Option.getOrNull),
- mtime: Option.map(info.mtime, (date) => date.getTime()).pipe(Option.getOrNull),
- birthtime: Option.map(info.birthtime, (date) => date.getTime()).pipe(Option.getOrNull),
- });
-}
-
-type SearchRequest = Extract;
-type ReadRequest = Extract;
-
-export const makeChatGptExportHistory = Effect.gen(function* () {
+export const makeChatGptExportStore = Effect.fn("ChatGptExportHistory.open")(function* (
+ filePath: string,
+ cache: ThreadCache,
+) {
const fs = yield* FileSystem.FileSystem;
- const crypto = yield* Digest.Digest;
- const digest = (text: string) =>
- crypto.digest(Digest.sha256, new TextEncoder().encode(text)).pipe(
- Effect.map(Hex.encode),
- Effect.mapError((cause) => new ExternalHistoryError({ reason: "unavailable", cause })),
- );
const mapError = (cause: unknown) =>
isHistoryError(cause)
? cause
@@ -202,8 +154,8 @@ export const makeChatGptExportHistory = Effect.gen(function* () {
const file = yield* fs.open(filePath, { flag: "r" });
const stat = yield* file.stat;
if (stat.type !== "File") return yield* new ExternalHistoryError({ reason: "unsupported" });
- const identity = fileIdentity(stat);
- const version = yield* digest(identity);
+ const identity = fileVersion(stat);
+ const version = identity;
if (expected !== undefined && expected !== version)
return yield* new ExternalHistoryError({ reason: "changed" });
let value: unknown;
@@ -247,8 +199,8 @@ export const makeChatGptExportHistory = Effect.gen(function* () {
return yield* new ExternalHistoryError({ reason: "unsupported" });
}
if (
- identity !== fileIdentity(yield* file.stat) ||
- identity !== fileIdentity(yield* fs.stat(filePath))
+ identity !== fileVersion(yield* file.stat) ||
+ identity !== fileVersion(yield* fs.stat(filePath))
)
return yield* new ExternalHistoryError({ reason: "changed" });
if (!found) return { file: version, conversation: null };
@@ -270,110 +222,97 @@ export const makeChatGptExportHistory = Effect.gen(function* () {
).pipe(Effect.mapError(mapError));
});
- const search = Effect.fn("ChatGptExportHistory.search")(function* (
- filePath: string,
- input: SearchRequest,
- ) {
- if (input.filterCwd) return yield* new ExternalHistoryError({ reason: "unsupported" });
- const cursor =
- input.cursor === undefined
- ? undefined
- : yield* decodeSearchCursor(input.cursor).pipe(
- Effect.mapError(() => new ExternalHistoryError({ reason: "invalid_cursor" })),
- );
- if (cursor && (cursor.sourceId !== input.sourceId || cursor.query !== input.query))
- return yield* new ExternalHistoryError({ reason: "invalid_cursor" });
- const index = cursor?.conversation ?? 0;
- const result = yield* readConversation(filePath, index, cursor?.file);
- if (!result.conversation)
- return {
- operation: "search",
- matches: [],
- examinedItems: 0,
- nextCursor: null,
- } satisfies ExternalHistoryResponse;
- const { threadId, title, messages } = result.conversation;
- const offset = cursor?.message ?? 0;
- if (offset > messages.length)
- return yield* new ExternalHistoryError({ reason: "invalid_cursor" });
- const end = Math.min(messages.length, offset + PAGE_ITEMS);
- const pattern = new RegExp(input.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu");
- const matches: Array = [];
- for (const message of messages.slice(offset, end)) {
- const match = pattern.exec(message.text);
- if (!match) continue;
- let start = Math.max(0, match.index - 200);
- if (!isTextBoundary(message.text, start)) start--;
- matches.push({
- ref: {
- sourceId: input.sourceId,
- threadId,
- readCursor: encodeReadCursor({
- version: 1,
- sourceId: input.sourceId,
- file: result.file,
- conversation: index,
- threadId,
- itemId: message.id,
- }),
- digest: yield* digest(`chatgpt-visible-evidence-v1\n${message.role}\n${message.text}`),
- },
- threadTitle: title,
- itemId: message.id,
- role: message.role,
- excerpt: message.text.slice(start, boundedTextEnd(message.text, start, MAX_EXCERPT_CHARS)),
- textOffset: start,
- totalChars: message.text.length,
- });
- }
- return {
- operation: "search",
- matches,
- examinedItems: end - offset,
- nextCursor: encodeSearchCursor({
- version: 1,
- sourceId: input.sourceId,
- file: result.file,
- query: input.query,
- conversation: end < messages.length ? index : index + 1,
- message: end < messages.length ? end : 0,
+ let listing: { version: string; refs: ReadonlyArray } | undefined;
+ const listThreads = Effect.gen(function* () {
+ const stat = yield* fs.stat(filePath);
+ if (stat.type !== "File") return yield* new ExternalHistoryError({ reason: "unsupported" });
+ const version = fileVersion(stat);
+ if (listing?.version === version) return listing.refs;
+ const refs: LocalThreadRef[] = [];
+ yield* Effect.scoped(
+ Effect.gen(function* () {
+ const file = yield* fs.open(filePath, { flag: "r" });
+ if (fileVersion(yield* file.stat) !== version)
+ return yield* new ExternalHistoryError({ reason: "changed" });
+ let allocated = 0;
+ const reader = createTranscriptJsonReader(
+ (bytes) => {
+ allocated += bytes;
+ if (allocated > MAX_CONVERSATION_BYTES)
+ throw new TranscriptJsonLimitError({ message: "Export metadata limit" });
+ },
+ (path) =>
+ path.length <= 1 ||
+ (path.length === 2 &&
+ (path[1] === "id" || path[1] === "conversation_id" || path[1] === "title")),
+ {
+ onArrayItem: (index, value) => {
+ allocated = 0;
+ const metadata = decodeMetadata(value);
+ const id = metadata.id ?? metadata.conversation_id;
+ if (!id) throw new ExternalHistoryError({ reason: "unsupported" });
+ const key = `${index}:${id}`;
+ if (key.length > 512 || refs.length >= 50_000)
+ throw new ExternalHistoryError({ reason: "limit_exceeded" });
+ refs.push({
+ key,
+ title: metadata.title,
+ updatedAt: Option.map(stat.mtime, (d) => d.getTime()).pipe(
+ Option.getOrElse(() => 0),
+ ),
+ version,
+ });
+ return true;
+ },
+ },
+ );
+ const decoder = new TextDecoder("utf-8", { fatal: true });
+ while (true) {
+ const bytes = yield* file.readAlloc(CHUNK_BYTES);
+ if (Option.isNone(bytes)) break;
+ yield* Effect.try({
+ try: () => reader.write(decoder.decode(bytes.value, { stream: true })),
+ catch: mapError,
+ });
+ }
+ const result = yield* Effect.try({
+ try: () => {
+ reader.write(decoder.decode());
+ return reader.finish();
+ },
+ catch: mapError,
+ });
+ if (!Array.isArray(result))
+ return yield* new ExternalHistoryError({ reason: "unsupported" });
+ if (
+ fileVersion(yield* file.stat) !== version ||
+ fileVersion(yield* fs.stat(filePath)) !== version
+ )
+ return yield* new ExternalHistoryError({ reason: "changed" });
}),
- } satisfies ExternalHistoryResponse;
- });
-
- const read = Effect.fn("ChatGptExportHistory.read")(function* (
- filePath: string,
- input: ReadRequest,
- ) {
- const cursor = yield* decodeReadCursor(input.ref.readCursor).pipe(
- Effect.mapError(() => new ExternalHistoryError({ reason: "invalid_cursor" })),
);
- if (cursor.sourceId !== input.ref.sourceId || cursor.threadId !== input.ref.threadId)
- return yield* new ExternalHistoryError({ reason: "invalid_cursor" });
- const result = yield* readConversation(filePath, cursor.conversation, cursor.file);
- if (result.conversation?.threadId !== cursor.threadId)
- return yield* new ExternalHistoryError({ reason: "changed" });
- const message = result.conversation.messages.find((entry) => entry.id === cursor.itemId);
- if (
- !message ||
- (yield* digest(`chatgpt-visible-evidence-v1\n${message.role}\n${message.text}`)) !==
- input.ref.digest
- )
- return yield* new ExternalHistoryError({ reason: "changed" });
- const start = input.textOffset ?? 0;
- if (start > message.text.length || !isTextBoundary(message.text, start))
+ listing = { version, refs };
+ return refs;
+ }).pipe(Effect.mapError(mapError));
+ const loadThread = Effect.fn("ChatGptExportHistory.loadThread")(function* (ref: LocalThreadRef) {
+ const version = fileVersion(yield* fs.stat(filePath));
+ const match = /^(0|[1-9][0-9]*):(.+)$/.exec(ref.key);
+ if (!match || !Number.isSafeInteger(Number(match[1])))
return yield* new ExternalHistoryError({ reason: "invalid_cursor" });
- const end = boundedTextEnd(message.text, start, input.maxChars ?? 12_000);
- return {
- operation: "read",
- ref: input.ref,
- threadTitle: result.conversation.title,
- role: message.role,
- text: message.text.slice(start, end),
- textOffset: start,
- totalChars: message.text.length,
- nextTextOffset: end < message.text.length ? end : null,
- } satisfies ExternalHistoryResponse;
- });
- return { search, read };
+ return yield* cache(
+ `${filePath}\0${ref.key}\0${version}`,
+ Effect.gen(function* () {
+ const result = yield* readConversation(filePath, Number(match[1]), version);
+ if (!result.conversation || result.conversation.threadId !== match[2])
+ return yield* new ExternalHistoryError({ reason: "changed" });
+ return {
+ version,
+ title: result.conversation.title,
+ cwd: null,
+ items: result.conversation.messages,
+ };
+ }),
+ );
+ }, Effect.mapError(mapError));
+ return { listThreads, loadThread } satisfies LocalHistoryStore;
});
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.test.ts b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
index 34a8e7a136587..63446ebfc0630 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.test.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
@@ -159,6 +159,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
operation: "sources",
enabled: false,
sources: [],
+ unsupported: [],
});
expect(state.registryReads).toBe(0);
const error = yield* service
@@ -283,6 +284,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
operation: "sources",
enabled: true,
sources: [],
+ unsupported: [],
});
}),
);
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.ts b/apps/server/src/project/externalHistory/ExternalHistory.ts
index 346aa3e958205..c3a3898801afc 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.ts
@@ -2,6 +2,8 @@ import {
ExternalHistoryError,
type ExternalHistoryRequest,
type ExternalHistoryResponse,
+ type ExternalHistorySource,
+ ProviderDriverKind,
} from "@t3tools/contracts";
import type {
ProviderNativeHistory,
@@ -11,6 +13,7 @@ import * as Context from "effect/Context";
import * as Digest from "effect/crypto/Digest";
import * as Effect from "effect/Effect";
import * as Hex from "effect/encoding/Hex";
+import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
@@ -18,7 +21,9 @@ import * as Schema from "effect/Schema";
import * as ServerConfig from "../../config.ts";
import * as ServerSettings from "../../serverSettings.ts";
import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
-import { makeChatGptExportHistory } from "./ChatGptExportHistory.ts";
+import { makeChatGptExportStore } from "./ChatGptExportHistory.ts";
+import { makeLocalHistory } from "./LocalHistory.ts";
+import type { LocalHistoryStore } from "./LocalHistoryStore.ts";
export class ExternalHistory extends Context.Service<
ExternalHistory,
@@ -49,6 +54,7 @@ const encodeCursor = (cursor: typeof NativeCursor.Type) =>
? Effect.succeed(encoded)
: Effect.fail(new ExternalHistoryError({ reason: "limit_exceeded" }));
});
+const encodeFingerprint = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
const encodeIdentity = Schema.encodeSync(
Schema.fromJsonString(
Schema.Struct({
@@ -58,12 +64,9 @@ const encodeIdentity = Schema.encodeSync(
),
);
-type Source = {
- readonly id: string;
- readonly label: string;
-} & (
- | { readonly kind: "codex"; readonly native: ProviderNativeHistory; readonly readers: number }
- | { readonly kind: "chatgpt_export"; readonly filePath: string }
+type Source = { readonly info: ExternalHistorySource } & (
+ | { readonly _tag: "native"; readonly native: ProviderNativeHistory; readonly readers: number }
+ | { readonly _tag: "local"; readonly store: LocalHistoryStore }
);
// A signed-out or older instance must not hide a shared home that another enabled instance
@@ -95,24 +98,31 @@ const make = Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const crypto = yield* Digest.Digest;
const path = yield* Path.Path;
- const exports = yield* makeChatGptExportHistory;
+ const fs = yield* FileSystem.FileSystem;
+ const local = yield* makeLocalHistory;
+ let localCatalog = new Map();
+ let fingerprint: string | undefined;
const digest = (text: string) =>
crypto.digest(Digest.sha256, new TextEncoder().encode(text)).pipe(
Effect.map(Hex.encode),
Effect.mapError((cause) => new ExternalHistoryError({ reason: "unavailable", cause })),
);
- const sources = Effect.fn("ExternalHistory.sources")(function* () {
+ const sources = Effect.fn("ExternalHistory.sources")(function* (refresh: boolean) {
const value = yield* settings.getSettings.pipe(
Effect.mapError((cause) => new ExternalHistoryError({ reason: "unavailable", cause })),
);
- if (!value.externalHistory.enabled) return { enabled: false, sources: [] as Array };
+ if (!value.externalHistory.enabled) {
+ localCatalog = new Map();
+ fingerprint = undefined;
+ return { enabled: false, sources: [] as Array };
+ }
const result = new Map();
for (const instance of yield* registry.listInstances) {
if (!instance.enabled || instance.driverKind !== "codex" || !instance.nativeHistory) continue;
const id = `codex:${yield* digest(encodeIdentity(instance.continuationIdentity))}`;
const existing = result.get(id);
- if (existing?.kind === "codex" && existing.readers < 3) {
+ if (existing?._tag === "native" && existing.readers < 3) {
result.set(id, {
...existing,
native: alternateNativeReader(existing.native, instance.nativeHistory),
@@ -120,28 +130,51 @@ const make = Effect.gen(function* () {
});
} else if (existing === undefined)
result.set(id, {
- id,
- label: instance.displayName ?? "Codex",
- kind: "codex",
+ _tag: "native",
+ info: {
+ id,
+ label: instance.displayName ?? "Codex",
+ kind: "codex",
+ provider: ProviderDriverKind.make("codex"),
+ capabilities: { cwdFilter: true, archived: true },
+ status: "ready",
+ },
native: instance.nativeHistory,
readers: 1,
});
}
- for (const configured of value.externalHistory.chatGptExportPaths) {
- const filePath = path.resolve(config.cwd, configured);
- const id = `chatgpt:${yield* digest(filePath)}`;
- result.set(id, {
- id,
- label: `ChatGPT · ${path.basename(filePath)}`,
- kind: "chatgpt_export",
- filePath,
- });
+ const nextFingerprint = encodeFingerprint([value.externalHistory, value.providerInstances]);
+ if (refresh || fingerprint !== nextFingerprint) {
+ const next = new Map();
+ for (const configured of value.externalHistory.chatGptExportPaths) {
+ const filePath = path.resolve(config.cwd, configured);
+ const id = `chatgpt:${yield* digest(filePath)}`;
+ next.set(
+ id,
+ localCatalog.get(id) ?? {
+ _tag: "local",
+ info: {
+ id,
+ label: `ChatGPT · ${path.basename(filePath)}`,
+ kind: "chatgpt_export",
+ capabilities: { cwdFilter: false, archived: false },
+ status: "ready",
+ },
+ store: yield* makeChatGptExportStore(filePath, local.cache).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ ),
+ },
+ );
+ }
+ localCatalog = next;
+ fingerprint = nextFingerprint;
}
+ for (const [id, source] of localCatalog) result.set(id, source);
return { enabled: true, sources: [...result.values()] };
});
const nativeSearch = Effect.fn("ExternalHistory.nativeSearch")(function* (
- source: Extract,
+ source: Extract,
input: Extract,
) {
const decoded =
@@ -152,7 +185,7 @@ const make = Effect.gen(function* () {
);
let cursor: typeof NativeCursor.Type = decoded ?? {
version: 1,
- sourceId: source.id,
+ sourceId: source.info.id,
query: input.query,
filterCwd: input.filterCwd ?? null,
includeArchived: input.includeArchived ?? false,
@@ -162,7 +195,7 @@ const make = Effect.gen(function* () {
itemCursor: null,
};
if (
- cursor.sourceId !== source.id ||
+ cursor.sourceId !== source.info.id ||
cursor.query !== input.query ||
cursor.filterCwd !== (input.filterCwd ?? null) ||
cursor.includeArchived !== (input.includeArchived ?? false) ||
@@ -226,7 +259,7 @@ const make = Effect.gen(function* () {
nextCursor,
matches: page.items.map((item) => ({
ref: {
- sourceId: source.id,
+ sourceId: source.info.id,
threadId: page.thread.nativeThreadId,
readCursor: item.readCursor,
digest: item.digest,
@@ -244,22 +277,44 @@ const make = Effect.gen(function* () {
const query = Effect.fn("ExternalHistory.query")(function* (
input: ExternalHistoryRequest,
): Effect.fn.Return {
- const current = yield* sources();
+ let current = yield* sources(input.operation === "sources");
+ const requestedId =
+ input.operation === "read"
+ ? input.ref.sourceId
+ : input.operation === "search"
+ ? input.sourceId
+ : undefined;
+ if (
+ current.enabled &&
+ requestedId &&
+ !current.sources.some((source) => source.info.id === requestedId)
+ )
+ current = yield* sources(true);
if (input.operation === "sources")
return {
operation: "sources",
enabled: current.enabled,
- sources: current.sources.map(({ id, label, kind }) => ({ id, label, kind })),
+ sources: current.sources
+ .map(({ info }) => info)
+ .filter((info) => input.catalogVersion === 2 || info.kind !== "local_transcript"),
+ unsupported: [],
};
if (!current.enabled) return yield* new ExternalHistoryError({ reason: "disabled" });
const source = current.sources.find(
- (entry) => entry.id === (input.operation === "read" ? input.ref.sourceId : input.sourceId),
+ (entry) =>
+ entry.info.id === (input.operation === "read" ? input.ref.sourceId : input.sourceId),
);
if (!source) return yield* new ExternalHistoryError({ reason: "missing" });
- if (source.kind === "chatgpt_export")
+ if (
+ input.operation === "search" &&
+ ((input.filterCwd && !source.info.capabilities.cwdFilter) ||
+ (input.includeArchived && !source.info.capabilities.archived))
+ )
+ return yield* new ExternalHistoryError({ reason: "unsupported" });
+ if (source._tag === "local")
return yield* input.operation === "search"
- ? exports.search(source.filePath, input)
- : exports.read(source.filePath, input);
+ ? local.search(source.store, input)
+ : local.read(source.store, input);
if (input.operation === "search") return yield* nativeSearch(source, input);
const result = yield* source.native
.readSource({
diff --git a/apps/server/src/project/externalHistory/LocalHistory.ts b/apps/server/src/project/externalHistory/LocalHistory.ts
new file mode 100644
index 0000000000000..1c9e31ec75a07
--- /dev/null
+++ b/apps/server/src/project/externalHistory/LocalHistory.ts
@@ -0,0 +1,194 @@
+import {
+ ExternalHistoryError,
+ type ExternalHistoryMatch,
+ type ExternalHistoryRequest,
+ type ExternalHistoryResponse,
+} from "@t3tools/contracts";
+import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
+import * as Digest from "effect/crypto/Digest";
+import * as Hex from "effect/encoding/Hex";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import {
+ boundedTextEnd,
+ isTextBoundary,
+} from "../conversationSummaries/conversationSummarySources.ts";
+import {
+ makeThreadCache,
+ type LocalHistoryStore,
+ type LocalThreadRef,
+} from "./LocalHistoryStore.ts";
+
+const SearchCursor = Schema.Struct({
+ v: Schema.Literal(2),
+ sourceId: Schema.String,
+ query: Schema.String,
+ filterCwd: Schema.NullOr(Schema.String),
+ afterKey: Schema.NullOr(Schema.String),
+ itemOffset: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
+ version: Schema.NullOr(Schema.String),
+});
+const ReadCursor = Schema.Struct({
+ v: Schema.Literal(2),
+ sourceId: Schema.String,
+ key: Schema.String,
+ version: Schema.String,
+ itemId: Schema.String,
+});
+const decodeSearchCursor = Schema.decodeUnknownEffect(Schema.fromJsonString(SearchCursor));
+const encodeSearchCursor = Schema.encodeSync(Schema.fromJsonString(SearchCursor));
+const decodeReadCursor = Schema.decodeUnknownEffect(Schema.fromJsonString(ReadCursor));
+const encodeReadCursor = Schema.encodeSync(Schema.fromJsonString(ReadCursor));
+const invalidCursor = () => new ExternalHistoryError({ reason: "invalid_cursor" });
+const evidence = (item: { readonly role: string; readonly text: string }) =>
+ `external-history-v2\n${item.role}\n${item.text}`;
+
+export const makeLocalHistory = Effect.gen(function* () {
+ const crypto = yield* Digest.Digest;
+ const cache = yield* makeThreadCache;
+ const digest = (text: string) =>
+ crypto.digest(Digest.sha256, new TextEncoder().encode(text)).pipe(
+ Effect.map(Hex.encode),
+ Effect.mapError((cause) => new ExternalHistoryError({ reason: "unavailable", cause })),
+ );
+ const search = Effect.fn("ExternalHistory.searchLocal")(function* (
+ store: LocalHistoryStore,
+ input: Extract,
+ ) {
+ const cursor =
+ input.cursor === undefined
+ ? undefined
+ : yield* decodeSearchCursor(input.cursor).pipe(Effect.mapError(invalidCursor));
+ if (
+ cursor &&
+ (cursor.sourceId !== input.sourceId ||
+ cursor.query !== input.query ||
+ cursor.filterCwd !== (input.filterCwd ?? null))
+ )
+ return yield* invalidCursor();
+ const refs = yield* store.listThreads;
+ const index =
+ cursor?.afterKey == null ? 0 : refs.findIndex((ref) => ref.key === cursor.afterKey);
+ if (index < 0) return yield* new ExternalHistoryError({ reason: "changed" });
+ const ref = refs[index];
+ if (!ref)
+ return {
+ operation: "search",
+ matches: [],
+ examinedItems: 0,
+ nextCursor: null,
+ } satisfies ExternalHistoryResponse;
+ const nextThread = () =>
+ refs[index + 1]
+ ? encodeSearchCursor({
+ v: 2,
+ sourceId: input.sourceId,
+ query: input.query,
+ filterCwd: input.filterCwd ?? null,
+ afterKey: refs[index + 1]!.key,
+ itemOffset: 0,
+ version: null,
+ })
+ : null;
+ const thread = yield* store.loadThread(ref);
+ if (
+ thread.origin === "t3" ||
+ (input.filterCwd &&
+ (thread.cwd === null ||
+ normalizeProjectPathForComparison(thread.cwd) !==
+ normalizeProjectPathForComparison(input.filterCwd)))
+ )
+ return {
+ operation: "search",
+ matches: [],
+ examinedItems: 0,
+ nextCursor: nextThread(),
+ } satisfies ExternalHistoryResponse;
+ const offset = cursor?.version === thread.version ? cursor.itemOffset : 0;
+ if (offset > thread.items.length) return yield* invalidCursor();
+ const end = Math.min(thread.items.length, offset + 20);
+ const pattern = new RegExp(input.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu");
+ const matches: ExternalHistoryMatch[] = [];
+ for (const item of thread.items.slice(offset, end)) {
+ const match = pattern.exec(item.text);
+ if (!match) continue;
+ let start = Math.max(0, match.index - 200);
+ if (!isTextBoundary(item.text, start)) start--;
+ const readCursor = encodeReadCursor({
+ v: 2,
+ sourceId: input.sourceId,
+ key: ref.key,
+ version: thread.version,
+ itemId: item.id,
+ });
+ if (readCursor.length > 16_384 || item.id.length > 512)
+ return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
+ matches.push({
+ ref: {
+ sourceId: input.sourceId,
+ threadId: ref.key,
+ readCursor,
+ digest: yield* digest(evidence(item)),
+ },
+ threadTitle: thread.title,
+ itemId: item.id,
+ role: item.role,
+ excerpt: item.text.slice(start, boundedTextEnd(item.text, start, 2_000)),
+ textOffset: start,
+ totalChars: item.text.length,
+ });
+ }
+ return {
+ operation: "search",
+ matches,
+ examinedItems: end - offset,
+ nextCursor:
+ end < thread.items.length
+ ? encodeSearchCursor({
+ v: 2,
+ sourceId: input.sourceId,
+ query: input.query,
+ filterCwd: input.filterCwd ?? null,
+ afterKey: ref.key,
+ itemOffset: end,
+ version: thread.version,
+ })
+ : nextThread(),
+ } satisfies ExternalHistoryResponse;
+ });
+ const read = Effect.fn("ExternalHistory.readLocal")(function* (
+ store: LocalHistoryStore,
+ input: Extract,
+ ) {
+ const cursor = yield* decodeReadCursor(input.ref.readCursor).pipe(
+ Effect.mapError(invalidCursor),
+ );
+ if (cursor.sourceId !== input.ref.sourceId || cursor.key !== input.ref.threadId)
+ return yield* invalidCursor();
+ const ref: LocalThreadRef = { key: cursor.key, version: cursor.version, updatedAt: 0 };
+ const thread = yield* store.loadThread(ref);
+ const item = thread.items.find((item) => item.id === cursor.itemId);
+ if (
+ thread.version !== cursor.version ||
+ thread.origin === "t3" ||
+ !item ||
+ (yield* digest(evidence(item))) !== input.ref.digest
+ )
+ return yield* new ExternalHistoryError({ reason: "changed" });
+ const start = input.textOffset ?? 0;
+ if (start > item.text.length || !isTextBoundary(item.text, start))
+ return yield* invalidCursor();
+ const end = boundedTextEnd(item.text, start, input.maxChars ?? 12_000);
+ return {
+ operation: "read",
+ ref: input.ref,
+ threadTitle: thread.title,
+ role: item.role,
+ text: item.text.slice(start, end),
+ textOffset: start,
+ totalChars: item.text.length,
+ nextTextOffset: end < item.text.length ? end : null,
+ } satisfies ExternalHistoryResponse;
+ });
+ return { search, read, cache };
+});
diff --git a/apps/server/src/project/externalHistory/LocalHistoryStore.ts b/apps/server/src/project/externalHistory/LocalHistoryStore.ts
new file mode 100644
index 0000000000000..72a0cc5b1a5b5
--- /dev/null
+++ b/apps/server/src/project/externalHistory/LocalHistoryStore.ts
@@ -0,0 +1,73 @@
+import type { ExternalHistoryError } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import type * as FileSystem from "effect/FileSystem";
+import type { TranscriptItem } from "../agentTranscripts.ts";
+
+export interface LocalThreadRef {
+ readonly key: string;
+ readonly title?: string;
+ readonly cwd?: string;
+ readonly updatedAt: number;
+ readonly version: string;
+}
+
+export interface LocalThread {
+ readonly version: string;
+ readonly title: string;
+ readonly cwd: string | null;
+ readonly items: ReadonlyArray;
+ readonly origin?: "t3";
+}
+
+export interface LocalHistoryStore {
+ readonly listThreads: Effect.Effect, ExternalHistoryError>;
+ readonly loadThread: (ref: LocalThreadRef) => Effect.Effect;
+}
+
+export type ThreadCache = (
+ key: string,
+ load: Effect.Effect,
+) => Effect.Effect;
+
+export const makeThreadCache = Effect.sync((): ThreadCache => {
+ const entries = new Map();
+ let bytes = 0;
+ return Effect.fn("ExternalHistory.cachedThread")(function* (key, load) {
+ const cached = entries.get(key);
+ if (cached) {
+ entries.delete(key);
+ entries.set(key, cached);
+ return cached.thread;
+ }
+ const thread = yield* load;
+ const size = thread.items.reduce(
+ (sum, item) => sum + 2 * (item.text.length + item.id.length),
+ 0,
+ );
+ if (size <= 32 * 1024 * 1024) {
+ const replaced = entries.get(key);
+ if (replaced) bytes -= replaced.bytes;
+ entries.delete(key);
+ while (entries.size >= 64 || bytes + size > 32 * 1024 * 1024) {
+ const oldest = entries.entries().next().value;
+ if (!oldest) break;
+ entries.delete(oldest[0]);
+ bytes -= oldest[1].bytes;
+ }
+ entries.set(key, { thread, bytes: size });
+ bytes += size;
+ }
+ return thread;
+ });
+});
+
+export function fileVersion(info: FileSystem.File.Info): string {
+ return JSON.stringify({
+ size: String(info.size),
+ dev: String(info.dev),
+ ino: Option.map(info.ino, String).pipe(Option.getOrNull),
+ mtime: Option.map(info.mtime, (date) => date.getTime()).pipe(Option.getOrNull),
+ birthtime: Option.map(info.birthtime, (date) => date.getTime()).pipe(Option.getOrNull),
+ });
+}
diff --git a/apps/server/src/project/externalHistory/historySupport.test.ts b/apps/server/src/project/externalHistory/historySupport.test.ts
new file mode 100644
index 0000000000000..af9fecd81f689
--- /dev/null
+++ b/apps/server/src/project/externalHistory/historySupport.test.ts
@@ -0,0 +1,18 @@
+import { expect, it } from "vite-plus/test";
+import { ExternalHistoryError } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import { BUILT_IN_DRIVERS } from "../../provider/builtInDrivers.ts";
+import { makeHistorySupport } from "./historySupport.ts";
+
+it("requires a history decision for every built-in driver", () => {
+ const open = () => Effect.fail(new ExternalHistoryError({ reason: "missing" }));
+ const table = makeHistorySupport({
+ claudeAgent: open,
+ pi: open,
+ opencode: open,
+ openCodeRoots: () => Effect.succeed([]),
+ });
+ expect(Object.keys(table).toSorted()).toEqual(
+ BUILT_IN_DRIVERS.map((driver) => driver.driverKind).toSorted(),
+ );
+});
diff --git a/apps/server/src/project/externalHistory/historySupport.ts b/apps/server/src/project/externalHistory/historySupport.ts
new file mode 100644
index 0000000000000..195c138afeb9d
--- /dev/null
+++ b/apps/server/src/project/externalHistory/historySupport.ts
@@ -0,0 +1,89 @@
+import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
+import type { ExternalHistoryError } from "@t3tools/contracts";
+import { piHome, resolvePiSessionsDir } from "@t3tools/provider-pi/server/usage";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { resolveClaudeRuntime } from "../../provider/Drivers/ClaudeHome.ts";
+import type { LocalHistoryStore, ThreadCache } from "./LocalHistoryStore.ts";
+
+interface Profile {
+ readonly instanceId: ProviderInstanceId;
+ readonly config: unknown;
+ readonly environment: NodeJS.ProcessEnv;
+ readonly stateDir: string;
+}
+
+type StoreFactory = (
+ root: string,
+ cache: ThreadCache,
+) => Effect.Effect;
+type HistorySupport = { readonly label: string } & (
+ | { readonly kind: "native" }
+ | {
+ readonly kind: "local";
+ readonly roots: (
+ profile: Profile,
+ ) => Effect.Effect, never, FileSystem.FileSystem | Path.Path>;
+ readonly open: StoreFactory;
+ }
+ | { readonly kind: "unsupported"; readonly reason: string }
+);
+
+const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
+const unsupported = (label: string): HistorySupport => ({
+ kind: "unsupported",
+ label,
+ reason: "History isn't stored on this machine in a readable format yet.",
+});
+
+export function makeHistorySupport(stores: {
+ readonly claudeAgent: StoreFactory;
+ readonly pi: StoreFactory;
+ readonly opencode: StoreFactory;
+ readonly openCodeRoots: (
+ environment: NodeJS.ProcessEnv,
+ ) => Effect.Effect, never, FileSystem.FileSystem | Path.Path>;
+}): Readonly> {
+ return {
+ codex: { kind: "native", label: "Codex" },
+ claudeAgent: {
+ kind: "local",
+ label: "Claude Code",
+ open: stores.claudeAgent,
+ roots: Effect.fn("ExternalHistory.claudeRoots")(function* (profile) {
+ const runtime = yield* resolveClaudeRuntime({
+ ...profile,
+ config: decodeClaudeSettings(profile.config ?? {}),
+ });
+ return [runtime.configDir];
+ }),
+ },
+ pi: {
+ kind: "local",
+ label: "Pi",
+ open: stores.pi,
+ roots: Effect.fn("ExternalHistory.piRoots")(function* ({ environment }) {
+ const path = yield* Path.Path;
+ const home = yield* HostProcess.HomeDirectory;
+ const platform = yield* HostProcess.Platform;
+ return [resolvePiSessionsDir(environment, piHome(environment, home, platform), path)];
+ }),
+ },
+ opencode: {
+ kind: "local",
+ label: "OpenCode",
+ open: stores.opencode,
+ roots: (profile) => stores.openCodeRoots(profile.environment),
+ },
+ grok: unsupported("Grok"),
+ antigravity: unsupported("Antigravity"),
+ cursor: unsupported("Cursor"),
+ kiro: unsupported("Kiro"),
+ muse: unsupported("Muse"),
+ fx: unsupported("FX"),
+ acpRegistry: unsupported("ACP agents"),
+ };
+}
From 7a264be7fc47b0985651a5a6b2a2d6437bbb82e0 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 14:04:43 -0800
Subject: [PATCH 04/15] feat(server): discover Claude Pi and OpenCode external
history
Co-Authored-By: Claude Fable 5.2
---
apps/server/src/project/agentTranscripts.ts | 6 +
.../externalHistory/ChatGptExportHistory.ts | 5 +-
.../externalHistory/ExternalHistory.test.ts | 140 +++++++++-
.../externalHistory/ExternalHistory.ts | 155 ++++++++++-
.../externalHistory/JsonlHistoryStore.test.ts | 172 ++++++++++++
.../externalHistory/JsonlHistoryStore.ts | 256 ++++++++++++++++++
.../OpenCodeHistoryStore.test.ts | 118 ++++++++
.../externalHistory/OpenCodeHistoryStore.ts | 194 +++++++++++++
packages/provider-opencode/package.json | 4 +
.../provider-opencode/src/server/usage.ts | 2 +-
10 files changed, 1038 insertions(+), 14 deletions(-)
create mode 100644 apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
create mode 100644 apps/server/src/project/externalHistory/JsonlHistoryStore.ts
create mode 100644 apps/server/src/project/externalHistory/OpenCodeHistoryStore.test.ts
create mode 100644 apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts
diff --git a/apps/server/src/project/agentTranscripts.ts b/apps/server/src/project/agentTranscripts.ts
index 24f66e996dcd1..d930bd383f478 100644
--- a/apps/server/src/project/agentTranscripts.ts
+++ b/apps/server/src/project/agentTranscripts.ts
@@ -153,8 +153,14 @@ export function transcriptItems(
source: "claudeAgent" | "pi",
): ReadonlyArray {
if (source === "claudeAgent" && isHiddenClaudeRecord(record)) return [];
+ if (source === "pi" && record.type !== "message") return [];
+ if (source === "claudeAgent" && record.type !== "user" && record.type !== "assistant") return [];
const nativeId = source === "pi" ? record.id : record.uuid;
if (!nativeId) return [];
+ if (source === "pi" && record.message?.role === "toolResult") {
+ const text = extractText(record.message.content);
+ return text ? [{ id: nativeId, role: "tool", text }] : [];
+ }
const conversation = conversationItem(record, source);
const items: TranscriptItem[] = conversation ? [{ ...conversation, id: nativeId }] : [];
const content = record.message?.content;
diff --git a/apps/server/src/project/externalHistory/ChatGptExportHistory.ts b/apps/server/src/project/externalHistory/ChatGptExportHistory.ts
index 592d0b525c116..f45c9a43a5357 100644
--- a/apps/server/src/project/externalHistory/ChatGptExportHistory.ts
+++ b/apps/server/src/project/externalHistory/ChatGptExportHistory.ts
@@ -299,7 +299,7 @@ export const makeChatGptExportStore = Effect.fn("ChatGptExportHistory.open")(fun
const match = /^(0|[1-9][0-9]*):(.+)$/.exec(ref.key);
if (!match || !Number.isSafeInteger(Number(match[1])))
return yield* new ExternalHistoryError({ reason: "invalid_cursor" });
- return yield* cache(
+ const thread = yield* cache(
`${filePath}\0${ref.key}\0${version}`,
Effect.gen(function* () {
const result = yield* readConversation(filePath, Number(match[1]), version);
@@ -313,6 +313,9 @@ export const makeChatGptExportStore = Effect.fn("ChatGptExportHistory.open")(fun
};
}),
);
+ if (fileVersion(yield* fs.stat(filePath)) !== version)
+ return yield* new ExternalHistoryError({ reason: "changed" });
+ return thread;
}, Effect.mapError(mapError));
return { listThreads, loadThread } satisfies LocalHistoryStore;
});
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.test.ts b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
index 63446ebfc0630..e4d70119f0a59 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.test.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
@@ -16,6 +16,7 @@ import {
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Stream from "effect/Stream";
import * as ServerConfig from "../../config.ts";
@@ -123,17 +124,32 @@ const makeFixture = Effect.fn("ExternalHistory.test.fixture")(function* () {
instances: [instance] as Array,
settings: { enabled: true, chatGptExportPaths: [] } as ExternalHistorySettings,
registryReads: 0,
+ providerInstances: DEFAULT_SERVER_SETTINGS.providerInstances,
};
+ const fs = yield* FileSystem.FileSystem;
+ const enumerations: string[] = [];
+ const countingFs = FileSystem.make({
+ ...fs,
+ readDirectory: (path) => {
+ enumerations.push(path);
+ return fs.readDirectory(path);
+ },
+ });
+ const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-history-home-" });
const service = yield* ExternalHistory.ExternalHistory.pipe(
Effect.provide(
ExternalHistory.layer.pipe(
Layer.provide(
Layer.mergeAll(
+ Layer.succeed(FileSystem.FileSystem, countingFs),
+ Layer.succeed(HostProcess.HomeDirectory, home),
+ Layer.succeed(HostProcess.Environment, { HOME: home }),
ServerConfig.layerTest("/operational-project", { prefix: "t3-external-config-" }),
Layer.mock(ServerSettings.ServerSettingsService)({
getSettings: Effect.sync(() => ({
...DEFAULT_SERVER_SETTINGS,
externalHistory: state.settings,
+ providerInstances: state.providerInstances,
})),
}),
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
@@ -147,19 +163,99 @@ const makeFixture = Effect.fn("ExternalHistory.test.fixture")(function* () {
),
),
);
- return { service, state, calls, instance, native };
+ return { service, state, calls, instance, native, home, enumerations };
});
it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history service", (it) => {
+ it.effect(
+ "discovers default and disabled profiles with no paths, excludes T3 homes, and keeps page requests out of discovery",
+ () =>
+ Effect.gen(function* () {
+ const { service, state, home, enumerations } = yield* makeFixture();
+ const fs = yield* FileSystem.FileSystem;
+ const claude = `${home}/.claude`;
+ const pi = `${home}/.pi/agent/sessions`;
+ const managed = `${home}/.t3/userdata/providers/inference/claude`;
+ for (const root of [claude, managed]) {
+ yield* fs.makeDirectory(`${root}/projects/p`, { recursive: true });
+ yield* fs.writeFileString(
+ `${root}/projects/p/session.jsonl`,
+ JSON.stringify({ type: "user", uuid: "claude", message: { content: "needle" } }) + "\n",
+ );
+ }
+ yield* fs.makeDirectory(`${pi}/p`, { recursive: true });
+ yield* fs.writeFileString(
+ `${pi}/p/pi.jsonl`,
+ JSON.stringify({
+ type: "message",
+ id: "pi",
+ message: { role: "user", content: "needle" },
+ }) + "\n",
+ );
+ yield* fs.makeDirectory(`${home}/Downloads`, { recursive: true });
+ state.providerInstances = {
+ ...state.providerInstances,
+ [ProviderInstanceId.make("disabled-claude")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ enabled: false,
+ config: { homePath: claude },
+ },
+ [ProviderInstanceId.make("opencode-downloads")]: {
+ driver: ProviderDriverKind.make("opencode"),
+ environment: [
+ { name: "OPENCODE_DATA_DIR", value: `${home}/Downloads`, sensitive: false },
+ ],
+ },
+ [ProviderInstanceId.make("opencode-inference")]: {
+ driver: ProviderDriverKind.make("opencode"),
+ environment: [{ name: "OPENCODE_DATA_DIR", value: managed, sensitive: false }],
+ },
+ [ProviderInstanceId.make("managed-claude")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ config: { homePath: managed },
+ },
+ };
+ const catalog = yield* service.query({ operation: "sources", catalogVersion: 2 });
+ if (catalog.operation !== "sources") return yield* Effect.die("wrong response");
+ expect(catalog.sources.map((source) => source.provider).toSorted()).toEqual([
+ "claudeAgent",
+ "codex",
+ "pi",
+ ]);
+ expect(catalog.unsupported.find((source) => source.provider === "grok")?.reason).toContain(
+ "readable format",
+ );
+ const legacy = yield* service.query({ operation: "sources" });
+ expect(
+ legacy.operation === "sources" && legacy.sources.map((source) => source.kind),
+ ).toEqual(["codex"]);
+ const source = catalog.sources.find((source) => source.provider === "claudeAgent")!;
+ const page = yield* service.query({
+ operation: "search",
+ sourceId: source.id,
+ query: "needle",
+ });
+ if (page.operation !== "search") return yield* Effect.die("wrong response");
+ const count = enumerations.length;
+ yield* service.query({ operation: "search", sourceId: source.id, query: "other" });
+ yield* service.query({ operation: "read", ref: page.matches[0]!.ref });
+ expect(enumerations).toHaveLength(count);
+ expect(
+ enumerations.some(
+ (path) => path.includes("Downloads") || path.includes("providers/inference"),
+ ),
+ ).toBe(false);
+ }),
+ );
+
it.effect("checks the environment opt-in before touching providers and on every read", () =>
Effect.gen(function* () {
const { service, state, calls } = yield* makeFixture();
state.settings = { enabled: false, chatGptExportPaths: [] };
- expect(yield* service.query({ operation: "sources" })).toEqual({
+ expect(yield* service.query({ operation: "sources" })).toMatchObject({
operation: "sources",
enabled: false,
sources: [],
- unsupported: [],
});
expect(state.registryReads).toBe(0);
const error = yield* service
@@ -280,12 +376,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
.pipe(Effect.flip);
expect(unsupported.reason).toBe("unsupported");
state.instances = [{ ...instance, enabled: false }];
- expect(yield* service.query({ operation: "sources" })).toEqual({
- operation: "sources",
- enabled: true,
- sources: [],
- unsupported: [],
- });
+ const disabledInstance = yield* service.query({ operation: "sources" });
+ expect(disabledInstance.operation === "sources" && disabledInstance.sources.length).toBe(1);
}),
);
@@ -333,6 +425,36 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
}).pipe(Effect.scoped),
);
+ it.effect("excludes native Codex sessions marked as T3 conversations", () =>
+ Effect.gen(function* () {
+ const { service, state, instance, native, calls } = yield* makeFixture();
+ state.instances = [
+ {
+ ...instance,
+ nativeHistory: {
+ ...native,
+ list: () =>
+ Effect.succeed({ threads: [{ ...thread, originator: "T3 Code" }], nextCursor: null }),
+ },
+ },
+ ];
+ const catalog = yield* service.query({ operation: "sources" });
+ if (catalog.operation !== "sources") return yield* Effect.die("wrong response");
+ const result = yield* service.query({
+ operation: "search",
+ sourceId: catalog.sources[0]!.id,
+ query: "needle",
+ });
+ expect(result).toEqual({
+ operation: "search",
+ matches: [],
+ examinedItems: 0,
+ nextCursor: null,
+ });
+ expect(calls.items).toEqual([]);
+ }),
+ );
+
it.effect("rejects a native continuation that cannot fit the public cursor budget", () =>
Effect.gen(function* () {
const { service, state, instance, native } = yield* makeFixture();
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.ts b/apps/server/src/project/externalHistory/ExternalHistory.ts
index c3a3898801afc..f59c5b36fd3c8 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.ts
@@ -4,6 +4,7 @@ import {
type ExternalHistoryResponse,
type ExternalHistorySource,
ProviderDriverKind,
+ ProviderInstanceId,
} from "@t3tools/contracts";
import type {
ProviderNativeHistory,
@@ -17,12 +18,20 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
+import * as Option from "effect/Option";
+import * as Semaphore from "effect/Semaphore";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
+import { resolveOpenCodeDataDirs } from "@t3tools/provider-opencode/server/usage";
import * as ServerConfig from "../../config.ts";
import * as ServerSettings from "../../serverSettings.ts";
import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
import { makeChatGptExportStore } from "./ChatGptExportHistory.ts";
import { makeLocalHistory } from "./LocalHistory.ts";
+import { makeHistorySupport } from "./historySupport.ts";
+import { makeClaudeHistoryStore, makePiHistoryStore } from "./JsonlHistoryStore.ts";
+import { makeOpenCodeHistoryStore } from "./OpenCodeHistoryStore.ts";
import type { LocalHistoryStore } from "./LocalHistoryStore.ts";
export class ExternalHistory extends Context.Service<
@@ -67,6 +76,7 @@ const encodeIdentity = Schema.encodeSync(
type Source = { readonly info: ExternalHistorySource } & (
| { readonly _tag: "native"; readonly native: ProviderNativeHistory; readonly readers: number }
| { readonly _tag: "local"; readonly store: LocalHistoryStore }
+ | { readonly _tag: "unavailable" }
);
// A signed-out or older instance must not hide a shared home that another enabled instance
@@ -100,6 +110,39 @@ const make = Effect.gen(function* () {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const local = yield* makeLocalHistory;
+ const home = yield* HostProcess.HomeDirectory;
+ const environment = yield* HostProcess.Environment;
+ const catalogLock = yield* Semaphore.make(1);
+ const excludedRoots = Effect.forEach(
+ [config.baseDir, config.stateDir, path.join(home, ".t3"), path.join(home, "Downloads")],
+ (dir) => fs.realPath(dir).pipe(Effect.orElseSucceed(() => path.resolve(dir))),
+ );
+ const inside = (root: string, parent: string) => {
+ const relative = path.relative(parent, root);
+ return (
+ relative === "" ||
+ (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
+ );
+ };
+ const supportTable = makeHistorySupport({
+ claudeAgent: makeClaudeHistoryStore,
+ pi: makePiHistoryStore,
+ opencode: makeOpenCodeHistoryStore,
+ openCodeRoots: Effect.fn("ExternalHistory.openCodeRoots")(function* (env) {
+ const roots: string[] = [];
+ const excluded = yield* excludedRoots;
+ for (const root of yield* resolveOpenCodeDataDirs(env)) {
+ const canonical = yield* fs.realPath(root).pipe(Effect.option);
+ if (Option.isNone(canonical) || excluded.some((parent) => inside(canonical.value, parent)))
+ continue;
+ const dir = canonical.value;
+ const names = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [] as string[]));
+ const databases = names.filter((name) => /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(name));
+ roots.push(...(databases.length ? databases.map((name) => path.join(dir, name)) : [dir]));
+ }
+ return roots;
+ }),
+ });
let localCatalog = new Map();
let fingerprint: string | undefined;
const digest = (text: string) =>
@@ -119,7 +162,7 @@ const make = Effect.gen(function* () {
}
const result = new Map();
for (const instance of yield* registry.listInstances) {
- if (!instance.enabled || instance.driverKind !== "codex" || !instance.nativeHistory) continue;
+ if (supportTable[instance.driverKind]?.kind !== "native" || !instance.nativeHistory) continue;
const id = `codex:${yield* digest(encodeIdentity(instance.continuationIdentity))}`;
const existing = result.get(id);
if (existing?._tag === "native" && existing.readers < 3) {
@@ -146,6 +189,87 @@ const make = Effect.gen(function* () {
const nextFingerprint = encodeFingerprint([value.externalHistory, value.providerInstances]);
if (refresh || fingerprint !== nextFingerprint) {
const next = new Map();
+ const excluded = yield* excludedRoots;
+ const seen = new Set();
+ for (const [driver, support] of Object.entries(supportTable)) {
+ if (support.kind !== "local") continue;
+ const profiles = [
+ {
+ instanceId: ProviderInstanceId.make(driver),
+ config: undefined,
+ environment: undefined,
+ },
+ ...Object.entries(value.providerInstances)
+ .filter(([, profile]) => profile.driver === driver)
+ .map(([id, profile]) => ({
+ instanceId: ProviderInstanceId.make(id),
+ config: profile.config,
+ environment: profile.environment,
+ })),
+ ];
+ for (const profile of profiles) {
+ const env = yield* mergeProviderInstanceEnvironment(
+ profile.environment,
+ environment,
+ ).pipe(Effect.provideService(HostProcess.HomeDirectory, home));
+ const roots = yield* support
+ .roots({
+ instanceId: profile.instanceId,
+ config: profile.config,
+ environment: env,
+ stateDir: config.stateDir,
+ })
+ .pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, path),
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ );
+ for (const root of roots) {
+ const canonical = yield* fs.realPath(root).pipe(Effect.option);
+ if (Option.isNone(canonical) || excluded.some((dir) => inside(canonical.value, dir)))
+ continue;
+ const stat = yield* fs.stat(canonical.value).pipe(Effect.option);
+ if (Option.isNone(stat)) continue;
+ const identity = `${driver}:${stat.value.dev}:${Option.map(stat.value.ino, String).pipe(Option.getOrElse(() => canonical.value))}`;
+ if (seen.has(identity)) continue;
+ seen.add(identity);
+ const id = `local:${driver}:${yield* digest(canonical.value)}`;
+ const existing = localCatalog.get(id);
+ if (existing?._tag === "local") {
+ next.set(id, existing);
+ continue;
+ }
+ const opened = yield* support
+ .open(canonical.value, local.cache)
+ .pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, path),
+ Effect.result,
+ );
+ if (opened._tag === "Failure" && opened.failure.reason === "missing") continue;
+ const info: ExternalHistorySource = {
+ id,
+ label: `${support.label} · ${canonical.value}`,
+ kind: "local_transcript",
+ provider: ProviderDriverKind.make(driver),
+ capabilities: { cwdFilter: true, archived: false },
+ status:
+ opened._tag === "Success"
+ ? "ready"
+ : opened.failure.reason === "unsupported"
+ ? "unsupported_format"
+ : "unavailable",
+ ...(opened._tag === "Failure" ? { message: opened.failure.message } : {}),
+ };
+ next.set(
+ id,
+ opened._tag === "Success"
+ ? { _tag: "local", info, store: opened.success }
+ : { _tag: "unavailable", info },
+ );
+ }
+ }
+ }
for (const configured of value.externalHistory.chatGptExportPaths) {
const filePath = path.resolve(config.cwd, configured);
const id = `chatgpt:${yield* digest(filePath)}`;
@@ -171,7 +295,7 @@ const make = Effect.gen(function* () {
}
for (const [id, source] of localCatalog) result.set(id, source);
return { enabled: true, sources: [...result.values()] };
- });
+ }, catalogLock.withPermit);
const nativeSearch = Effect.fn("ExternalHistory.nativeSearch")(function* (
source: Extract,
@@ -236,6 +360,15 @@ const make = Effect.gen(function* () {
? afterPhase()
: encodeCursor({ ...cursor, listCursor: page.nextCursor }),
} satisfies ExternalHistoryResponse;
+ if (thread.originator === "T3 Code")
+ return {
+ operation: "search",
+ matches: [],
+ examinedItems: 0,
+ nextCursor: yield* page.nextCursor === null
+ ? afterPhase()
+ : encodeCursor({ ...cursor, listCursor: page.nextCursor }),
+ } satisfies ExternalHistoryResponse;
cursor = { ...cursor, threadId: thread.nativeThreadId, listCursor: page.nextCursor };
}
const page = yield* source.native
@@ -297,7 +430,17 @@ const make = Effect.gen(function* () {
sources: current.sources
.map(({ info }) => info)
.filter((info) => input.catalogVersion === 2 || info.kind !== "local_transcript"),
- unsupported: [],
+ unsupported: Object.entries(supportTable).flatMap(([provider, support]) =>
+ support.kind === "unsupported"
+ ? [
+ {
+ provider: ProviderDriverKind.make(provider),
+ label: support.label,
+ reason: support.reason,
+ },
+ ]
+ : [],
+ ),
};
if (!current.enabled) return yield* new ExternalHistoryError({ reason: "disabled" });
const source = current.sources.find(
@@ -305,6 +448,10 @@ const make = Effect.gen(function* () {
entry.info.id === (input.operation === "read" ? input.ref.sourceId : input.sourceId),
);
if (!source) return yield* new ExternalHistoryError({ reason: "missing" });
+ if (source._tag === "unavailable")
+ return yield* new ExternalHistoryError({
+ reason: source.info.status === "unsupported_format" ? "unsupported" : "unavailable",
+ });
if (
input.operation === "search" &&
((input.filterCwd && !source.info.capabilities.cwdFilter) ||
@@ -326,6 +473,8 @@ const make = Effect.gen(function* () {
...(input.maxChars === undefined ? {} : { maxChars: input.maxChars }),
})
.pipe(Effect.mapError((cause) => new ExternalHistoryError({ reason: cause.reason, cause })));
+ if (result.thread.originator === "T3 Code")
+ return yield* new ExternalHistoryError({ reason: "missing" });
return {
operation: "read",
ref: input.ref,
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
new file mode 100644
index 0000000000000..43205c76a2c58
--- /dev/null
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
@@ -0,0 +1,172 @@
+import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import { makeClaudeHistoryStore, makePiHistoryStore } from "./JsonlHistoryStore.ts";
+import { makeLocalHistory } from "./LocalHistory.ts";
+
+it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL histories", (it) => {
+ it.effect(
+ "finds old Claude evidence before the import window and preserves verified native IDs without re-enumeration",
+ () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const root = yield* fs.makeTempDirectoryScoped();
+ const project = `${root}/projects/project`;
+ yield* fs.makeDirectory(project, { recursive: true });
+ const file = `${project}/session.jsonl`;
+ const records = [
+ {
+ type: "user",
+ uuid: "old-evidence",
+ sessionId: "session",
+ cwd: "/project",
+ timestamp: "2020-01-01T00:00:00Z",
+ message: { content: "needle from the distant past" },
+ },
+ ...Array.from({ length: 230 }, (_, index) => ({
+ type: "assistant",
+ uuid: `new-${index}`,
+ message: { content: `Other message ${index}` },
+ })),
+ { type: "custom-title", customTitle: "Renamed" },
+ { type: "ai-title", aiTitle: "Generated" },
+ ];
+ yield* fs.writeFileString(file, records.map((r) => JSON.stringify(r)).join("\n") + "\n");
+ let enumerations = 0;
+ const counting = FileSystem.make({
+ ...fs,
+ readDirectory: (path) => {
+ enumerations++;
+ return fs.readDirectory(path);
+ },
+ });
+ const history = yield* makeLocalHistory;
+ const store = yield* makeClaudeHistoryStore(root, history.cache).pipe(
+ Effect.provideService(FileSystem.FileSystem, counting),
+ );
+ const request = {
+ operation: "search",
+ sourceId: "claude",
+ query: "needle",
+ filterCwd: "/project",
+ } as const;
+ const first = yield* history.search(store, request);
+ expect(first.matches.map((m) => m.itemId)).toEqual(["old-evidence"]);
+ expect(first.matches[0]?.threadTitle).toBe("Renamed");
+ const enumerated = enumerations;
+ const second = yield* history.search(store, { ...request, cursor: first.nextCursor! });
+ expect(second.examinedItems).toBe(20);
+ const read = yield* history.read(store, { operation: "read", ref: first.matches[0]!.ref });
+ expect(read.text).toBe("needle from the distant past");
+ expect(enumerations).toBe(enumerated);
+ expect(
+ (yield* history
+ .read(store, {
+ operation: "read",
+ ref: { ...first.matches[0]!.ref, digest: "0".repeat(64) },
+ })
+ .pipe(Effect.flip)).reason,
+ ).toBe("changed");
+ yield* fs.writeFileString(
+ file,
+ records.map((r) => JSON.stringify(r)).join("\n") +
+ "\n" +
+ JSON.stringify({
+ type: "assistant",
+ uuid: "append",
+ message: { content: "needle appended" },
+ }) +
+ "\n",
+ );
+ expect(
+ (yield* history
+ .read(store, { operation: "read", ref: first.matches[0]!.ref })
+ .pipe(Effect.flip)).reason,
+ ).toBe("changed");
+ const restarted = yield* history.search(store, { ...request, cursor: second.nextCursor! });
+ expect(restarted.matches.map((m) => m.itemId)).toEqual(["old-evidence"]);
+ expect(
+ (yield* store
+ .loadThread({ key: "../../outside.jsonl", version: "", updatedAt: 0 })
+ .pipe(Effect.flip)).reason,
+ ).toBe("invalid_cursor");
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("excludes T3 Claude sessions and follows the active Pi branch", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const root = yield* fs.makeTempDirectoryScoped();
+ const project = `${root}/projects/project`;
+ yield* fs.makeDirectory(project, { recursive: true });
+ yield* fs.writeFileString(
+ `${project}/t3.jsonl`,
+ JSON.stringify({
+ type: "user",
+ uuid: "t3",
+ entrypoint: "t3-code",
+ message: { content: "needle" },
+ }) + "\n",
+ );
+ const history = yield* makeLocalHistory;
+ const claude = yield* makeClaudeHistoryStore(root, history.cache);
+ expect(
+ (yield* history.search(claude, {
+ operation: "search",
+ sourceId: "claude",
+ query: "needle",
+ })).matches,
+ ).toEqual([]);
+ const sessions = `${root}/sessions/project`;
+ yield* fs.makeDirectory(sessions, { recursive: true });
+ const piFile = `${sessions}/pi.jsonl`;
+ yield* fs.writeFileString(
+ piFile,
+ [
+ { type: "session", id: "pi", cwd: "/project" },
+ {
+ type: "message",
+ id: "user",
+ parentId: null,
+ message: { role: "user", content: "Question" },
+ },
+ {
+ type: "message",
+ id: "abandoned",
+ parentId: "user",
+ message: { role: "assistant", content: "needle abandoned" },
+ },
+ {
+ type: "message",
+ id: "active",
+ parentId: "user",
+ message: { role: "assistant", content: "needle active" },
+ },
+ { type: "session_info", id: "name", parentId: "active", name: "Pi renamed" },
+ ]
+ .map((r) => JSON.stringify(r))
+ .join("\n") + "\n",
+ );
+ const pi = yield* makePiHistoryStore(`${root}/sessions`, history.cache);
+ const page = yield* history.search(pi, {
+ operation: "search",
+ sourceId: "pi",
+ query: "needle",
+ });
+ expect(page.matches.map((m) => m.itemId)).toEqual(["active"]);
+ expect(page.matches[0]?.threadTitle).toBe("Pi renamed");
+ yield* fs.writeFileString(
+ piFile,
+ JSON.stringify({ type: "message", message: { role: "user", content: "legacy" } }) + "\n",
+ );
+ expect(
+ (yield* history
+ .search(pi, { operation: "search", sourceId: "pi", query: "legacy" })
+ .pipe(Effect.flip)).reason,
+ ).toBe("unsupported");
+ }).pipe(Effect.scoped),
+ );
+});
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
new file mode 100644
index 0000000000000..16448d5ae3c13
--- /dev/null
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
@@ -0,0 +1,256 @@
+import { ExternalHistoryError } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+import {
+ TranscriptRecord,
+ conversationItem,
+ activePiBranch,
+ emptyClaudeFields,
+ foldClaudeFields,
+ transcriptItems,
+ type DecodedTranscriptRecord,
+ type TranscriptItem,
+} from "../agentTranscripts.ts";
+import {
+ createTranscriptJsonReader,
+ createTranscriptJsonSelector,
+ TranscriptJsonLimitError,
+} from "../AgentSessionJson.ts";
+import {
+ fileVersion,
+ type LocalThreadRef,
+ type LocalHistoryStore,
+ type ThreadCache,
+} from "./LocalHistoryStore.ts";
+
+const decodeRecord = Schema.decodeUnknownOption(TranscriptRecord);
+const selectRecord = createTranscriptJsonSelector(TranscriptRecord);
+const isHistoryError = Schema.is(ExternalHistoryError);
+const isLimitError = Schema.is(TranscriptJsonLimitError);
+const mapError = (cause: unknown) =>
+ isHistoryError(cause)
+ ? cause
+ : new ExternalHistoryError({
+ reason: isLimitError(cause)
+ ? "limit_exceeded"
+ : PlatformError.isPlatformError(cause)
+ ? cause.reason._tag === "NotFound"
+ ? "missing"
+ : "unavailable"
+ : "unsupported",
+ cause,
+ });
+
+function projectThread(
+ records: ReadonlyArray,
+ source: "claudeAgent" | "pi",
+ version: string,
+) {
+ const origin = source === "claudeAgent" && records.some((r) => r.entrypoint === "t3-code");
+ const fields = records.reduce(foldClaudeFields, emptyClaudeFields);
+ const items = new Map();
+ for (const record of source === "pi" ? activePiBranch(records) : records) {
+ const visible = transcriptItems(record, source);
+ const conversation = conversationItem(record, source);
+ if (!origin && conversation && !conversation.id)
+ throw new ExternalHistoryError({ reason: "unsupported" });
+ for (const item of visible) {
+ const previous = items.get(item.id);
+ if (previous && (previous.role !== item.role || previous.text !== item.text))
+ throw new ExternalHistoryError({ reason: "unsupported" });
+ items.set(item.id, item);
+ }
+ }
+ const firstUser = [...items.values()].find((item) => item.role === "user");
+ const title =
+ source === "pi"
+ ? records.findLast((r) => r.type === "session_info")?.name?.trim()
+ : (fields.customTitle ?? fields.title);
+ return {
+ version,
+ title: title || firstUser?.text.split("\n")[0]?.slice(0, 100) || "Conversation",
+ cwd: records.find((r) => r.cwd?.trim())?.cwd ?? null,
+ items: origin ? [] : [...items.values()],
+ ...(origin ? { origin: "t3" as const } : {}),
+ };
+}
+
+const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(function* (
+ root: string,
+ source: "claudeAgent" | "pi",
+ cache: ThreadCache,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const directory = yield* fs.realPath(
+ source === "claudeAgent" ? path.join(root, "projects") : root,
+ );
+ if ((yield* fs.stat(directory)).type !== "Directory")
+ return yield* new ExternalHistoryError({ reason: "unsupported" });
+ let listing:
+ | { directories: ReadonlyMap; refs: ReadonlyArray }
+ | undefined;
+ const listThreads = Effect.gen(function* () {
+ if (listing) {
+ let unchanged = true;
+ for (const [dir, version] of listing.directories) {
+ const stat = yield* fs.stat(dir).pipe(Effect.option);
+ if (Option.isNone(stat) || fileVersion(stat.value) !== version) {
+ unchanged = false;
+ break;
+ }
+ }
+ if (unchanged) return listing.refs;
+ }
+ const directories = new Map();
+ const refs: LocalThreadRef[] = [];
+ const rootInfo = yield* fs.stat(directory);
+ directories.set(directory, fileVersion(rootInfo));
+ const children = yield* fs.readDirectory(directory);
+ if (children.length > 50_000)
+ return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
+ for (const child of children) {
+ const childPath = path.join(directory, child);
+ if (yield* Effect.isSuccess(fs.readLink(childPath))) continue;
+ const info = yield* fs.stat(childPath);
+ const files =
+ info.type === "Directory"
+ ? yield* fs.readDirectory(childPath)
+ : source === "pi" && child.endsWith(".jsonl")
+ ? [child]
+ : [];
+ if (info.type === "Directory") directories.set(childPath, fileVersion(info));
+ if (directories.size > 50_000 || files.length + refs.length > 50_000)
+ return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
+ for (const name of files) {
+ if (!name.endsWith(".jsonl")) continue;
+ const filePath = info.type === "Directory" ? path.join(childPath, name) : childPath;
+ if (yield* Effect.isSuccess(fs.readLink(filePath))) continue;
+ const stat = yield* fs.stat(filePath);
+ if (stat.type !== "File") continue;
+ const key = path.relative(directory, filePath).split(path.sep).join("/");
+ if (key.length > 512) return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
+ refs.push({
+ key,
+ updatedAt: Option.map(stat.mtime, (d) => d.getTime()).pipe(Option.getOrElse(() => 0)),
+ version: fileVersion(stat),
+ });
+ }
+ }
+ refs.sort((a, b) => b.updatedAt - a.updatedAt || a.key.localeCompare(b.key));
+ listing = { directories, refs };
+ return refs;
+ }).pipe(Effect.mapError(mapError));
+
+ const loadThread = Effect.fn("ExternalHistory.loadJsonlThread")(function* (ref: LocalThreadRef) {
+ if (
+ ref.key.includes("\\") ||
+ ref.key.split("/").some((part) => !part || part === "." || part === "..") ||
+ path.isAbsolute(ref.key) ||
+ !ref.key.endsWith(".jsonl")
+ )
+ return yield* new ExternalHistoryError({ reason: "invalid_cursor" });
+ const filePath = path.join(directory, ...ref.key.split("/"));
+ const canonical = yield* fs.realPath(filePath);
+ if (canonical !== filePath) return yield* new ExternalHistoryError({ reason: "changed" });
+ const stat = yield* fs.stat(filePath);
+ if (stat.type !== "File") return yield* new ExternalHistoryError({ reason: "unsupported" });
+ const version = fileVersion(stat);
+ const thread = yield* cache(
+ `${source}\0${filePath}\0${version}`,
+ Effect.scoped(
+ Effect.gen(function* () {
+ const file = yield* fs.open(filePath, { flag: "r" });
+ if (fileVersion(yield* file.stat) !== version)
+ return yield* new ExternalHistoryError({ reason: "changed" });
+ const records: DecodedTranscriptRecord[] = [];
+ let allocated = 0;
+ let reader = createTranscriptJsonReader(
+ (bytes) => {
+ allocated += bytes;
+ if (allocated > 32 * 1024 * 1024)
+ throw new TranscriptJsonLimitError({ message: "Transcript text limit" });
+ },
+ (path) => path[0] !== "payload" && selectRecord(path),
+ );
+ const finishRecord = () => {
+ const decoded = decodeRecord(reader.finish());
+ if (Option.isNone(decoded)) throw new ExternalHistoryError({ reason: "unsupported" });
+ if (records.length >= 100_000)
+ throw new ExternalHistoryError({ reason: "limit_exceeded" });
+ records.push(decoded.value);
+ reader = createTranscriptJsonReader(
+ (bytes) => {
+ allocated += bytes;
+ if (allocated > 32 * 1024 * 1024)
+ throw new TranscriptJsonLimitError({ message: "Transcript text limit" });
+ },
+ (path) => path[0] !== "payload" && selectRecord(path),
+ );
+ };
+ const decoder = new TextDecoder("utf-8", { fatal: true });
+ let pending = false;
+ let readBytes = 0;
+ const consume = (text: string) => {
+ let start = 0;
+ for (let index = 0; index < text.length; index++) {
+ if (text[index] !== "\n") continue;
+ const part = text.slice(start, index);
+ if (pending || part.trim()) {
+ reader.write(part);
+ finishRecord();
+ }
+ pending = false;
+ start = index + 1;
+ }
+ const tail = text.slice(start);
+ if (tail.trim() || pending) {
+ reader.write(tail);
+ pending = true;
+ }
+ };
+ while (true) {
+ const bytes = yield* file.readAlloc(64 * 1024);
+ if (Option.isNone(bytes)) break;
+ readBytes += bytes.value.length;
+ if (readBytes > 4 * 1024 * 1024 * 1024)
+ return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
+ yield* Effect.try({
+ try: () => consume(decoder.decode(bytes.value, { stream: true })),
+ catch: mapError,
+ });
+ }
+ yield* Effect.try({
+ try: () => {
+ consume(decoder.decode());
+ if (pending) finishRecord();
+ },
+ catch: mapError,
+ });
+ if (
+ fileVersion(yield* file.stat) !== version ||
+ fileVersion(yield* fs.stat(filePath)) !== version
+ )
+ return yield* new ExternalHistoryError({ reason: "changed" });
+ return yield* Effect.try({
+ try: () => projectThread(records, source, version),
+ catch: mapError,
+ });
+ }),
+ ).pipe(Effect.mapError(mapError)),
+ );
+ if (fileVersion(yield* fs.stat(filePath)) !== version)
+ return yield* new ExternalHistoryError({ reason: "changed" });
+ return thread;
+ }, Effect.mapError(mapError));
+ return { listThreads, loadThread } satisfies LocalHistoryStore;
+}, Effect.mapError(mapError));
+
+export const makeClaudeHistoryStore = (root: string, cache: ThreadCache) =>
+ makeJsonlHistoryStore(root, "claudeAgent", cache);
+export const makePiHistoryStore = (root: string, cache: ThreadCache) =>
+ makeJsonlHistoryStore(root, "pi", cache);
diff --git a/apps/server/src/project/externalHistory/OpenCodeHistoryStore.test.ts b/apps/server/src/project/externalHistory/OpenCodeHistoryStore.test.ts
new file mode 100644
index 0000000000000..09bad8c783fcf
--- /dev/null
+++ b/apps/server/src/project/externalHistory/OpenCodeHistoryStore.test.ts
@@ -0,0 +1,118 @@
+import * as NodeSqlite from "node:sqlite";
+import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import { makeOpenCodeHistoryStore } from "./OpenCodeHistoryStore.ts";
+import { makeLocalHistory } from "./LocalHistory.ts";
+
+it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("OpenCode local history", (it) => {
+ it.effect(
+ "reads current-schema user, assistant and tool text without reasoning or database writes",
+ () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const root = yield* fs.makeTempDirectoryScoped();
+ const file = `${root}/opencode.db`;
+ yield* Effect.sync(() => {
+ const db = new NodeSqlite.DatabaseSync(file);
+ try {
+ db.exec(
+ "CREATE TABLE session_v2 (id TEXT PRIMARY KEY, directory TEXT, title TEXT, parent_id TEXT, time_updated INTEGER); CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, data TEXT)",
+ );
+ db.prepare("INSERT INTO session_v2 VALUES (?, ?, ?, ?, ?)").run(
+ "session",
+ "/project",
+ "OpenCode conversation",
+ null,
+ 1,
+ );
+ const insert = db.prepare("INSERT INTO session_message VALUES (?, 'session', ?, ?, ?)");
+ insert.run("user", "user", 0, JSON.stringify({ text: "needle user" }));
+ insert.run(
+ "assistant",
+ "assistant",
+ 1,
+ JSON.stringify({
+ content: [
+ {
+ type: "reasoning",
+ text: "hidden needle reasoning",
+ native: { opaque: "x".repeat(20_000_000) },
+ },
+ { type: "text", text: "needle answer" },
+ {
+ type: "tool",
+ id: "call",
+ name: "read",
+ state: {
+ input: { path: "src/index.ts" },
+ content: [{ type: "text", text: "needle tool output" }],
+ },
+ },
+ ],
+ }),
+ );
+ } finally {
+ db.close();
+ }
+ });
+ const before = yield* fs.readFile(file);
+ const history = yield* makeLocalHistory;
+ const store = yield* makeOpenCodeHistoryStore(file, history.cache);
+ const result = yield* history.search(store, {
+ operation: "search",
+ sourceId: "opencode",
+ query: "needle",
+ });
+ expect(result.matches.map((m) => [m.itemId, m.role])).toEqual([
+ ["user", "user"],
+ ["assistant", "assistant"],
+ ["assistant/tool/call", "tool"],
+ ]);
+ expect(result.matches.map((m) => m.excerpt).join("\n")).not.toContain("reasoning");
+ expect(
+ (yield* history.read(store, { operation: "read", ref: result.matches[2]!.ref })).text,
+ ).toBe("read\nneedle tool output");
+ expect(Buffer.from(yield* fs.readFile(file)).equals(Buffer.from(before))).toBe(true);
+ expect(yield* fs.readDirectory(root)).toEqual(["opencode.db"]);
+ yield* Effect.sync(() => {
+ const db = new NodeSqlite.DatabaseSync(file);
+ try {
+ db.prepare("UPDATE session_message SET data = ? WHERE id = 'user'").run(
+ JSON.stringify({ text: "Changed" }),
+ );
+ } finally {
+ db.close();
+ }
+ });
+ expect(
+ (yield* history
+ .read(store, { operation: "read", ref: result.matches[0]!.ref })
+ .pipe(Effect.flip)).reason,
+ ).toBe("changed");
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("rejects older OpenCode databases as an unsupported format", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const root = yield* fs.makeTempDirectoryScoped();
+ const file = `${root}/opencode.db`;
+ yield* Effect.sync(() => {
+ const db = new NodeSqlite.DatabaseSync(file);
+ try {
+ db.exec("CREATE TABLE message (id TEXT, data TEXT)");
+ } finally {
+ db.close();
+ }
+ });
+ const history = yield* makeLocalHistory;
+ expect((yield* makeOpenCodeHistoryStore(file, history.cache).pipe(Effect.flip)).reason).toBe(
+ "unsupported",
+ );
+ }).pipe(Effect.scoped),
+ );
+});
diff --git a/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts b/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts
new file mode 100644
index 0000000000000..f87ffb67f97b8
--- /dev/null
+++ b/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts
@@ -0,0 +1,194 @@
+import * as NodeSqlite from "node:sqlite";
+import { ExternalHistoryError } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import type { TranscriptItem } from "../agentTranscripts.ts";
+import {
+ fileVersion,
+ type LocalHistoryStore,
+ type LocalThreadRef,
+ type ThreadCache,
+} from "./LocalHistoryStore.ts";
+
+const decodeSessions = Schema.decodeUnknownSync(
+ Schema.Array(
+ Schema.Struct({
+ id: Schema.String,
+ title: Schema.NullOr(Schema.String),
+ directory: Schema.String,
+ time_updated: Schema.Number,
+ }),
+ ),
+);
+const decodeMessages = Schema.decodeUnknownSync(
+ Schema.Array(
+ Schema.Struct({
+ id: Schema.String,
+ type: Schema.Literals(["user", "assistant"]),
+ visible: Schema.String,
+ }),
+ ),
+);
+const decodeVisible = Schema.decodeUnknownSync(
+ Schema.fromJsonString(
+ Schema.Struct({
+ text: Schema.optionalKey(Schema.String),
+ content: Schema.optionalKey(
+ Schema.Array(
+ Schema.Struct({
+ type: Schema.Literals(["text", "tool"]),
+ text: Schema.NullOr(Schema.String),
+ id: Schema.NullOr(Schema.String),
+ name: Schema.NullOr(Schema.String),
+ output: Schema.Array(Schema.String),
+ }),
+ ),
+ ),
+ }),
+ ),
+);
+const isHistoryError = Schema.is(ExternalHistoryError);
+const mapError = (cause: unknown) =>
+ isHistoryError(cause) ? cause : new ExternalHistoryError({ reason: "unavailable", cause });
+
+export const makeOpenCodeHistoryStore = Effect.fn("ExternalHistory.openOpenCodeStore")(function* (
+ filePath: string,
+ cache: ThreadCache,
+) {
+ const fs = yield* FileSystem.FileSystem;
+ if ((yield* fs.stat(filePath)).type !== "File")
+ return yield* new ExternalHistoryError({ reason: "unsupported" });
+ const withDatabase = (read: (db: NodeSqlite.DatabaseSync) => A) =>
+ Effect.try({
+ try: () => {
+ const db = new NodeSqlite.DatabaseSync(filePath, { readOnly: true });
+ try {
+ db.exec("PRAGMA busy_timeout = 100");
+ return read(db);
+ } finally {
+ db.close();
+ }
+ },
+ catch: mapError,
+ });
+ yield* withDatabase((db) => {
+ for (const [table, required] of [
+ ["session_v2", ["id", "directory", "title", "parent_id", "time_updated"]],
+ ["session_message", ["id", "session_id", "type", "seq", "data"]],
+ ] as const) {
+ const columns = new Set(
+ db
+ .prepare(`PRAGMA table_info(${table})`)
+ .all()
+ .map((row) => row["name"]),
+ );
+ if (required.some((column) => !columns.has(column)))
+ throw new ExternalHistoryError({ reason: "unsupported" });
+ }
+ });
+ const version = Effect.gen(function* () {
+ const database = fileVersion(yield* fs.stat(filePath));
+ const wal = yield* fs.stat(`${filePath}-wal`).pipe(
+ Effect.map(fileVersion),
+ Effect.catchTags({
+ PlatformError: (error) =>
+ error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error),
+ }),
+ );
+ return database + wal;
+ }).pipe(Effect.mapError(mapError));
+ let listing: { version: string; refs: ReadonlyArray } | undefined;
+ const listThreads = Effect.gen(function* () {
+ const current = yield* version;
+ if (listing?.version === current) return listing.refs;
+ const rows = yield* withDatabase((db) =>
+ decodeSessions(
+ db
+ .prepare(
+ "SELECT id, title, directory, time_updated FROM session_v2 WHERE parent_id IS NULL ORDER BY time_updated DESC, id DESC LIMIT 50001",
+ )
+ .all(),
+ ),
+ );
+ if (rows.length > 50_000) return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
+ const refs = rows.map((row) => ({
+ key: row.id,
+ title: row.title ?? "Conversation",
+ cwd: row.directory,
+ updatedAt: row.time_updated,
+ version: current,
+ }));
+ listing = { version: current, refs };
+ return refs;
+ });
+ const loadThread = Effect.fn("ExternalHistory.loadOpenCodeThread")(function* (
+ ref: LocalThreadRef,
+ ) {
+ const current = yield* version;
+ const thread = yield* cache(
+ `${filePath}\0${ref.key}\0${current}`,
+ withDatabase((db) => {
+ db.exec("BEGIN");
+ const session = decodeSessions(
+ db
+ .prepare(
+ "SELECT id, title, directory, time_updated FROM session_v2 WHERE id = ? AND parent_id IS NULL",
+ )
+ .all(ref.key),
+ )[0];
+ if (!session) throw new ExternalHistoryError({ reason: "missing" });
+ const rows = decodeMessages(
+ db
+ .prepare(`SELECT id, type, CASE WHEN json_valid(data) THEN
+ CASE WHEN type = 'user' THEN json_object('text', data ->> '$.text')
+ WHEN json_type(data, '$.content') = 'array' THEN json_object('content', json((
+ SELECT coalesce(json_group_array(json_object(
+ 'type', c.value ->> '$.type', 'text', c.value ->> '$.text',
+ 'id', c.value ->> '$.id', 'name', c.value ->> '$.name',
+ 'output', json((SELECT coalesce(json_group_array(p.value ->> '$.text'), '[]')
+ FROM json_each(c.value, '$.state.content') p WHERE p.value ->> '$.type' = 'text'))
+ )), '[]') FROM json_each(data, '$.content') c WHERE c.value ->> '$.type' IN ('text', 'tool')
+ ))) END END AS visible
+ FROM session_message WHERE session_id = ? AND type IN ('user', 'assistant') ORDER BY seq, id LIMIT 100001`)
+ .all(ref.key),
+ );
+ if (rows.length > 100_000) throw new ExternalHistoryError({ reason: "limit_exceeded" });
+ const items: TranscriptItem[] = [];
+ let chars = 0;
+ for (const row of rows) {
+ if (row.visible.length > 16 * 1024 * 1024)
+ throw new ExternalHistoryError({ reason: "limit_exceeded" });
+ const visible = decodeVisible(row.visible);
+ const text =
+ row.type === "user"
+ ? visible.text
+ : visible.content
+ ?.filter((part) => part.type === "text")
+ .map((part) => part.text ?? "")
+ .join("\n");
+ if (text) items.push({ id: row.id, role: row.type, text });
+ for (const part of visible.content ?? []) {
+ if (part.type !== "tool" || !part.id) continue;
+ const text = [part.name, ...part.output].filter(Boolean).join("\n");
+ if (text) items.push({ id: `${row.id}/tool/${part.id}`, role: "tool", text });
+ }
+ chars +=
+ (text?.length ?? 0) +
+ (visible.content?.reduce((sum, part) => sum + part.output.join("\n").length, 0) ?? 0);
+ if (chars > 16 * 1024 * 1024)
+ throw new ExternalHistoryError({ reason: "limit_exceeded" });
+ }
+ return {
+ version: current,
+ title: session.title ?? "Conversation",
+ cwd: session.directory,
+ items,
+ };
+ }),
+ );
+ if ((yield* version) !== current) return yield* new ExternalHistoryError({ reason: "changed" });
+ return thread;
+ });
+ return { listThreads, loadThread } satisfies LocalHistoryStore;
+}, Effect.mapError(mapError));
diff --git a/packages/provider-opencode/package.json b/packages/provider-opencode/package.json
index 3f569ca2c508b..7289e293a352a 100644
--- a/packages/provider-opencode/package.json
+++ b/packages/provider-opencode/package.json
@@ -19,6 +19,10 @@
"types": "./src/testing.ts",
"import": "./src/testing.ts"
},
+ "./server/usage": {
+ "types": "./src/server/usage.ts",
+ "import": "./src/server/usage.ts"
+ },
"./server/OpenCodeRuntime": {
"types": "./src/server/OpenCodeRuntime.ts",
"import": "./src/server/OpenCodeRuntime.ts"
diff --git a/packages/provider-opencode/src/server/usage.ts b/packages/provider-opencode/src/server/usage.ts
index 4ae7b6df358e9..8358f14af10bb 100644
--- a/packages/provider-opencode/src/server/usage.ts
+++ b/packages/provider-opencode/src/server/usage.ts
@@ -314,7 +314,7 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* (
return { files, missing: !found && !error, error };
});
-const resolveOpenCodeDataDirs = Effect.fn("resolveOpenCodeDataDirs")(function* (
+export const resolveOpenCodeDataDirs = Effect.fn("resolveOpenCodeDataDirs")(function* (
environment: NodeJS.ProcessEnv,
) {
const fileSystem = yield* FileSystem.FileSystem;
From 74b3e408701ea2e8a6191eeb44fa987c9f34cc6f Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 14:27:39 -0800
Subject: [PATCH 05/15] feat(clients): lead external history with discovered
conversations
Show source availability and supported filters before search on web and mobile. Keep manual ChatGPT exports in a collapsed section and describe automatic discovery to agents and users.
Co-Authored-By: Claude Fable 5.2
---
.../ExternalHistoryRouteScreen.test.tsx | 13 +-
.../ExternalHistoryRouteScreen.tsx | 282 +++++++++-------
.../SettingsServerControlsRouteScreen.tsx | 2 +-
.../src/mcp/toolkits/externalHistory/tools.ts | 2 +-
.../externalHistory/ExternalHistory.test.ts | 10 +-
.../externalHistory/ExternalHistory.ts | 14 +-
.../src/provider/agentContextBudget.json | 2 +-
.../ExternalHistoryDialog.browser.test.tsx | 39 ++-
.../externalHistory/ExternalHistoryDialog.tsx | 305 +++++++++++-------
.../src/components/settings/ZackSettings.tsx | 2 +-
docs/user/external-history.md | 30 +-
.../src/externalHistory/index.test.ts | 16 +
.../src/externalHistory/index.ts | 26 +-
13 files changed, 479 insertions(+), 264 deletions(-)
create mode 100644 packages/client-runtime/src/externalHistory/index.test.ts
diff --git a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx
index 4ebbd9d8a99c0..a0be498c637e5 100644
--- a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx
+++ b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx
@@ -117,7 +117,16 @@ vi.mock("../../state/query", () => ({
? {
operation: "sources",
enabled: true,
- sources: [{ id: "codex-history", label: "Native Codex", kind: "codex" }],
+ sources: [
+ {
+ id: "codex-history",
+ label: "Native Codex",
+ kind: "codex",
+ capabilities: { cwdFilter: true, archived: true },
+ status: "ready",
+ },
+ ],
+ unsupported: [],
}
: request.operation === "search"
? {
@@ -200,6 +209,8 @@ it("keeps remote history read-only without settings permission and stops reads w
expect(container.textContent).toContain("Update this server");
act(() => registry.set(config, configFor(true)));
expect(button("Allow external history search").disabled).toBe(true);
+ expect(container.querySelector('[aria-label="ChatGPT export paths"]')).toBeNull();
+ await click("Add a ChatGPT export");
expect(
container.querySelector('[aria-label="ChatGPT export paths"]')?.disabled,
).toBe(true);
diff --git a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
index eb23d85ad52fc..d89bed200e468 100644
--- a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
+++ b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
@@ -1,15 +1,20 @@
import { useAtomValue } from "@effect/atom-react";
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
-import { externalHistoryRangeLabel } from "@t3tools/client-runtime/externalHistory";
+import {
+ externalHistoryRangeLabel,
+ externalHistorySearchFilters,
+ externalHistorySourceStatusLabel,
+} from "@t3tools/client-runtime/externalHistory";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import {
EnvironmentId,
type ExternalHistoryMatch,
type ExternalHistoryRequest,
type ExternalHistorySettings,
+ type ExternalHistorySource,
type ProjectId,
} from "@t3tools/contracts";
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import { ScrollView, View } from "react-native";
import { AppBar } from "../../components/ui/AppBar";
@@ -70,8 +75,8 @@ export function ExternalHistoryRouteScreen(
contentContainerStyle={{ padding: 20, paddingBottom: 40, gap: 16 }}
>
- Search Codex conversations and configured ChatGPT exports on this environment. Open
- the original evidence behind each match.
+ Search agent conversations found on this environment. Open the original evidence
+ behind each match.
- {config.settings.externalHistory.enabled ? (
-
- ) : (
-
- External history search is off for this environment.
-
- )}
+ >
+ {config.settings.externalHistory.enabled ? (
+
+ ) : (
+
+ External history search is off for this environment.
+
+ )}
+
) : !supported ? (
@@ -108,11 +116,13 @@ export function ExternalHistoryRouteScreen(
function HistorySettings(props: {
readonly environmentId: EnvironmentId;
readonly settings: ExternalHistorySettings;
+ readonly children: ReactNode;
}) {
const canWrite = useAtomValue(
serverEnvironment.updateSettings.permissionAtom(props.environmentId),
);
const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
+ const [exportsOpen, setExportsOpen] = useState(false);
const [paths, setPaths] = useState(props.settings.chatGptExportPaths.join("\n"));
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
@@ -128,60 +138,74 @@ function HistorySettings(props: {
if (result._tag === "Failure") setError(String(squashAtomCommandFailure(result)));
};
return (
-
- void save({ ...props.settings, enabled })}
- />
-
- Makes these sources available to this environment's connected clients and agents with thread
- access. Source files stay in their existing locations.
-
- ChatGPT export paths
-
-
- Paths refer to files on the T3 server. Remove a path to stop offering that export.
-
+
+
+ void save({ ...props.settings, enabled })}
+ />
+
+ Makes these sources available to this environment's connected clients and agents with
+ thread access. Source files stay in their existing locations.
+
+ {!canWrite ? (
+
+ This connection cannot change environment settings.
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {props.children}
- void save({
- ...props.settings,
- chatGptExportPaths: [
- ...new Set(
- paths
- .split("\n")
- .map((path) => path.trim())
- .filter(Boolean),
- ),
- ],
- })
- }
+ onPress={() => setExportsOpen((open) => !open)}
/>
- {!canWrite ? (
-
- This connection cannot change environment settings.
-
- ) : null}
- {error ? (
-
- {error}
-
+ {exportsOpen ? (
+
+ ChatGPT export paths
+
+
+ Paths refer to files on the T3 server. Remove a path to stop offering that export.
+
+
+ void save({
+ ...props.settings,
+ chatGptExportPaths: [
+ ...new Set(
+ paths
+ .split("\n")
+ .map((path) => path.trim())
+ .filter(Boolean),
+ ),
+ ],
+ })
+ }
+ />
+
) : null}
);
@@ -189,7 +213,10 @@ function HistorySettings(props: {
function HistorySources(props: Scope) {
const query = useEnvironmentQuery(
- externalHistory.query({ environmentId: props.environmentId, input: { operation: "sources" } }),
+ externalHistory.query({
+ environmentId: props.environmentId,
+ input: { operation: "sources", catalogVersion: 2 },
+ }),
);
const [selected, setSelected] = useState(null);
if (query.error)
@@ -207,13 +234,39 @@ function HistorySources(props: Scope) {
);
const sources = query.data.sources;
- const source = sources.find((item) => item.id === selected) ?? sources[0];
- if (!source)
- return (
+ const source =
+ sources.find((item) => item.id === selected && item.status === "ready") ??
+ sources.find((item) => item.status === "ready");
+ return (
+
-
- No sources are configured. Enable a Codex provider or add a ChatGPT export path.
-
+ Found on this environment
+ {sources.length === 0 ? (
+
+ No local history found. Refresh after adding conversations on this server.
+
+ ) : null}
+ {sources.map((item, index) => (
+
+ setSelected(item.id)}
+ />
+
+ {externalHistorySourceStatusLabel(item.status)}
+ {item.message ? ` · ${item.message}` : ""}
+
+
+ ))}
+ {query.data.unsupported.map((item) => (
+
+ {item.label} · {item.reason}
+
+ ))}
- );
- return (
-
-
- History source
- {sources.map((item, index) => (
- setSelected(item.id)}
- />
- ))}
-
-
+ {source ? (
+
+ ) : null}
);
}
-function HistorySearch(props: Scope & { readonly sourceId: string; readonly codex: boolean }) {
+function HistorySearch(
+ props: Scope & {
+ readonly sourceId: string;
+ readonly capabilities: ExternalHistorySource["capabilities"];
+ },
+) {
const [text, setText] = useState("");
const [filterCwd, setFilterCwd] = useState("");
const [includeArchived, setIncludeArchived] = useState(false);
@@ -260,9 +304,7 @@ function HistorySearch(props: Scope & { readonly sourceId: string; readonly code
operation: "search",
sourceId: props.sourceId,
query: text.trim(),
- ...(props.codex
- ? { includeArchived, ...(filterCwd.trim() ? { filterCwd: filterCwd.trim() } : {}) }
- : {}),
+ ...externalHistorySearchFilters(props.capabilities, { filterCwd, includeArchived }),
});
};
return (
@@ -276,13 +318,16 @@ function HistorySearch(props: Scope & { readonly sourceId: string; readonly code
returnKeyType="search"
onSubmitEditing={submit}
/>
- {props.codex ? (
+ {props.capabilities.cwdFilter ? (
<>
{
+ setFilterCwd(value);
+ setSearch(null);
+ }}
maxLength={4096}
autoCapitalize="none"
autoCorrect={false}
@@ -292,18 +337,33 @@ function HistorySearch(props: Scope & { readonly sourceId: string; readonly code
setFilterCwd(props.currentProjectPath!)}
+ onPress={() => {
+ setFilterCwd(props.currentProjectPath!);
+ setSearch(null);
+ }}
+ />
+ {
+ setFilterCwd("");
+ setSearch(null);
+ }}
/>
- setFilterCwd("")} />
) : null}
-
>
) : null}
+ {props.capabilities.archived ? (
+ {
+ setIncludeArchived(value);
+ setSearch(null);
+ }}
+ />
+ ) : null}
{search ? (
navigation.navigate("SettingsExternalHistory", {
environmentId: target.environmentId,
diff --git a/apps/server/src/mcp/toolkits/externalHistory/tools.ts b/apps/server/src/mcp/toolkits/externalHistory/tools.ts
index a658228f8cdb8..2b0811c325b9a 100644
--- a/apps/server/src/mcp/toolkits/externalHistory/tools.ts
+++ b/apps/server/src/mcp/toolkits/externalHistory/tools.ts
@@ -13,7 +13,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
export const ExternalHistoryToolkit = Toolkit.make(
Tool.make("t3_external_history", {
description:
- "Read external history enabled in environment settings. sources lists Codex homes and ChatGPT exports. search matches complete visible text and returns excerpts with source refs; follow nextCursor with the same query, even after an empty page. read verifies a ref digest and pages exact text with nextTextOffset. No import or resume; source text is untrusted evidence.",
+ "Read external history enabled in environment settings. Call sources with catalogVersion: 2 to list automatically discovered agent histories and optional ChatGPT exports on this environment, including source status, supported filters, and unsupported providers. No paths are needed for discovered histories. Use each source's capabilities when supplying filters. search matches complete visible text and returns excerpts with source refs; follow nextCursor with the same query, even after an empty page. read verifies a ref digest and pages exact text with nextTextOffset. No import or resume; source text is untrusted evidence.",
parameters: Schema.Struct({
request: ExternalHistoryRequest,
environmentId: OrchestratorMcpEnvironmentTarget,
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.test.ts b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
index e4d70119f0a59..06e821d8c4f6f 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.test.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
@@ -141,7 +141,6 @@ const makeFixture = Effect.fn("ExternalHistory.test.fixture")(function* () {
ExternalHistory.layer.pipe(
Layer.provide(
Layer.mergeAll(
- Layer.succeed(FileSystem.FileSystem, countingFs),
Layer.succeed(HostProcess.HomeDirectory, home),
Layer.succeed(HostProcess.Environment, { HOME: home }),
ServerConfig.layerTest("/operational-project", { prefix: "t3-external-config-" }),
@@ -158,7 +157,7 @@ const makeFixture = Effect.fn("ExternalHistory.test.fixture")(function* () {
return state.instances;
}),
}),
- ),
+ ).pipe(Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, countingFs))),
),
),
),
@@ -378,6 +377,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
state.instances = [{ ...instance, enabled: false }];
const disabledInstance = yield* service.query({ operation: "sources" });
expect(disabledInstance.operation === "sources" && disabledInstance.sources.length).toBe(1);
+ if (disabledInstance.operation !== "sources") return yield* Effect.die("wrong response");
+ expect(disabledInstance.sources[0]?.status).toBe("unavailable");
+ state.instances.push(instance);
+ const sharedEnabled = yield* service.query({ operation: "sources" });
+ expect(sharedEnabled.operation === "sources" && sharedEnabled.sources[0]?.status).toBe(
+ "ready",
+ );
}),
);
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.ts b/apps/server/src/project/externalHistory/ExternalHistory.ts
index f59c5b36fd3c8..09c5395358a4f 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.ts
@@ -165,25 +165,29 @@ const make = Effect.gen(function* () {
if (supportTable[instance.driverKind]?.kind !== "native" || !instance.nativeHistory) continue;
const id = `codex:${yield* digest(encodeIdentity(instance.continuationIdentity))}`;
const existing = result.get(id);
+ if (!instance.enabled && existing) continue;
if (existing?._tag === "native" && existing.readers < 3) {
result.set(id, {
...existing,
native: alternateNativeReader(existing.native, instance.nativeHistory),
readers: existing.readers + 1,
});
- } else if (existing === undefined)
+ } else if (existing === undefined || existing._tag === "unavailable")
result.set(id, {
- _tag: "native",
+ ...(instance.enabled
+ ? { _tag: "native" as const, native: instance.nativeHistory, readers: 1 }
+ : { _tag: "unavailable" as const }),
info: {
id,
label: instance.displayName ?? "Codex",
kind: "codex",
provider: ProviderDriverKind.make("codex"),
capabilities: { cwdFilter: true, archived: true },
- status: "ready",
+ status: instance.enabled ? "ready" : "unavailable",
+ ...(!instance.enabled
+ ? { message: "Enable this Codex provider to read its history." }
+ : {}),
},
- native: instance.nativeHistory,
- readers: 1,
});
}
const nextFingerprint = encodeFingerprint([value.externalHistory, value.providerInstances]);
diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json
index 1b354139143e4..ec45155e91358 100644
--- a/apps/server/src/provider/agentContextBudget.json
+++ b/apps/server/src/provider/agentContextBudget.json
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 8646,
"codexAdditionalContextBytes": 10372,
- "mcpToolListJsonBytes": 67419
+ "mcpToolListJsonBytes": 67725
}
diff --git a/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx b/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx
index d6fe2b8d3047a..493c2f06d50e0 100644
--- a/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx
+++ b/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx
@@ -4,6 +4,7 @@ import {
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
ExternalHistoryError,
+ ProviderDriverKind,
type ExternalHistoryRequest,
type ExternalHistoryResponse,
} from "@t3tools/contracts";
@@ -82,7 +83,30 @@ it("continues an empty bounded search, opens exact source pages, and hides revok
state.sources = success({
operation: "sources",
enabled: true,
- sources: [{ id: ref.sourceId, label: "Local Codex history", kind: "codex" }],
+ sources: [
+ {
+ id: ref.sourceId,
+ label: "Local Codex history",
+ kind: "codex",
+ capabilities: { cwdFilter: true, archived: true },
+ status: "ready",
+ },
+ {
+ id: "claude",
+ label: "Claude Code history",
+ kind: "local_transcript",
+ provider: ProviderDriverKind.make("claudeAgent"),
+ capabilities: { cwdFilter: true, archived: false },
+ status: "ready",
+ },
+ ],
+ unsupported: [
+ {
+ provider: ProviderDriverKind.make("grok"),
+ label: "Grok",
+ reason: "No verified history reader.",
+ },
+ ],
});
state.first = success({
operation: "search",
@@ -138,12 +162,16 @@ it("continues an empty bounded search, opens exact source pages, and hides revok
,
);
await page.getByRole("button", { name: "External history", exact: true }).click();
+ expect(state.requests).toContainEqual({ operation: "sources", catalogVersion: 2 });
+ await expect.element(page.getByText("Grok · No verified history reader.")).toBeVisible();
+ await expect.element(page.getByLabelText("ChatGPT export paths")).not.toBeVisible();
+ await page.getByText("Add a ChatGPT export", { exact: true }).click();
await expect
.element(page.getByRole("textbox", { name: "ChatGPT export paths" }))
.toHaveValue("/exports/conversations.json");
await page.getByRole("textbox", { name: "Search external history" }).fill("source");
await page.getByRole("button", { name: "This project", exact: true }).click();
- await page.getByRole("switch", { name: "Include archived Codex threads" }).click();
+ await page.getByRole("switch", { name: "Include archived threads" }).click();
await page.getByRole("button", { name: "Search", exact: true }).click();
await expect
.element(page.getByText("No matches on this page. Continue to search the next page."))
@@ -192,6 +220,13 @@ it("continues an empty bounded search, opens exact source pages, and hides revok
.element(page.getByRole("switch", { name: "Allow external history search" }))
.toBeDisabled();
await expect.element(page.getByRole("textbox", { name: "ChatGPT export paths" })).toBeDisabled();
+ await page.getByRole("button", { name: "Claude Code history", exact: true }).click();
+ await expect
+ .element(page.getByRole("textbox", { name: "History directory filter" }))
+ .toBeVisible();
+ await expect
+ .element(page.getByRole("switch", { name: "Include archived threads" }))
+ .not.toBeInTheDocument();
registry.set(config, configFor(false));
await expect
.element(page.getByText("External history search is off for this environment."))
diff --git a/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx b/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx
index b7b0a3c50a2bc..f24874dbec1cf 100644
--- a/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx
+++ b/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx
@@ -1,13 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
-import { externalHistoryRangeLabel } from "@t3tools/client-runtime/externalHistory";
+import {
+ externalHistoryRangeLabel,
+ externalHistorySearchFilters,
+ externalHistorySourceStatusLabel,
+} from "@t3tools/client-runtime/externalHistory";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import type {
EnvironmentId,
ExternalHistoryMatch,
ExternalHistoryRequest,
ExternalHistorySettings,
+ ExternalHistorySource,
} from "@t3tools/contracts";
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import { externalHistory } from "../../state/externalHistory";
import { useEnvironmentQuery } from "../../state/query";
@@ -23,7 +28,6 @@ import {
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";
-import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { Switch } from "../ui/switch";
import { Textarea } from "../ui/textarea";
@@ -47,8 +51,8 @@ export function ExternalHistoryButton(props: {
External history
- Search Codex conversations and configured ChatGPT exports on this environment. Open
- the original evidence behind each match.
+ Search agent conversations found on this environment. Open the original evidence
+ behind each match.
@@ -63,6 +67,7 @@ export function ExternalHistoryButton(props: {
function ExternalHistorySettingsEditor(props: {
readonly environmentId: EnvironmentId;
readonly settings: ExternalHistorySettings;
+ readonly children: ReactNode;
}) {
const canWrite = useAtomValue(
serverEnvironment.updateSettings.permissionAtom(props.environmentId),
@@ -83,71 +88,81 @@ function ExternalHistorySettingsEditor(props: {
if (result._tag === "Failure") setError(String(squashAtomCommandFailure(result)));
};
return (
-
-
- Allow external history search
- void save({ ...props.settings, enabled })}
- />
-
-
- Makes these sources available to this environment's connected clients and agents with thread
- access. Source files stay in their existing locations.
-
-
- ChatGPT export paths
-
-
- Paths refer to files on the T3 server, including when you connect remotely. Remove a path to
- stop offering that export.
-
-
-
- void save({
- ...props.settings,
- chatGptExportPaths: [
- ...new Set(
- paths
- .split("\n")
- .map((path) => path.trim())
- .filter(Boolean),
- ),
- ],
- })
- }
- >
- Save export paths
-
-
- {!canWrite ? (
+
+
+
+ Allow external history search
+ void save({ ...props.settings, enabled })}
+ />
+
- This connection cannot change environment settings.
+ Makes these sources available to this environment's connected clients and agents with
+ thread access. Source files stay in their existing locations.
- ) : null}
- {error ? (
-
- {error}
-
- ) : null}
-
+ {!canWrite ? (
+
+ This connection cannot change environment settings.
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {props.children}
+
+ Add a ChatGPT export
+
+
+ ChatGPT export paths
+
+
+ Paths refer to files on the T3 server, including when you connect remotely. Remove a
+ path to stop offering that export.
+
+
+
+ void save({
+ ...props.settings,
+ chatGptExportPaths: [
+ ...new Set(
+ paths
+ .split("\n")
+ .map((path) => path.trim())
+ .filter(Boolean),
+ ),
+ ],
+ })
+ }
+ >
+ Save export paths
+
+
+
+
+
);
}
@@ -169,14 +184,15 @@ function ExternalHistoryPanel(props: {
key={"settings:" + JSON.stringify(settings.chatGptExportPaths)}
environmentId={props.environmentId}
settings={settings}
- />
- {settings.enabled ? (
-
- ) : (
-
- External history search is off for this environment.
-
- )}
+ >
+ {settings.enabled ? (
+
+ ) : (
+
+ External history search is off for this environment.
+
+ )}
+
);
}
@@ -186,7 +202,10 @@ function HistorySources(props: {
readonly currentProjectPath?: string;
}) {
const query = useEnvironmentQuery(
- externalHistory.query({ environmentId: props.environmentId, input: { operation: "sources" } }),
+ externalHistory.query({
+ environmentId: props.environmentId,
+ input: { operation: "sources", catalogVersion: 2 },
+ }),
);
const [selected, setSelected] = useState(null);
if (query.error)
@@ -204,40 +223,65 @@ function HistorySources(props: {
);
const sources = query.data.sources;
- const source = sources.find((item) => item.id === selected) ?? sources[0];
- if (!source)
- return (
-
-
- No sources are configured. Enable a Codex provider or add a ChatGPT export path.
-
+ const source =
+ sources.find((item) => item.id === selected && item.status === "ready") ??
+ sources.find((item) => item.status === "ready");
+ return (
+
+
+ Found on this environment
+ {sources.length === 0 ? (
+
+ No local history found. Refresh after adding conversations on this server.
+
+ ) : null}
+
+ {query.data.unsupported.length > 0 ? (
+
+ {query.data.unsupported.map((item) => (
+
+ {item.label} · {item.reason}
+
+ ))}
+
+ ) : null}
Refresh sources
-
- );
- return (
-
-
-
- {source.label}
-
-
- {sources.map((item) => (
-
- {item.label}
-
- ))}
-
-
-
+
+ {source ? (
+
+ ) : null}
);
}
@@ -245,7 +289,7 @@ function HistorySources(props: {
function HistorySearch(props: {
readonly environmentId: EnvironmentId;
readonly sourceId: string;
- readonly codex: boolean;
+ readonly capabilities: ExternalHistorySource["capabilities"];
readonly currentProjectPath?: string;
}) {
const [text, setText] = useState("");
@@ -265,9 +309,7 @@ function HistorySearch(props: {
operation: "search",
sourceId: props.sourceId,
query: text.trim(),
- ...(props.codex
- ? { includeArchived, ...(filterCwd.trim() ? { filterCwd: filterCwd.trim() } : {}) }
- : {}),
+ ...externalHistorySearchFilters(props.capabilities, { filterCwd, includeArchived }),
});
}}
>
@@ -278,13 +320,16 @@ function HistorySearch(props: {
onChange={(event) => setText(event.target.value)}
maxLength={256}
/>
- {props.codex ? (
+ {props.capabilities.cwdFilter ? (
<>
setFilterCwd(event.target.value)}
+ onChange={(event) => {
+ setFilterCwd(event.target.value);
+ setSearch(null);
+ }}
maxLength={4096}
/>
{props.currentProjectPath ? (
@@ -293,25 +338,41 @@ function HistorySearch(props: {
type="button"
variant="ghost"
size="sm"
- onClick={() => setFilterCwd(props.currentProjectPath!)}
+ onClick={() => {
+ setFilterCwd(props.currentProjectPath!);
+ setSearch(null);
+ }}
>
This project
-
setFilterCwd("")}>
+ {
+ setFilterCwd("");
+ setSearch(null);
+ }}
+ >
All projects
) : null}
-
- Include archived Codex threads
-
-
>
) : null}
+ {props.capabilities.archived ? (
+
+ Include archived threads
+ {
+ setIncludeArchived(value);
+ setSearch(null);
+ }}
+ />
+
+ ) : null}
Search
diff --git a/apps/web/src/components/settings/ZackSettings.tsx b/apps/web/src/components/settings/ZackSettings.tsx
index a4212b6caeb03..a39b0b782d687 100644
--- a/apps/web/src/components/settings/ZackSettings.tsx
+++ b/apps/web/src/components/settings/ZackSettings.tsx
@@ -212,7 +212,7 @@ function ExternalHistoryRow() {
return (
{connectedEnvironments.map((environment) => (
diff --git a/docs/user/external-history.md b/docs/user/external-history.md
index c9c20f21cd143..003e452ebf8b3 100644
--- a/docs/user/external-history.md
+++ b/docs/user/external-history.md
@@ -1,31 +1,29 @@
# Search conversations outside T3
-Open **Settings → Labs → Zack → External history** and turn on **Allow external history search** for the selected environment. The same interface is available from conversation summaries. Web, desktop and mobile use the selected T3 server's sources.
+Open **Settings → Labs → Zack → External history** and turn on **Allow external history search** for the selected environment. The same interface is available from conversation summaries. Web, desktop, and mobile search the selected T3 server's sources, including when you connect remotely.
-External search reads existing source text. It does not import a conversation as a runnable T3 thread, resume it, generate summaries, or store a second raw transcript. Connected agents can use `t3_external_history` to discover sources, search them, and verify the original text behind a match. Delegated subagents cannot use it.
+External history finds supported agent conversations automatically. You do not need to enter paths or enable a provider for local transcript discovery. Choose a ready source, enter text, and search. **Refresh sources** checks for newly available stores.
-## Codex
+## Automatically discovered history
-Enable a Codex provider in that environment. External search uses its shared Codex home, including a configured `CODEX_HOME`, and includes interactive CLI, editor and app-server conversations. Multiple provider instances using the same history identity appear once.
+T3 checks the default and configured homes for Claude Code, Codex, Pi, and OpenCode. Shared homes appear once. Claude Code and Pi use local transcripts. OpenCode uses its current local database format; older formats appear as unsupported. Codex uses its existing app-server history reader and requires an enabled provider with a compatible installed version.
-Search across projects or supply a directory filter. Archived conversations are included only when **Include archived Codex threads** is selected. The reader requires a Codex app-server version with paged history reads. Unsupported versions and unavailable homes return an error; T3 does not fall back to resuming a conversation.
+Claude sessions marked as T3 conversations and Codex conversations with a T3 originator are excluded. T3's managed homes and inference histories are excluded from local discovery. Older sessions without an origin marker can still appear. T3 never scans Downloads.
-The source must exist on the selected server. Connecting to a remote T3 server searches that server's Codex history; it does not grant access to conversations stored only on another computer.
+Directory filters appear when the selected source supports them. Archived conversations are available only for sources that support that filter. Providers without a verified readable history format appear with a reason under the source list.
-## ChatGPT
+## Add a ChatGPT export
-[Export your ChatGPT data](https://help.openai.com/en/articles/7260999-exporting-your-chatgpt-history-and-data), extract the archive, and add the path to `conversations.json` under **ChatGPT export paths**. Larger exports can contain numbered conversation JSON files; add each file on a separate line. Paths refer to files on the T3 server, including for remote connections.
+ChatGPT exports are optional. Expand **Add a ChatGPT export**, then add the path to an extracted `conversations.json` file under **ChatGPT export paths**. [Export your ChatGPT data](https://help.openai.com/en/articles/7260999-exporting-your-chatgpt-history-and-data) first. Numbered conversation JSON files can each be added on a separate line. Paths refer to files on the selected T3 server.
-T3 supports the conversation-array export format with a message mapping and current branch. It searches visible user, assistant and tool text on that branch. Media objects, hidden messages and abandoned branches are excluded. An unsupported export shape or incomplete branch returns an error rather than mixing unrelated branches.
+T3 supports the conversation-array export format with a message mapping and current branch. It searches visible user, assistant, and tool text on that branch. Media objects, hidden messages, and abandoned branches are excluded. An unsupported export shape or incomplete branch returns an error.
-This is access to the configured export, not live synchronization with the ChatGPT app. Replace the export to include newer conversations, then search again. Remove its configured path to stop offering it.
+Replace an export to include newer conversations, then search again. Remove its configured path to stop offering it.
-## Search and evidence
+## Search and verify evidence
-Search uses a literal, case-insensitive match against complete visible message or tool text before returning excerpts. Each request examines a bounded page. A page with no matches can still have a continuation; use **Continue search** until the search ends. The examined count describes that page, not the entire archive.
+Search matches literal text without regard to case across complete visible messages and tool evidence. It can find old messages and messages earlier in long conversations. Each request examines a bounded page. A page with no matches can still have a continuation; use **Continue search** until the search ends. The examined count describes that page, not the entire archive.
-Open a match to read its complete visible evidence in pages. Codex tool records use stable metadata headings followed by their visible output or diff; offsets refer to that representation. A digest binds the result to the complete evidence. Changed or missing sources require a new search. Pages preserve Unicode boundaries; offsets count UTF-16 code units.
+Open a match to read its original visible evidence in pages. A digest binds the result to the complete evidence. Changed or missing sources require a new search. Appending to a local transcript can invalidate an earlier result. Pages preserve Unicode boundaries. Oversized evidence produces an explicit capacity error. Very large ChatGPT exports can take longer because loading a later conversation reads the file prefix again.
-Codex responses and selected ChatGPT conversations have an 8 MiB reader budget. Oversized evidence produces an explicit capacity error. ChatGPT files are streamed and unrelated records are discarded; reaching a later conversation currently scans the file prefix again, so very large exports can take longer across successive pages. There is no background import or disk index.
-
-Turning external history off removes it from this environment's search interface and refuses subsequent agent reads. It does not delete the original Codex history or ChatGPT export.
+Connected agents can use `t3_external_history` to discover sources, search, and verify original text. Delegated subagents cannot use it. Search does not import or resume a conversation, generate summaries, or create a disk index. Turning it off refuses subsequent searches and agent reads without deleting the original history.
diff --git a/packages/client-runtime/src/externalHistory/index.test.ts b/packages/client-runtime/src/externalHistory/index.test.ts
new file mode 100644
index 0000000000000..3b84457c15ed8
--- /dev/null
+++ b/packages/client-runtime/src/externalHistory/index.test.ts
@@ -0,0 +1,16 @@
+import { expect, it } from "vite-plus/test";
+import { externalHistorySearchFilters } from "./index.ts";
+
+it.each([
+ [{ cwdFilter: false, archived: false }, {}],
+ [{ cwdFilter: true, archived: false }, { filterCwd: "/project" }],
+ [{ cwdFilter: false, archived: true }, { includeArchived: true }],
+ [
+ { cwdFilter: true, archived: true },
+ { filterCwd: "/project", includeArchived: true },
+ ],
+])("sends only filters allowed by source capabilities %j", (capabilities, expected) => {
+ expect(
+ externalHistorySearchFilters(capabilities, { filterCwd: " /project ", includeArchived: true }),
+ ).toEqual(expected);
+});
diff --git a/packages/client-runtime/src/externalHistory/index.ts b/packages/client-runtime/src/externalHistory/index.ts
index 18808eb120905..bfa97bc58412a 100644
--- a/packages/client-runtime/src/externalHistory/index.ts
+++ b/packages/client-runtime/src/externalHistory/index.ts
@@ -1,4 +1,8 @@
-import { WS_METHODS } from "@t3tools/contracts";
+import {
+ type ExternalHistorySource,
+ type ExternalHistoryRequest,
+ WS_METHODS,
+} from "@t3tools/contracts";
import type { Atom } from "effect/reactivity";
import type { EnvironmentRegistry } from "../connection/registry.ts";
import { createEnvironmentRpcQueryAtomFamily } from "../state/runtime.ts";
@@ -24,3 +28,23 @@ export function externalHistoryRangeLabel(source: {
const start = source.text.length === 0 ? source.textOffset : source.textOffset + 1;
return `Characters ${start.toLocaleString()}–${(source.textOffset + source.text.length).toLocaleString()} of ${source.totalChars.toLocaleString()}`;
}
+
+export function externalHistorySearchFilters(
+ capabilities: ExternalHistorySource["capabilities"],
+ wanted: { readonly filterCwd: string; readonly includeArchived: boolean },
+): Pick, "filterCwd" | "includeArchived"> {
+ return {
+ ...(capabilities.cwdFilter && wanted.filterCwd.trim()
+ ? { filterCwd: wanted.filterCwd.trim() }
+ : {}),
+ ...(capabilities.archived ? { includeArchived: wanted.includeArchived } : {}),
+ };
+}
+
+export function externalHistorySourceStatusLabel(status: ExternalHistorySource["status"]): string {
+ return {
+ ready: "Ready",
+ unavailable: "Cannot read",
+ unsupported_format: "Unsupported history format",
+ }[status];
+}
From e627feec46bb30a21ccd9431ef49010d48429bf8 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 14:56:29 -0800
Subject: [PATCH 06/15] fix(server): one external history search request keeps
going until it has 20 matches
A search examined one thread per request, so a no-hit search over 1,843 Claude sessions took about 1,800 "Continue search" clicks. A request now walks threads newest first until it has 20 matches or has read about 4M characters of text or 2,000 threads, then returns a stateless cursor bound to source, query and cwd. Threads that cannot be parsed or change mid-search are skipped instead of ending the search.
Claude and OpenCode stores answer "can this thread contain the query" from raw bytes (a streamed regex over the JSONL file, an instr over message data) before the full parse, only for plain ASCII queries. A skipped thread can only be a thread that the full parse would also have missed, except for an OpenCode hit made solely of U+212A or U+017F.
Measured on this Mac (load average 30+, 1,645 Claude files / 1.1 GiB, 184 OpenCode sessions): a no-hit Claude search went from 26 s of parsing in 1,645 requests to 1.8 to 2.9 s in 1 request, OpenCode from 4.8 s to 0.5 to 0.9 s.
Co-Authored-By: Claude Fable 5.2
---
.../ChatGptExportHistory.test.ts | 27 ++-
.../externalHistory/JsonlHistoryStore.test.ts | 39 +++-
.../externalHistory/JsonlHistoryStore.ts | 22 ++-
.../externalHistory/LocalHistory.test.ts | 125 +++++++++++++
.../project/externalHistory/LocalHistory.ts | 177 +++++++++++-------
.../externalHistory/LocalHistoryStore.ts | 2 +
.../externalHistory/OpenCodeHistoryStore.ts | 15 +-
7 files changed, 311 insertions(+), 96 deletions(-)
create mode 100644 apps/server/src/project/externalHistory/LocalHistory.test.ts
diff --git a/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts b/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts
index c5f3bbb115f9a..856fe6ae5dcdf 100644
--- a/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts
+++ b/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts
@@ -104,22 +104,20 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export h
];
const { history, store } = yield* fixture(exportValue);
const request = { operation: "search", sourceId: "export", query: "needle" } as const;
- const page1 = yield* history.search(store, request);
- expect(page1.matches).toEqual([]);
- expect(page1.examinedItems).toBe(20);
- expect(page1.nextCursor).not.toBeNull();
- const page2 = yield* history.search(store, { ...request, cursor: page1.nextCursor! });
- expect(page2.matches.map((entry) => entry.itemId)).toEqual(["message-20"]);
- const page3 = yield* history.search(store, { ...request, cursor: page2.nextCursor! });
- expect(page3.matches.map((entry) => entry.ref.threadId)).toEqual(["1:conversation-2"]);
- expect(page3.nextCursor).toBeNull();
+ const page = yield* history.search(store, request);
+ expect(page.matches.map((entry) => [entry.ref.threadId, entry.itemId])).toEqual([
+ ["0:conversation-1", "message-20"],
+ ["1:conversation-2", "message-0"],
+ ]);
+ expect(page.examinedItems).toBe(22);
+ expect(page.nextCursor).toBeNull();
}).pipe(Effect.scoped),
);
it.effect("binds search cursors and exact source reads to the current file", () =>
Effect.gen(function* () {
const { history, store, fs, filePath } = yield* fixture([
- conversation(["needle", ...Array.from({ length: 20 }, () => "other")]),
+ conversation(Array.from({ length: 25 }, () => "needle")),
]);
const request = { operation: "search", sourceId: "export", query: "needle" } as const;
const page = yield* history.search(store, request);
@@ -168,12 +166,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export h
filePath,
JSON.stringify([conversation(["x".repeat(5_000_000) + "needle"])]),
);
- const error = yield* history.search(store, request).pipe(Effect.flip);
- expect(error.reason).toBe("limit_exceeded");
+ expect((yield* history.search(store, request)).matches).toEqual([]);
}).pipe(Effect.scoped),
);
- it.effect("fails explicitly for unsupported exports and ambiguous branches", () =>
+ it.effect("fails explicitly for unsupported exports and skips ambiguous conversations", () =>
Effect.gen(function* () {
const { history, store, fs, filePath } = yield* fixture({ conversations: [] });
const request = { operation: "search", sourceId: "export", query: "needle" } as const;
@@ -185,11 +182,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export h
{ ...source, mapping: { "node-0": { ...source.mapping["node-0"], parent: "node-0" } } },
]),
);
- expect((yield* history.search(store, request).pipe(Effect.flip)).reason).toBe("unsupported");
+ expect((yield* history.search(store, request)).matches).toEqual([]);
const duplicate = conversation(["first message", "needle second message"]);
duplicate.mapping["node-1"]!.message.id = duplicate.mapping["node-0"]!.message.id;
yield* fs.writeFileString(filePath, JSON.stringify([duplicate]));
- expect((yield* history.search(store, request).pipe(Effect.flip)).reason).toBe("unsupported");
+ expect((yield* history.search(store, request)).matches).toEqual([]);
yield* fs.writeFileString(filePath, '[{"bad":');
expect((yield* history.search(store, request).pipe(Effect.flip)).reason).toBe("unsupported");
}).pipe(Effect.scoped),
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
index 43205c76a2c58..6bfebb0275c9c 100644
--- a/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
@@ -56,8 +56,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
const first = yield* history.search(store, request);
expect(first.matches.map((m) => m.itemId)).toEqual(["old-evidence"]);
expect(first.matches[0]?.threadTitle).toBe("Renamed");
+ expect(first.nextCursor).toBeNull();
const enumerated = enumerations;
- const second = yield* history.search(store, { ...request, cursor: first.nextCursor! });
+ const paged = { ...request, query: "Other message" };
+ const pageOne = yield* history.search(store, paged);
+ const second = yield* history.search(store, { ...paged, cursor: pageOne.nextCursor! });
expect(second.examinedItems).toBe(20);
const read = yield* history.read(store, { operation: "read", ref: first.matches[0]!.ref });
expect(read.text).toBe("needle from the distant past");
@@ -86,8 +89,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
.read(store, { operation: "read", ref: first.matches[0]!.ref })
.pipe(Effect.flip)).reason,
).toBe("changed");
- const restarted = yield* history.search(store, { ...request, cursor: second.nextCursor! });
- expect(restarted.matches.map((m) => m.itemId)).toEqual(["old-evidence"]);
+ const restarted = yield* history.search(store, { ...paged, cursor: second.nextCursor! });
+ expect(restarted.matches[0]?.itemId).toBe("new-0");
expect(
(yield* store
.loadThread({ key: "../../outside.jsonl", version: "", updatedAt: 0 })
@@ -96,6 +99,29 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
}).pipe(Effect.scoped),
);
+ it.effect("finds queries split across read chunks and queries that JSON escapes", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const root = yield* fs.makeTempDirectoryScoped();
+ const project = `${root}/projects/project`;
+ yield* fs.makeDirectory(project, { recursive: true });
+ const head = '{"type":"user","uuid":"split","message":{"content":"';
+ const text = "x".repeat(1024 * 1024 - head.length - 3) + 'needle then say "hi"';
+ yield* fs.writeFileString(
+ `${project}/session.jsonl`,
+ JSON.stringify({ type: "user", uuid: "split", message: { content: text } }) + "\n",
+ );
+ const history = yield* makeLocalHistory;
+ const claude = yield* makeClaudeHistoryStore(root, history.cache);
+ for (const query of ["needle", 'say "hi"']) {
+ expect(
+ (yield* history.search(claude, { operation: "search", sourceId: "claude", query }))
+ .matches,
+ ).toHaveLength(1);
+ }
+ }).pipe(Effect.scoped),
+ );
+
it.effect("excludes T3 Claude sessions and follows the active Pi branch", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
@@ -163,10 +189,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
JSON.stringify({ type: "message", message: { role: "user", content: "legacy" } }) + "\n",
);
expect(
- (yield* history
- .search(pi, { operation: "search", sourceId: "pi", query: "legacy" })
- .pipe(Effect.flip)).reason,
- ).toBe("unsupported");
+ (yield* history.search(pi, { operation: "search", sourceId: "pi", query: "legacy" }))
+ .matches,
+ ).toEqual([]);
}).pipe(Effect.scoped),
);
});
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
index 16448d5ae3c13..d9070140d451a 100644
--- a/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
@@ -247,7 +247,27 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
return yield* new ExternalHistoryError({ reason: "changed" });
return thread;
}, Effect.mapError(mapError));
- return { listThreads, loadThread } satisfies LocalHistoryStore;
+ // Raw JSON text holds a plain-ASCII query verbatim; quotes, backslashes and control
+ // characters are escaped on disk and joined blocks differ, so those queries skip the check.
+ const mightMention = Effect.fn("ExternalHistory.prefilterJsonlThread")(
+ function* (ref: LocalThreadRef, query: string) {
+ if (!/^[ !#-[\]-~]+$/.test(query)) return true;
+ const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu");
+ const file = yield* fs.open(path.join(directory, ...ref.key.split("/")), { flag: "r" });
+ const decoder = new TextDecoder();
+ let tail = "";
+ while (true) {
+ const bytes = yield* file.readAlloc(1024 * 1024);
+ if (Option.isNone(bytes)) return false;
+ const text = tail + decoder.decode(bytes.value, { stream: true });
+ if (pattern.test(text)) return true;
+ tail = text.slice(Math.max(0, text.length - query.length + 1));
+ }
+ },
+ Effect.scoped,
+ Effect.orElseSucceed(() => true),
+ );
+ return { listThreads, loadThread, mightMention } satisfies LocalHistoryStore;
}, Effect.mapError(mapError));
export const makeClaudeHistoryStore = (root: string, cache: ThreadCache) =>
diff --git a/apps/server/src/project/externalHistory/LocalHistory.test.ts b/apps/server/src/project/externalHistory/LocalHistory.test.ts
new file mode 100644
index 0000000000000..0bf20d0625244
--- /dev/null
+++ b/apps/server/src/project/externalHistory/LocalHistory.test.ts
@@ -0,0 +1,125 @@
+import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
+import { expect, it } from "@effect/vitest";
+import { ExternalHistoryError } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import type { TranscriptItem } from "../agentTranscripts.ts";
+import { makeLocalHistory } from "./LocalHistory.ts";
+import type { LocalHistoryStore, LocalThread } from "./LocalHistoryStore.ts";
+
+const isHistoryError = Schema.is(ExternalHistoryError);
+const item = (id: string, text: string): TranscriptItem => ({ id, role: "user", text });
+const thread = (items: ReadonlyArray): LocalThread => ({
+ version: "v1",
+ title: "Conversation",
+ cwd: null,
+ items,
+});
+
+const makeStore = (
+ threads: ReadonlyArray,
+ mightMention?: (key: string) => boolean,
+) => {
+ const loaded: string[] = [];
+ const store: LocalHistoryStore = {
+ listThreads: Effect.succeed(
+ threads.map(([key], index) => ({ key, updatedAt: threads.length - index, version: "v1" })),
+ ),
+ loadThread: (ref) => {
+ loaded.push(ref.key);
+ const entry = threads.find(([key]) => key === ref.key)![1];
+ return isHistoryError(entry) ? Effect.fail(entry) : Effect.succeed(entry);
+ },
+ ...(mightMention ? { mightMention: (ref) => Effect.succeed(mightMention(ref.key)) } : {}),
+ };
+ return { store, loaded };
+};
+
+const searchAll = Effect.fn("LocalHistory.test.searchAll")(function* (
+ store: LocalHistoryStore,
+ query: string,
+) {
+ const history = yield* makeLocalHistory;
+ const found: string[] = [];
+ let requests = 0;
+ let cursor: string | undefined;
+ do {
+ const page = yield* history.search(store, {
+ operation: "search",
+ sourceId: "local",
+ query,
+ ...(cursor ? { cursor } : {}),
+ });
+ requests++;
+ found.push(...page.matches.map((match) => `${match.ref.threadId}:${match.itemId}`));
+ cursor = page.nextCursor ?? undefined;
+ } while (cursor);
+ return { found, requests };
+});
+
+it.layer(NodeCrypto.layer)("local history search paging", (it) => {
+ it.effect("returns a hit in the 300th thread from one request", () =>
+ Effect.gen(function* () {
+ const { store, loaded } = makeStore(
+ Array.from({ length: 400 }, (_, index) => [
+ `thread-${index}`,
+ thread([item("only", index === 299 ? "the needle" : "nothing here")]),
+ ]),
+ );
+ const history = yield* makeLocalHistory;
+ const page = yield* history.search(store, {
+ operation: "search",
+ sourceId: "local",
+ query: "needle",
+ });
+ expect(page.matches.map((match) => match.ref.threadId)).toEqual(["thread-299"]);
+ expect(page.examinedItems).toBe(400);
+ expect(loaded).toHaveLength(400);
+ expect(page.nextCursor).toBeNull();
+ }),
+ );
+
+ it.effect("resumes at the match cap and the text budget without skipping or repeating", () =>
+ Effect.gen(function* () {
+ const crowded = Array.from({ length: 45 }, (_, index) => item(`hit-${index}`, "needle"));
+ const heavy = (key: string) =>
+ [key, thread([item("padding", "x".repeat(2_500_000)), item("hit", "needle")])] as const;
+ const { store } = makeStore([
+ ["crowded", thread(crowded)],
+ heavy("heavy-0"),
+ heavy("heavy-1"),
+ heavy("heavy-2"),
+ heavy("heavy-3"),
+ ["last", thread([item("hit", "needle")])],
+ ]);
+ const { found, requests } = yield* searchAll(store, "needle");
+ expect(found).toEqual([
+ ...crowded.map((entry) => `crowded:${entry.id}`),
+ "heavy-0:hit",
+ "heavy-1:hit",
+ "heavy-2:hit",
+ "heavy-3:hit",
+ "last:hit",
+ ]);
+ expect(requests).toBeGreaterThan(3);
+ }),
+ );
+
+ it.effect("skips unreadable threads and threads the store rules out without loading them", () =>
+ Effect.gen(function* () {
+ const { store, loaded } = makeStore(
+ [
+ ["broken", new ExternalHistoryError({ reason: "unsupported" })],
+ ["live", new ExternalHistoryError({ reason: "changed" })],
+ ["ruled-out", thread([item("hit", "needle")])],
+ ["good", thread([item("hit", "needle")])],
+ ],
+ (key) => key !== "ruled-out",
+ );
+ const { found, requests } = yield* searchAll(store, "needle");
+ expect(found).toEqual(["good:hit"]);
+ expect(requests).toBe(1);
+ expect(loaded).toEqual(["broken", "live", "good"]);
+ }),
+ );
+});
diff --git a/apps/server/src/project/externalHistory/LocalHistory.ts b/apps/server/src/project/externalHistory/LocalHistory.ts
index 1c9e31ec75a07..7978fbacdb514 100644
--- a/apps/server/src/project/externalHistory/LocalHistory.ts
+++ b/apps/server/src/project/externalHistory/LocalHistory.ts
@@ -39,6 +39,19 @@ const decodeSearchCursor = Schema.decodeUnknownEffect(Schema.fromJsonString(Sear
const encodeSearchCursor = Schema.encodeSync(Schema.fromJsonString(SearchCursor));
const decodeReadCursor = Schema.decodeUnknownEffect(Schema.fromJsonString(ReadCursor));
const encodeReadCursor = Schema.encodeSync(Schema.fromJsonString(ReadCursor));
+const SEARCH_MATCHES = 20;
+// Set from measurement against ~1,800 real sessions; see the commit message.
+const SEARCH_CHAR_BUDGET = 4_000_000;
+const SEARCH_THREAD_BUDGET = 2_000;
+// Oversized sessions fail only after about half a second of parsing.
+const FAILED_LOAD_CHARS = 1_000_000;
+// One unreadable or live-changing session must not end a search across everything else.
+const SKIPPED_THREAD_REASONS: ReadonlySet = new Set([
+ "unsupported",
+ "limit_exceeded",
+ "missing",
+ "changed",
+]);
const invalidCursor = () => new ExternalHistoryError({ reason: "invalid_cursor" });
const evidence = (item: { readonly role: string; readonly text: string }) =>
`external-history-v2\n${item.role}\n${item.text}`;
@@ -67,93 +80,113 @@ export const makeLocalHistory = Effect.gen(function* () {
)
return yield* invalidCursor();
const refs = yield* store.listThreads;
- const index =
+ const first =
cursor?.afterKey == null ? 0 : refs.findIndex((ref) => ref.key === cursor.afterKey);
- if (index < 0) return yield* new ExternalHistoryError({ reason: "changed" });
- const ref = refs[index];
- if (!ref)
- return {
- operation: "search",
- matches: [],
- examinedItems: 0,
- nextCursor: null,
- } satisfies ExternalHistoryResponse;
- const nextThread = () =>
- refs[index + 1]
+ if (first < 0) return yield* new ExternalHistoryError({ reason: "changed" });
+ const cursorAt = (index: number, itemOffset: number, version: string | null) => {
+ const ref = refs[index];
+ return ref
? encodeSearchCursor({
v: 2,
sourceId: input.sourceId,
query: input.query,
filterCwd: input.filterCwd ?? null,
- afterKey: refs[index + 1]!.key,
- itemOffset: 0,
- version: null,
+ afterKey: ref.key,
+ itemOffset,
+ version,
})
: null;
- const thread = yield* store.loadThread(ref);
- if (
- thread.origin === "t3" ||
- (input.filterCwd &&
- (thread.cwd === null ||
- normalizeProjectPathForComparison(thread.cwd) !==
- normalizeProjectPathForComparison(input.filterCwd)))
- )
- return {
- operation: "search",
- matches: [],
- examinedItems: 0,
- nextCursor: nextThread(),
- } satisfies ExternalHistoryResponse;
- const offset = cursor?.version === thread.version ? cursor.itemOffset : 0;
- if (offset > thread.items.length) return yield* invalidCursor();
- const end = Math.min(thread.items.length, offset + 20);
+ };
const pattern = new RegExp(input.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu");
const matches: ExternalHistoryMatch[] = [];
- for (const item of thread.items.slice(offset, end)) {
- const match = pattern.exec(item.text);
- if (!match) continue;
- let start = Math.max(0, match.index - 200);
- if (!isTextBoundary(item.text, start)) start--;
- const readCursor = encodeReadCursor({
- v: 2,
- sourceId: input.sourceId,
- key: ref.key,
- version: thread.version,
- itemId: item.id,
- });
- if (readCursor.length > 16_384 || item.id.length > 512)
- return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
- matches.push({
- ref: {
+ let examinedItems = 0;
+ let examinedChars = 0;
+ let visited = 0;
+ for (let index = first; index < refs.length; index++) {
+ if (examinedChars >= SEARCH_CHAR_BUDGET || visited >= SEARCH_THREAD_BUDGET)
+ return {
+ operation: "search",
+ matches,
+ examinedItems,
+ nextCursor: cursorAt(index, 0, null),
+ } satisfies ExternalHistoryResponse;
+ visited++;
+ const ref = refs[index]!;
+ const resumed = index === first ? cursor : undefined;
+ if (
+ !resumed?.itemOffset &&
+ store.mightMention &&
+ !(yield* store.mightMention(ref, input.query))
+ )
+ continue;
+ const thread = yield* store.loadThread(ref).pipe(
+ Effect.catchIf(
+ (error) => SKIPPED_THREAD_REASONS.has(error.reason),
+ () => Effect.undefined,
+ ),
+ );
+ if (!thread) {
+ examinedChars += FAILED_LOAD_CHARS;
+ continue;
+ }
+ examinedChars += thread.items.reduce((sum, item) => sum + item.text.length, 0);
+ if (
+ thread.origin === "t3" ||
+ (input.filterCwd &&
+ (thread.cwd === null ||
+ normalizeProjectPathForComparison(thread.cwd) !==
+ normalizeProjectPathForComparison(input.filterCwd)))
+ )
+ continue;
+ const offset = resumed?.version === thread.version ? resumed.itemOffset : 0;
+ if (offset > thread.items.length) return yield* invalidCursor();
+ for (let position = offset; position < thread.items.length; position++) {
+ const item = thread.items[position]!;
+ examinedItems++;
+ const match = pattern.exec(item.text);
+ if (!match) continue;
+ let start = Math.max(0, match.index - 200);
+ if (!isTextBoundary(item.text, start)) start--;
+ const readCursor = encodeReadCursor({
+ v: 2,
sourceId: input.sourceId,
- threadId: ref.key,
- readCursor,
- digest: yield* digest(evidence(item)),
- },
- threadTitle: thread.title,
- itemId: item.id,
- role: item.role,
- excerpt: item.text.slice(start, boundedTextEnd(item.text, start, 2_000)),
- textOffset: start,
- totalChars: item.text.length,
- });
+ key: ref.key,
+ version: thread.version,
+ itemId: item.id,
+ });
+ if (readCursor.length > 16_384 || item.id.length > 512)
+ return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
+ matches.push({
+ ref: {
+ sourceId: input.sourceId,
+ threadId: ref.key,
+ readCursor,
+ digest: yield* digest(evidence(item)),
+ },
+ threadTitle: thread.title,
+ itemId: item.id,
+ role: item.role,
+ excerpt: item.text.slice(start, boundedTextEnd(item.text, start, 2_000)),
+ textOffset: start,
+ totalChars: item.text.length,
+ });
+ if (matches.length === SEARCH_MATCHES)
+ return {
+ operation: "search",
+ matches,
+ examinedItems,
+ nextCursor:
+ position + 1 < thread.items.length
+ ? cursorAt(index, position + 1, thread.version)
+ : cursorAt(index + 1, 0, null),
+ } satisfies ExternalHistoryResponse;
+ }
}
return {
operation: "search",
matches,
- examinedItems: end - offset,
- nextCursor:
- end < thread.items.length
- ? encodeSearchCursor({
- v: 2,
- sourceId: input.sourceId,
- query: input.query,
- filterCwd: input.filterCwd ?? null,
- afterKey: ref.key,
- itemOffset: end,
- version: thread.version,
- })
- : nextThread(),
+ examinedItems,
+ nextCursor: null,
} satisfies ExternalHistoryResponse;
});
const read = Effect.fn("ExternalHistory.readLocal")(function* (
diff --git a/apps/server/src/project/externalHistory/LocalHistoryStore.ts b/apps/server/src/project/externalHistory/LocalHistoryStore.ts
index 72a0cc5b1a5b5..e93fcaad570a6 100644
--- a/apps/server/src/project/externalHistory/LocalHistoryStore.ts
+++ b/apps/server/src/project/externalHistory/LocalHistoryStore.ts
@@ -23,6 +23,8 @@ export interface LocalThread {
export interface LocalHistoryStore {
readonly listThreads: Effect.Effect, ExternalHistoryError>;
readonly loadThread: (ref: LocalThreadRef) => Effect.Effect;
+ // False only when the thread provably cannot contain the query; any doubt answers true.
+ readonly mightMention?: (ref: LocalThreadRef, query: string) => Effect.Effect;
}
export type ThreadCache = (
diff --git a/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts b/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts
index f87ffb67f97b8..bcd7ed0bf9299 100644
--- a/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts
+++ b/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts
@@ -190,5 +190,18 @@ export const makeOpenCodeHistoryStore = Effect.fn("ExternalHistory.openOpenCodeS
if ((yield* version) !== current) return yield* new ExternalHistoryError({ reason: "changed" });
return thread;
});
- return { listThreads, loadThread } satisfies LocalHistoryStore;
+ // Message data is JSON text, so only a plain-ASCII query is stored verbatim. SQLite lower()
+ // folds ASCII only, so a hit made solely of U+212A or U+017F is missed.
+ const mightMention = (ref: LocalThreadRef, query: string) =>
+ /^[ !#-[\]-~]+$/.test(query)
+ ? withDatabase(
+ (db) =>
+ db
+ .prepare(
+ "SELECT 1 FROM session_message WHERE session_id = ? AND type IN ('user', 'assistant') AND instr(lower(data), ?) > 0 LIMIT 1",
+ )
+ .get(ref.key, query.toLowerCase()) !== undefined,
+ ).pipe(Effect.orElseSucceed(() => true))
+ : Effect.succeed(true);
+ return { listThreads, loadThread, mightMention } satisfies LocalHistoryStore;
}, Effect.mapError(mapError));
From 8109415b659e26f5aa1c089474c99a82b10e18f8 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 15:10:18 -0800
Subject: [PATCH 07/15] fix(server): external history hides sessions T3 already
owns, matched by stored id
Only Claude records with the t3-code entrypoint were skipped, and older T3 runs lack it. T3 keeps each provider's native session id on its provider thread, so one batched ProjectionStore lookup per search request now answers which of the listed session ids are T3 threads, and the engine skips those without loading them.
On this Mac the stored id equals the Claude session id and file name for all 1,645 sessions: 304 are T3 threads and only 6 of those carried the marker. For OpenCode it equals the session id for 179 of 184 top-level sessions. Pi stays unmatched because T3 stores Pi's session file path, not its session id.
Co-Authored-By: Claude Fable 5.2
---
.../orchestration-v2/ProjectionStore.test.ts | 5 +++
.../src/orchestration-v2/ProjectionStore.ts | 21 +++++++++++++
.../ProviderTurnControlService.test.ts | 1 +
.../externalHistory/ExternalHistory.test.ts | 31 +++++++++++++++++++
.../externalHistory/ExternalHistory.ts | 10 ++++--
.../externalHistory/JsonlHistoryStore.ts | 12 ++++++-
.../externalHistory/LocalHistory.test.ts | 25 +++++++++++++++
.../project/externalHistory/LocalHistory.ts | 13 +++++++-
.../externalHistory/LocalHistoryStore.ts | 2 ++
.../externalHistory/OpenCodeHistoryStore.ts | 7 ++++-
apps/server/src/server.ts | 2 +-
11 files changed, 123 insertions(+), 6 deletions(-)
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
index 7bd969bbcb091..7d33d711f2e9b 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
@@ -660,6 +660,11 @@ it.effect("finds the thread behind a provider's native thread id", () =>
});
assert.equal(yield* store.findThreadIdByNativeThreadId("native-session-1"), threadId);
assert.equal(yield* store.findThreadIdByNativeThreadId("native-session-2"), null);
+ assert.deepEqual(
+ [...(yield* store.findOwnedNativeThreadIds(["native-session-1", "native-session-2"]))],
+ ["native-session-1"],
+ );
+ assert.equal((yield* store.findOwnedNativeThreadIds([])).size, 0);
}).pipe(Effect.provide(ProjectionStore.layer.pipe(Layer.provideMerge(layerReaderDatabase)))),
);
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index c7c363b02844a..97b75d1f80aea 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -588,6 +588,10 @@ export interface ProjectionStoreV2Shape {
readonly findThreadIdByNativeThreadId: (
nativeThreadId: string,
) => Effect.Effect;
+ /** Which of these provider session ids belong to a T3 thread, in one scan of provider threads. */
+ readonly findOwnedNativeThreadIds: (
+ nativeThreadIds: ReadonlyArray,
+ ) => Effect.Effect, ProjectionStoreV2Error>;
/** The thread whose current guest share has this id. Scans thread rows; only redeem uses it. */
readonly findGuestShareThreadId: (
shareId: GuestShareId,
@@ -7911,6 +7915,22 @@ export const layer: Layer.Layer =
Effect.mapError(shellSnapshotReadError),
);
+ const findOwnedNativeThreadIds = Effect.fn("ProjectionStore.findOwnedNativeThreadIds")(
+ function* (nativeThreadIds: ReadonlyArray) {
+ if (nativeThreadIds.length === 0) return new Set();
+ const rows = yield* sql<{ readonly native_id: string }>`
+ SELECT DISTINCT native_id
+ FROM (
+ SELECT json_extract(payload_json, '$.nativeThreadRef.nativeId') AS native_id
+ FROM orchestration_v2_projection_provider_threads
+ )
+ WHERE native_id IN (SELECT value FROM json_each(${encodeIdList(nativeThreadIds)}))
+ `;
+ return new Set(rows.map((row) => row.native_id));
+ },
+ Effect.mapError(shellSnapshotReadError),
+ );
+
const listOlderSettledThreads: ProjectionStoreV2Shape["listOlderSettledThreads"] = Effect.fn(
"ProjectionStore.listOlderSettledThreads",
)(function* ({ before, projectIds, limit }) {
@@ -8063,6 +8083,7 @@ export const layer: Layer.Layer =
getThreadShell: readOnReader(getThreadShell),
findGuestShareThreadId: readOnReader(findGuestShareThreadId),
findThreadIdByNativeThreadId: readOnReader(findThreadIdByNativeThreadId),
+ findOwnedNativeThreadIds: readOnReader(findOwnedNativeThreadIds),
getThread: readOnReader(getThread),
getSettlementCandidates: readOnReader(getSettlementCandidates),
getThreadsWithPullRequests: readOnReader(getThreadsWithPullRequests),
diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts
index 6d390566234e4..a068f672f4627 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts
@@ -242,6 +242,7 @@ it.effect(
getThreadLinkMetadata: () => Effect.die("unused getThreadLinkMetadata"),
findGuestShareThreadId: () => Effect.die("unused findGuestShareThreadId"),
findThreadIdByNativeThreadId: () => Effect.die("unused findThreadIdByNativeThreadId"),
+ findOwnedNativeThreadIds: () => Effect.die("unused findOwnedNativeThreadIds"),
getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)),
getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"),
getThreadsWithPullRequests: () => Effect.die("unused getThreadsWithPullRequests"),
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.test.ts b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
index 06e821d8c4f6f..d1d4e7959d519 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.test.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
@@ -21,6 +21,7 @@ import * as Stream from "effect/Stream";
import * as ServerConfig from "../../config.ts";
import * as ServerSettings from "../../serverSettings.ts";
+import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts";
import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
import * as ExternalHistory from "./ExternalHistory.ts";
@@ -124,6 +125,7 @@ const makeFixture = Effect.fn("ExternalHistory.test.fixture")(function* () {
instances: [instance] as Array,
settings: { enabled: true, chatGptExportPaths: [] } as ExternalHistorySettings,
registryReads: 0,
+ ownedNativeIds: new Set(),
providerInstances: DEFAULT_SERVER_SETTINGS.providerInstances,
};
const fs = yield* FileSystem.FileSystem;
@@ -151,6 +153,10 @@ const makeFixture = Effect.fn("ExternalHistory.test.fixture")(function* () {
providerInstances: state.providerInstances,
})),
}),
+ Layer.mock(ProjectionStore.ProjectionStoreV2)({
+ findOwnedNativeThreadIds: (ids) =>
+ Effect.succeed(new Set(ids.filter((id) => state.ownedNativeIds.has(id)))),
+ }),
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
listInstances: Effect.sync(() => {
state.registryReads++;
@@ -247,6 +253,31 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
}),
);
+ it.effect("hides Claude sessions whose id T3 already stores, with or without the marker", () =>
+ Effect.gen(function* () {
+ const { service, state, home } = yield* makeFixture();
+ const fs = yield* FileSystem.FileSystem;
+ yield* fs.makeDirectory(`${home}/.claude/projects/p`, { recursive: true });
+ for (const session of ["owned-session", "outside-session"])
+ yield* fs.writeFileString(
+ `${home}/.claude/projects/p/${session}.jsonl`,
+ JSON.stringify({ type: "user", uuid: session, message: { content: "needle" } }) + "\n",
+ );
+ state.ownedNativeIds.add("owned-session");
+ const catalog = yield* service.query({ operation: "sources", catalogVersion: 2 });
+ if (catalog.operation !== "sources") return yield* Effect.die("wrong response");
+ const result = yield* service.query({
+ operation: "search",
+ sourceId: catalog.sources.find((source) => source.provider === "claudeAgent")!.id,
+ query: "needle",
+ });
+ if (result.operation !== "search") return yield* Effect.die("wrong response");
+ expect(result.matches.map((match) => match.ref.threadId)).toEqual([
+ "p/outside-session.jsonl",
+ ]);
+ }),
+ );
+
it.effect("checks the environment opt-in before touching providers and on every read", () =>
Effect.gen(function* () {
const { service, state, calls } = yield* makeFixture();
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.ts b/apps/server/src/project/externalHistory/ExternalHistory.ts
index 09c5395358a4f..47e3b2ac20f20 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.ts
@@ -28,7 +28,8 @@ import * as ServerConfig from "../../config.ts";
import * as ServerSettings from "../../serverSettings.ts";
import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
import { makeChatGptExportStore } from "./ChatGptExportHistory.ts";
-import { makeLocalHistory } from "./LocalHistory.ts";
+import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts";
+import { makeLocalHistory, type OwnedNativeIds } from "./LocalHistory.ts";
import { makeHistorySupport } from "./historySupport.ts";
import { makeClaudeHistoryStore, makePiHistoryStore } from "./JsonlHistoryStore.ts";
import { makeOpenCodeHistoryStore } from "./OpenCodeHistoryStore.ts";
@@ -110,6 +111,11 @@ const make = Effect.gen(function* () {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const local = yield* makeLocalHistory;
+ const projection = yield* ProjectionStore.ProjectionStoreV2;
+ const ownedNativeIds: OwnedNativeIds = (nativeIds) =>
+ projection
+ .findOwnedNativeThreadIds(nativeIds)
+ .pipe(Effect.mapError((cause) => new ExternalHistoryError({ reason: "unavailable", cause })));
const home = yield* HostProcess.HomeDirectory;
const environment = yield* HostProcess.Environment;
const catalogLock = yield* Semaphore.make(1);
@@ -464,7 +470,7 @@ const make = Effect.gen(function* () {
return yield* new ExternalHistoryError({ reason: "unsupported" });
if (source._tag === "local")
return yield* input.operation === "search"
- ? local.search(source.store, input)
+ ? local.search(source.store, input, ownedNativeIds)
: local.read(source.store, input);
if (input.operation === "search") return yield* nativeSearch(source, input);
const result = yield* source.native
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
index d9070140d451a..63a926eeedfdd 100644
--- a/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
@@ -267,7 +267,17 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
Effect.scoped,
Effect.orElseSucceed(() => true),
);
- return { listThreads, loadThread, mightMention } satisfies LocalHistoryStore;
+ // T3 records Pi's session file path, not its session id, so Pi threads are not matched by id.
+ const nativeSessionId =
+ source === "claudeAgent"
+ ? (ref: LocalThreadRef) => path.basename(ref.key, ".jsonl")
+ : undefined;
+ return {
+ listThreads,
+ loadThread,
+ mightMention,
+ ...(nativeSessionId ? { nativeSessionId } : {}),
+ } satisfies LocalHistoryStore;
}, Effect.mapError(mapError));
export const makeClaudeHistoryStore = (root: string, cache: ThreadCache) =>
diff --git a/apps/server/src/project/externalHistory/LocalHistory.test.ts b/apps/server/src/project/externalHistory/LocalHistory.test.ts
index 0bf20d0625244..18e34da899bf0 100644
--- a/apps/server/src/project/externalHistory/LocalHistory.test.ts
+++ b/apps/server/src/project/externalHistory/LocalHistory.test.ts
@@ -30,6 +30,7 @@ const makeStore = (
const entry = threads.find(([key]) => key === ref.key)![1];
return isHistoryError(entry) ? Effect.fail(entry) : Effect.succeed(entry);
},
+ nativeSessionId: (ref) => ref.key,
...(mightMention ? { mightMention: (ref) => Effect.succeed(mightMention(ref.key)) } : {}),
};
return { store, loaded };
@@ -122,4 +123,28 @@ it.layer(NodeCrypto.layer)("local history search paging", (it) => {
expect(loaded).toEqual(["broken", "live", "good"]);
}),
);
+
+ it.effect("skips sessions T3 owns without loading them, using one lookup per request", () =>
+ Effect.gen(function* () {
+ const { store, loaded } = makeStore([
+ ["owned-a", thread([item("hit", "needle")])],
+ ["mine", thread([item("hit", "needle")])],
+ ["owned-b", thread([item("hit", "needle")])],
+ ]);
+ const lookups: Array> = [];
+ const history = yield* makeLocalHistory;
+ const page = yield* history.search(
+ store,
+ { operation: "search", sourceId: "local", query: "needle" },
+ (ids) =>
+ Effect.sync(() => {
+ lookups.push(ids);
+ return new Set(ids.filter((id) => id.startsWith("owned")));
+ }),
+ );
+ expect(page.matches.map((match) => match.ref.threadId)).toEqual(["mine"]);
+ expect(loaded).toEqual(["mine"]);
+ expect(lookups).toEqual([["owned-a", "mine", "owned-b"]]);
+ }),
+ );
});
diff --git a/apps/server/src/project/externalHistory/LocalHistory.ts b/apps/server/src/project/externalHistory/LocalHistory.ts
index 7978fbacdb514..5eddd18ae2f2c 100644
--- a/apps/server/src/project/externalHistory/LocalHistory.ts
+++ b/apps/server/src/project/externalHistory/LocalHistory.ts
@@ -56,6 +56,10 @@ const invalidCursor = () => new ExternalHistoryError({ reason: "invalid_cursor"
const evidence = (item: { readonly role: string; readonly text: string }) =>
`external-history-v2\n${item.role}\n${item.text}`;
+export type OwnedNativeIds = (
+ nativeIds: ReadonlyArray,
+) => Effect.Effect, ExternalHistoryError>;
+
export const makeLocalHistory = Effect.gen(function* () {
const crypto = yield* Digest.Digest;
const cache = yield* makeThreadCache;
@@ -67,6 +71,7 @@ export const makeLocalHistory = Effect.gen(function* () {
const search = Effect.fn("ExternalHistory.searchLocal")(function* (
store: LocalHistoryStore,
input: Extract,
+ owned?: OwnedNativeIds,
) {
const cursor =
input.cursor === undefined
@@ -98,6 +103,10 @@ export const makeLocalHistory = Effect.gen(function* () {
: null;
};
const pattern = new RegExp(input.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu");
+ const ownedIds: ReadonlySet =
+ store.nativeSessionId && owned
+ ? yield* owned(refs.slice(first).flatMap((ref) => store.nativeSessionId!(ref) ?? []))
+ : new Set();
const matches: ExternalHistoryMatch[] = [];
let examinedItems = 0;
let examinedChars = 0;
@@ -110,8 +119,10 @@ export const makeLocalHistory = Effect.gen(function* () {
examinedItems,
nextCursor: cursorAt(index, 0, null),
} satisfies ExternalHistoryResponse;
- visited++;
const ref = refs[index]!;
+ const nativeId = store.nativeSessionId?.(ref);
+ if (nativeId !== undefined && ownedIds.has(nativeId)) continue;
+ visited++;
const resumed = index === first ? cursor : undefined;
if (
!resumed?.itemOffset &&
diff --git a/apps/server/src/project/externalHistory/LocalHistoryStore.ts b/apps/server/src/project/externalHistory/LocalHistoryStore.ts
index e93fcaad570a6..97141dee02584 100644
--- a/apps/server/src/project/externalHistory/LocalHistoryStore.ts
+++ b/apps/server/src/project/externalHistory/LocalHistoryStore.ts
@@ -23,6 +23,8 @@ export interface LocalThread {
export interface LocalHistoryStore {
readonly listThreads: Effect.Effect, ExternalHistoryError>;
readonly loadThread: (ref: LocalThreadRef) => Effect.Effect;
+ // The provider's own session id, when T3 can have recorded it for a thread it ran.
+ readonly nativeSessionId?: (ref: LocalThreadRef) => string | undefined;
// False only when the thread provably cannot contain the query; any doubt answers true.
readonly mightMention?: (ref: LocalThreadRef, query: string) => Effect.Effect;
}
diff --git a/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts b/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts
index bcd7ed0bf9299..74c725d3a4c86 100644
--- a/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts
+++ b/apps/server/src/project/externalHistory/OpenCodeHistoryStore.ts
@@ -203,5 +203,10 @@ export const makeOpenCodeHistoryStore = Effect.fn("ExternalHistory.openOpenCodeS
.get(ref.key, query.toLowerCase()) !== undefined,
).pipe(Effect.orElseSucceed(() => true))
: Effect.succeed(true);
- return { listThreads, loadThread, mightMention } satisfies LocalHistoryStore;
+ return {
+ listThreads,
+ loadThread,
+ mightMention,
+ nativeSessionId: (ref) => ref.key,
+ } satisfies LocalHistoryStore;
}, Effect.mapError(mapError));
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index a4e599dbdc3cb..cbf9436467606 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -751,7 +751,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
),
),
),
- Layer.provideMerge(ExternalHistory.layer),
+ Layer.provideMerge(ExternalHistory.layer.pipe(Layer.provide(ProjectionStoreV2.layer))),
Layer.provideMerge(
ProjectKnowledge.layer.pipe(
Layer.provide(ProjectionStoreV2.layer),
From 10bd7f8aef47ea75439cfdb050c6db369bd7ddaf Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 15:33:57 -0800
Subject: [PATCH 08/15] fix(server): search large histories and skip malformed
records
Keep valid messages when another JSONL record cannot decode. Accept repeated native IDs using the latest record, skip messages without an ID, and reject files with no transcript records.
Raise the cumulative selected-parser allocation budget from 32 MiB to 128 MiB. A read-only census of 1,645 real Claude sessions changed from 1,640 successful loads and five allocation failures to 1,645 successful loads and no failures. Selected allocation in the failed files peaked at 58,058,328 bytes. One file also contained 12 malformed JSON lines. These are single census runs, not latency benchmarks.
Keep the 32 MiB cache, 100,000-record ceiling, depth ceiling, 64 KiB read chunks, and search budgets. Focused store and engine tests cover malformed-line recovery and search plus digest-verified reads of a 34 MiB session.
Implemented with GPT-6.1 Sol through the Codex harness.
Co-Authored-By: Claude Fable 5.2
---
.../externalHistory/JsonlHistoryStore.test.ts | 83 +++++++++++++++++++
.../externalHistory/JsonlHistoryStore.ts | 44 +++++-----
2 files changed, 104 insertions(+), 23 deletions(-)
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
index 6bfebb0275c9c..22cb66dd5aba4 100644
--- a/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
@@ -99,6 +99,89 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
}).pipe(Effect.scoped),
);
+ it.effect("keeps valid messages around malformed and undecodable records", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const root = yield* fs.makeTempDirectoryScoped();
+ const project = `${root}/projects/project`;
+ yield* fs.makeDirectory(project, { recursive: true });
+ const message = (uuid: string, content = "needle") =>
+ JSON.stringify({ type: "user", uuid, message: { content } });
+ yield* fs.writeFileString(
+ `${project}/session.jsonl`,
+ [
+ message("before"),
+ '{"type":"user",',
+ JSON.stringify({ type: "progress", timestamp: 42 }),
+ JSON.stringify({ type: "user", message: { content: "missing ID" } }),
+ message("before", "needle updated"),
+ message("after"),
+ ].join("\n"),
+ );
+ const history = yield* makeLocalHistory;
+ const store = yield* makeClaudeHistoryStore(root, history.cache);
+ const page = yield* history.search(store, {
+ operation: "search",
+ sourceId: "claude",
+ query: "needle",
+ });
+ expect(page.matches.map((match) => match.itemId)).toEqual(["before", "after"]);
+ expect(
+ (yield* history.read(store, { operation: "read", ref: page.matches[0]!.ref })).text,
+ ).toBe("needle updated");
+ yield* fs.writeFileString(`${project}/session.jsonl`, '{"unrelated":"data"}\nnot JSON');
+ const [ref] = yield* store.listThreads;
+ expect((yield* store.loadThread(ref!).pipe(Effect.flip)).reason).toBe("unsupported");
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("searches sessions larger than the old allocation cap and verifies their source", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const root = yield* fs.makeTempDirectoryScoped();
+ const project = `${root}/projects/project`;
+ yield* fs.makeDirectory(project, { recursive: true });
+ const file = `${project}/large.jsonl`;
+ for (let index = 0; index < 34; index++) {
+ yield* fs.writeFileString(
+ file,
+ JSON.stringify({
+ type: "assistant",
+ uuid: `large-${index}`,
+ message: { content: "x".repeat(1024 * 1024) },
+ }) + "\n",
+ { flag: index === 0 ? "w" : "a" },
+ );
+ }
+ yield* fs.writeFileString(
+ file,
+ JSON.stringify({
+ type: "user",
+ uuid: "match",
+ message: { content: "needle in a large session" },
+ }) + "\n",
+ { flag: "a" },
+ );
+ const history = yield* makeLocalHistory;
+ const store = yield* makeClaudeHistoryStore(root, history.cache);
+ const page = yield* history.search(store, {
+ operation: "search",
+ sourceId: "claude",
+ query: "needle",
+ });
+ expect(page.matches.map((match) => match.itemId)).toEqual(["match"]);
+ const ref = page.matches[0]!.ref;
+ expect((yield* history.read(store, { operation: "read", ref })).text).toBe(
+ "needle in a large session",
+ );
+ expect(
+ (yield* history
+ .read(store, { operation: "read", ref: { ...ref, digest: "0".repeat(64) } })
+ .pipe(Effect.flip)).reason,
+ ).toBe("changed");
+ }).pipe(Effect.scoped),
+ );
+
it.effect("finds queries split across read chunks and queries that JSON escapes", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
index 63a926eeedfdd..23e2bdb72bc35 100644
--- a/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
@@ -7,7 +7,6 @@ import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import {
TranscriptRecord,
- conversationItem,
activePiBranch,
emptyClaudeFields,
foldClaudeFields,
@@ -27,6 +26,7 @@ import {
type ThreadCache,
} from "./LocalHistoryStore.ts";
+const MAX_THREAD_ALLOCATION = 128 * 1024 * 1024;
const decodeRecord = Schema.decodeUnknownOption(TranscriptRecord);
const selectRecord = createTranscriptJsonSelector(TranscriptRecord);
const isHistoryError = Schema.is(ExternalHistoryError);
@@ -55,13 +55,7 @@ function projectThread(
const items = new Map();
for (const record of source === "pi" ? activePiBranch(records) : records) {
const visible = transcriptItems(record, source);
- const conversation = conversationItem(record, source);
- if (!origin && conversation && !conversation.id)
- throw new ExternalHistoryError({ reason: "unsupported" });
for (const item of visible) {
- const previous = items.get(item.id);
- if (previous && (previous.role !== item.role || previous.text !== item.text))
- throw new ExternalHistoryError({ reason: "unsupported" });
items.set(item.id, item);
}
}
@@ -169,28 +163,24 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
return yield* new ExternalHistoryError({ reason: "changed" });
const records: DecodedTranscriptRecord[] = [];
let allocated = 0;
- let reader = createTranscriptJsonReader(
- (bytes) => {
- allocated += bytes;
- if (allocated > 32 * 1024 * 1024)
- throw new TranscriptJsonLimitError({ message: "Transcript text limit" });
- },
- (path) => path[0] !== "payload" && selectRecord(path),
- );
- const finishRecord = () => {
- const decoded = decodeRecord(reader.finish());
- if (Option.isNone(decoded)) throw new ExternalHistoryError({ reason: "unsupported" });
- if (records.length >= 100_000)
- throw new ExternalHistoryError({ reason: "limit_exceeded" });
- records.push(decoded.value);
- reader = createTranscriptJsonReader(
+ const newReader = () =>
+ createTranscriptJsonReader(
(bytes) => {
allocated += bytes;
- if (allocated > 32 * 1024 * 1024)
+ if (allocated > MAX_THREAD_ALLOCATION)
throw new TranscriptJsonLimitError({ message: "Transcript text limit" });
},
(path) => path[0] !== "payload" && selectRecord(path),
);
+ let reader = newReader();
+ const finishRecord = () => {
+ const decoded = decodeRecord(reader.finish());
+ if (Option.isSome(decoded)) {
+ if (records.length >= 100_000)
+ throw new ExternalHistoryError({ reason: "limit_exceeded" });
+ records.push(decoded.value);
+ }
+ reader = newReader();
};
const decoder = new TextDecoder("utf-8", { fatal: true });
let pending = false;
@@ -236,6 +226,14 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
fileVersion(yield* fs.stat(filePath)) !== version
)
return yield* new ExternalHistoryError({ reason: "changed" });
+ if (
+ !records.some((record) =>
+ source === "pi"
+ ? record.type === "session" || record.type === "message"
+ : record.type === "user" || record.type === "assistant",
+ )
+ )
+ return yield* new ExternalHistoryError({ reason: "unsupported" });
return yield* Effect.try({
try: () => projectThread(records, source, version),
catch: mapError,
From 01d3ad3dc03affd4f1046d9a4b0fff2cb0432728 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 15:48:25 -0800
Subject: [PATCH 09/15] fix(clients): follow empty external history pages
automatically
Keep searching through empty pages until matches appear or the source is exhausted. Show cumulative progress and let the user stop and resume scanning.
Share paging transitions between web and mobile in a pure reducer. Only pages with matches enter previous-page navigation. Ignore stale and duplicate responses, and unsubscribe from the query while stopped or displaying results. Query hooks have no component result callback, so each client delivers completed responses through a reasoned effect.
Update the existing client tests and user guide for automatic following. Pure reducer tests cover consecutive empty pages, exhaustion, previous navigation, and stop and resume. Focused tests and check:changed against ee442a979a pass.
Implemented with GPT-6.1 Sol through the Codex harness.
Co-Authored-By: Claude Fable 5.2
---
.../ExternalHistoryRouteScreen.test.tsx | 10 +-
.../ExternalHistoryRouteScreen.tsx | 66 +++++++-----
.../ExternalHistoryDialog.browser.test.tsx | 6 +-
.../externalHistory/ExternalHistoryDialog.tsx | 70 +++++++-----
docs/user/external-history.md | 2 +-
.../src/externalHistory/index.ts | 5 +
.../searchPaging.logic.test.ts | 100 ++++++++++++++++++
.../src/externalHistory/searchPaging.logic.ts | 60 +++++++++++
8 files changed, 256 insertions(+), 63 deletions(-)
create mode 100644 packages/client-runtime/src/externalHistory/searchPaging.logic.test.ts
create mode 100644 packages/client-runtime/src/externalHistory/searchPaging.logic.ts
diff --git a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx
index a0be498c637e5..d63539b0c95eb 100644
--- a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx
+++ b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx
@@ -110,7 +110,9 @@ const ref = {
digest: "a".repeat(64),
};
vi.mock("../../state/query", () => ({
- useEnvironmentQuery: (request: ExternalHistoryRequest) => {
+ useEnvironmentQuery: (request: ExternalHistoryRequest | null) => {
+ if (request === null)
+ return { data: null, error: null, isPending: false, isSuccess: false, refresh: vi.fn() };
state.requests.push(request);
const data: ExternalHistoryResponse =
request.operation === "sources"
@@ -157,7 +159,7 @@ vi.mock("../../state/query", () => ({
totalChars: 33,
nextTextOffset: request.textOffset ? null : 16,
};
- return { data, error: null, isPending: false, refresh: vi.fn() };
+ return { data, error: null, isPending: false, isSuccess: true, refresh: vi.fn() };
},
}));
@@ -223,8 +225,6 @@ it("keeps remote history read-only without settings permission and stops reads w
});
await click("This project");
await click("Search");
- expect(container.textContent).toContain("No matches on this page. Continue");
- await click("Continue search");
expect(container.textContent).toContain("Compiler investigation");
expect(state.requests).toContainEqual({
operation: "search",
@@ -240,7 +240,7 @@ it("keeps remote history read-only without settings permission and stops reads w
expect(container.textContent).toContain("Second exact page");
expect(container.textContent).not.toContain("First exact page");
await click("Search");
- expect(container.textContent).toContain("No matches on this page. Continue");
+ expect(container.textContent).toContain("Compiler investigation");
expect(container.textContent).not.toContain("Second exact page");
expect(state.update).not.toHaveBeenCalled();
act(() => registry.set(config, configFor(true, false)));
diff --git a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
index d89bed200e468..3cde7c0208925 100644
--- a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
+++ b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
@@ -1,6 +1,8 @@
import { useAtomValue } from "@effect/atom-react";
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import {
+ externalHistoryPagingReducer,
+ initialExternalHistoryPaging,
externalHistoryRangeLabel,
externalHistorySearchFilters,
externalHistorySourceStatusLabel,
@@ -14,7 +16,7 @@ import {
type ExternalHistorySource,
type ProjectId,
} from "@t3tools/contracts";
-import { useState, type ReactNode } from "react";
+import { useEffect, useReducer, useState, type ReactNode } from "react";
import { ScrollView, View } from "react-native";
import { AppBar } from "../../components/ui/AppBar";
@@ -373,8 +375,7 @@ function HistorySearch(
/>
) : (
- Search reads bounded pages from the selected source. It may take several pages to scan a
- large history.
+ Search messages and tool evidence from the selected source.
)}
@@ -382,15 +383,27 @@ function HistorySearch(
}
function HistoryResults(props: { readonly environmentId: EnvironmentId; readonly search: Search }) {
- const [cursors, setCursors] = useState>([]);
+ const [paging, dispatch] = useReducer(
+ externalHistoryPagingReducer,
+ undefined,
+ initialExternalHistoryPaging,
+ );
const [selected, setSelected] = useState(null);
- const cursor = cursors.at(-1);
+ const cursor = paging.cursor;
const query = useEnvironmentQuery(
- externalHistory.query({
- environmentId: props.environmentId,
- input: { ...props.search, ...(cursor === undefined ? {} : { cursor }) },
- }),
+ paging.status === "searching"
+ ? externalHistory.query({
+ environmentId: props.environmentId,
+ input: { ...props.search, ...(cursor === undefined ? {} : { cursor }) },
+ })
+ : null,
);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- The query hook has no result callback to advance the shared pager.
+ useEffect(() => {
+ if (query.isSuccess && !query.isPending && query.data?.operation === "search")
+ dispatch({ type: "page", cursor, page: query.data });
+ }, [cursor, query.data, query.isPending, query.isSuccess]);
+ const data = paging.status === "ready" ? paging.page : null;
if (selected)
return (
@@ -408,21 +421,24 @@ function HistoryResults(props: { readonly environmentId: EnvironmentId; readonly
{query.error}
);
- if (!query.data || query.data.operation !== "search")
- return Searching history… ;
- const data = query.data;
return (
- Examined {data.examinedItems.toLocaleString()} items on this page.
+ Searched {paging.examinedItems.toLocaleString()} items
+ {paging.status === "searching" ? "…" : "."}
- {data.matches.length === 0 ? (
-
- No matches on this page.
- {data.nextCursor !== null ? " Continue to search the next page." : ""}
-
+ {paging.status === "searching" ? (
+ dispatch({ type: "stop" })} />
+ ) : paging.status === "stopped" ? (
+
+ Search stopped.
+ dispatch({ type: "resume" })} />
+
) : null}
- {data.matches.map((match) => (
+ {data?.matches.length === 0 ? (
+ No more matches found.
+ ) : null}
+ {data?.matches.map((match) => (
setSelected(match)} />
))}
- {cursors.length > 0 || data.nextCursor !== null ? (
+ {paging.previous.length > 0 || data?.nextCursor != null ? (
setCursors((current) => current.slice(0, -1))}
+ disabled={paging.previous.length === 0}
+ onPress={() => dispatch({ type: "previous" })}
/>
{
- if (data.nextCursor !== null) setCursors((current) => [...current, data.nextCursor!]);
- }}
+ disabled={data?.nextCursor == null}
+ onPress={() => dispatch({ type: "next" })}
/>
) : null}
diff --git a/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx b/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx
index 493c2f06d50e0..614cc81f480fe 100644
--- a/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx
+++ b/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx
@@ -173,10 +173,6 @@ it("continues an empty bounded search, opens exact source pages, and hides revok
await page.getByRole("button", { name: "This project", exact: true }).click();
await page.getByRole("switch", { name: "Include archived threads" }).click();
await page.getByRole("button", { name: "Search", exact: true }).click();
- await expect
- .element(page.getByText("No matches on this page. Continue to search the next page."))
- .toBeVisible();
- await page.getByRole("button", { name: "Continue search", exact: true }).click();
await expect
.element(page.getByText("Native compiler investigation", { exact: true }))
.toBeVisible();
@@ -212,7 +208,7 @@ it("continues an empty bounded search, opens exact source pages, and hides revok
.not.toBeInTheDocument();
await page.getByRole("button", { name: "Search", exact: true }).click();
await expect
- .element(page.getByText("No matches on this page. Continue to search the next page."))
+ .element(page.getByText("Native compiler investigation", { exact: true }))
.toBeVisible();
await expect.element(page.getByRole("alert")).not.toBeInTheDocument();
registry.set(allowed, false);
diff --git a/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx b/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx
index f24874dbec1cf..4ef4d5d6c9416 100644
--- a/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx
+++ b/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx
@@ -1,5 +1,7 @@
import { useAtomValue } from "@effect/atom-react";
import {
+ externalHistoryPagingReducer,
+ initialExternalHistoryPaging,
externalHistoryRangeLabel,
externalHistorySearchFilters,
externalHistorySourceStatusLabel,
@@ -12,7 +14,7 @@ import type {
ExternalHistorySettings,
ExternalHistorySource,
} from "@t3tools/contracts";
-import { useState, type ReactNode } from "react";
+import { useEffect, useReducer, useState, type ReactNode } from "react";
import { externalHistory } from "../../state/externalHistory";
import { useEnvironmentQuery } from "../../state/query";
@@ -387,8 +389,7 @@ function HistorySearch(props: {
/>
) : (
- Search reads bounded pages from the selected source. It may take several pages to scan a
- large history.
+ Search messages and tool evidence from the selected source.
)}
@@ -396,15 +397,27 @@ function HistorySearch(props: {
}
function HistoryResults(props: { readonly environmentId: EnvironmentId; readonly search: Search }) {
- const [cursors, setCursors] = useState>([]);
+ const [paging, dispatch] = useReducer(
+ externalHistoryPagingReducer,
+ undefined,
+ initialExternalHistoryPaging,
+ );
const [selected, setSelected] = useState(null);
- const cursor = cursors.at(-1);
+ const cursor = paging.cursor;
const query = useEnvironmentQuery(
- externalHistory.query({
- environmentId: props.environmentId,
- input: { ...props.search, ...(cursor === undefined ? {} : { cursor }) },
- }),
+ paging.status === "searching"
+ ? externalHistory.query({
+ environmentId: props.environmentId,
+ input: { ...props.search, ...(cursor === undefined ? {} : { cursor }) },
+ })
+ : null,
);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- The query hook has no result callback to advance the shared pager.
+ useEffect(() => {
+ if (query.isSuccess && !query.isPending && query.data?.operation === "search")
+ dispatch({ type: "page", cursor, page: query.data });
+ }, [cursor, query.data, query.isPending, query.isSuccess]);
+ const data = paging.status === "ready" ? paging.page : null;
if (selected)
return (
@@ -426,21 +439,28 @@ function HistoryResults(props: { readonly environmentId: EnvironmentId; readonly
{query.error}
);
- if (!query.data || query.data.operation !== "search")
- return
Searching history…
;
- const data = query.data;
return (
- Examined {data.examinedItems.toLocaleString()} items on this page.
+ Searched {paging.examinedItems.toLocaleString()} items
+ {paging.status === "searching" ? "…" : "."}
- {data.matches.length === 0 ? (
-
- No matches on this page.
- {data.nextCursor !== null ? " Continue to search the next page." : ""}
-
+ {paging.status === "searching" ? (
+
+ dispatch({ type: "stop" })}>
+ Stop search
+
+
+ ) : paging.status === "stopped" ? (
+
+ Search stopped.
+ dispatch({ type: "resume" })}>
+ Resume search
+
+
) : null}
- {data.matches.map((match) => (
+ {data?.matches.length === 0 ?
No more matches found.
: null}
+ {data?.matches.map((match) => (
))}
- {cursors.length > 0 || data.nextCursor !== null ? (
+ {paging.previous.length > 0 || data?.nextCursor != null ? (
setCursors((current) => current.slice(0, -1))}
+ disabled={paging.previous.length === 0}
+ onClick={() => dispatch({ type: "previous" })}
>
Previous page
{
- if (data.nextCursor !== null) setCursors((current) => [...current, data.nextCursor!]);
- }}
+ disabled={data?.nextCursor == null}
+ onClick={() => dispatch({ type: "next" })}
>
Continue search
diff --git a/docs/user/external-history.md b/docs/user/external-history.md
index 003e452ebf8b3..253b6bb19468a 100644
--- a/docs/user/external-history.md
+++ b/docs/user/external-history.md
@@ -22,7 +22,7 @@ Replace an export to include newer conversations, then search again. Remove its
## Search and verify evidence
-Search matches literal text without regard to case across complete visible messages and tool evidence. It can find old messages and messages earlier in long conversations. Each request examines a bounded page. A page with no matches can still have a continuation; use **Continue search** until the search ends. The examined count describes that page, not the entire archive.
+Search matches literal text without regard to case across complete visible messages and tool evidence. It can find old messages and messages earlier in long conversations. Search automatically skips pages without matches and shows the total number of items examined during the search. You can stop and resume scanning. Use **Continue search** to look for more matches.
Open a match to read its original visible evidence in pages. A digest binds the result to the complete evidence. Changed or missing sources require a new search. Appending to a local transcript can invalidate an earlier result. Pages preserve Unicode boundaries. Oversized evidence produces an explicit capacity error. Very large ChatGPT exports can take longer because loading a later conversation reads the file prefix again.
diff --git a/packages/client-runtime/src/externalHistory/index.ts b/packages/client-runtime/src/externalHistory/index.ts
index bfa97bc58412a..dbe4623be9962 100644
--- a/packages/client-runtime/src/externalHistory/index.ts
+++ b/packages/client-runtime/src/externalHistory/index.ts
@@ -48,3 +48,8 @@ export function externalHistorySourceStatusLabel(status: ExternalHistorySource["
unsupported_format: "Unsupported history format",
}[status];
}
+
+export {
+ externalHistoryPagingReducer,
+ initialExternalHistoryPaging,
+} from "./searchPaging.logic.ts";
diff --git a/packages/client-runtime/src/externalHistory/searchPaging.logic.test.ts b/packages/client-runtime/src/externalHistory/searchPaging.logic.test.ts
new file mode 100644
index 0000000000000..9606345e60a8b
--- /dev/null
+++ b/packages/client-runtime/src/externalHistory/searchPaging.logic.test.ts
@@ -0,0 +1,100 @@
+import type { ExternalHistoryResponse } from "@t3tools/contracts";
+import { expect, it } from "vite-plus/test";
+import {
+ externalHistoryPagingReducer as reduce,
+ initialExternalHistoryPaging,
+} from "./searchPaging.logic.ts";
+
+type Page = Extract
;
+function page(nextCursor: string | null, matched = false): Page {
+ return {
+ operation: "search",
+ nextCursor,
+ examinedItems: 100,
+ matches: matched
+ ? [
+ {
+ ref: { sourceId: "claude", threadId: "thread", readCursor: "read", digest: "digest" },
+ threadTitle: "Thread",
+ itemId: "item",
+ role: "user",
+ excerpt: "needle",
+ textOffset: 0,
+ totalChars: 6,
+ },
+ ]
+ : [],
+ };
+}
+
+it("follows consecutive empty pages and keeps only matching pages in previous navigation", () => {
+ let state = initialExternalHistoryPaging();
+ for (const [cursor, nextCursor] of [
+ [undefined, "empty"],
+ ["empty", "first-match"],
+ ] as const) {
+ state = reduce(state, {
+ type: "page",
+ cursor,
+ page: { ...page(nextCursor), examinedItems: cursor === undefined ? 0 : 100 },
+ });
+ expect(state.status).toBe("searching");
+ expect(state.previous).toEqual([]);
+ }
+ state = reduce(state, { type: "page", cursor: "first-match", page: page("another-empty", true) });
+ expect(state.status).toBe("ready");
+ expect(state.examinedItems).toBe(200);
+ expect(reduce(state, { type: "previous" })).toBe(state);
+ state = reduce(state, { type: "next" });
+ state = reduce(state, { type: "page", cursor: "another-empty", page: page("last-match") });
+ state = reduce(state, { type: "page", cursor: "last-match", page: page(null, true) });
+ expect(state.previous).toEqual(["first-match"]);
+ expect(reduce(state, { type: "next" })).toBe(state);
+ state = reduce(state, { type: "previous" });
+ expect(state.cursor).toBe("first-match");
+ expect(state.previous).toEqual([]);
+});
+
+it("finishes an exhausted search and preserves the first matching page's undefined cursor", () => {
+ let state = reduce(initialExternalHistoryPaging(), {
+ type: "page",
+ cursor: undefined,
+ page: page("empty", true),
+ });
+ state = reduce(state, { type: "next" });
+ state = reduce(state, { type: "page", cursor: "empty", page: page(null) });
+ expect(state.status).toBe("ready");
+ expect(state.examinedItems).toBe(200);
+ expect(state.previous).toEqual([undefined]);
+ state = reduce(state, { type: "previous" });
+ expect(state.cursor).toBeUndefined();
+ expect(state.status).toBe("searching");
+ const exhausted = reduce(initialExternalHistoryPaging(), {
+ type: "page",
+ cursor: undefined,
+ page: page(null),
+ });
+ expect(exhausted.status).toBe("ready");
+ expect(reduce(exhausted, { type: "next" })).toBe(exhausted);
+ expect(reduce(exhausted, { type: "previous" })).toBe(exhausted);
+});
+
+it("stops scanning, ignores late or duplicate responses, and resumes from the pending cursor", () => {
+ let state = reduce(initialExternalHistoryPaging(), {
+ type: "page",
+ cursor: undefined,
+ page: page("pending"),
+ });
+ expect(reduce(state, { type: "page", cursor: undefined, page: page("pending") })).toBe(state);
+ state = reduce(state, { type: "stop" });
+ expect(state.status).toBe("stopped");
+ expect(reduce(state, { type: "page", cursor: "pending", page: page(null, true) })).toBe(state);
+ expect(state.examinedItems).toBe(100);
+ state = reduce(state, { type: "resume" });
+ expect(state.cursor).toBe("pending");
+ state = reduce(state, { type: "page", cursor: "pending", page: page(null, true) });
+ expect(state.status).toBe("ready");
+ expect(state.examinedItems).toBe(200);
+ expect(reduce(state, { type: "page", cursor: "pending", page: page(null, true) })).toBe(state);
+ expect(reduce(state, { type: "stop" })).toBe(state);
+});
diff --git a/packages/client-runtime/src/externalHistory/searchPaging.logic.ts b/packages/client-runtime/src/externalHistory/searchPaging.logic.ts
new file mode 100644
index 0000000000000..da80db89292b5
--- /dev/null
+++ b/packages/client-runtime/src/externalHistory/searchPaging.logic.ts
@@ -0,0 +1,60 @@
+import type { ExternalHistoryResponse } from "@t3tools/contracts";
+
+type SearchPage = Extract;
+type Position = {
+ readonly cursor: string | undefined;
+ readonly previous: ReadonlyArray;
+ readonly examinedItems: number;
+};
+type ExternalHistoryPaging = Position &
+ (
+ | { readonly status: "searching" | "stopped" }
+ | { readonly status: "ready"; readonly page: SearchPage }
+ );
+type Action =
+ | { readonly type: "page"; readonly cursor: string | undefined; readonly page: SearchPage }
+ | { readonly type: "next" | "previous" | "stop" | "resume" };
+
+export function initialExternalHistoryPaging(): ExternalHistoryPaging {
+ return { status: "searching", cursor: undefined, previous: [], examinedItems: 0 };
+}
+
+export function externalHistoryPagingReducer(
+ state: ExternalHistoryPaging,
+ action: Action,
+): ExternalHistoryPaging {
+ const position = {
+ cursor: state.cursor,
+ previous: state.previous,
+ examinedItems: state.examinedItems,
+ };
+ switch (action.type) {
+ case "page": {
+ if (state.status !== "searching" || action.cursor !== state.cursor) return state;
+ const examinedItems = state.examinedItems + action.page.examinedItems;
+ return action.page.matches.length === 0 && action.page.nextCursor !== null
+ ? { ...position, status: "searching", cursor: action.page.nextCursor, examinedItems }
+ : { ...position, status: "ready", page: action.page, examinedItems };
+ }
+ case "next":
+ if (state.status !== "ready" || state.page.nextCursor === null) return state;
+ return {
+ ...position,
+ status: "searching",
+ cursor: state.page.nextCursor,
+ previous: [...state.previous, state.cursor],
+ };
+ case "previous":
+ if (state.previous.length === 0) return state;
+ return {
+ ...position,
+ status: "searching",
+ cursor: state.previous.at(-1),
+ previous: state.previous.slice(0, -1),
+ };
+ case "stop":
+ return state.status === "searching" ? { ...position, status: "stopped" } : state;
+ case "resume":
+ return state.status === "stopped" ? { ...position, status: "searching" } : state;
+ }
+}
From a42a914862a1a2ff120627e75782b7ae2915a0b9 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 16:41:36 -0800
Subject: [PATCH 10/15] refactor: remove the typed ChatGPT export path source
from external history
External history now only lists automatically discovered sources. Drops the
chatGptExportPaths setting, the chatgpt_export source kind, the export reader,
the path editor in web and mobile, and the related copy and docs. Stored settings
and older-client patches that still carry chatGptExportPaths decode and ignore it.
Co-Authored-By: Claude Fable 5.2
---
.../ExternalHistoryRouteScreen.test.tsx | 7 +-
.../ExternalHistoryRouteScreen.tsx | 51 ---
.../src/mcp/toolkits/externalHistory/tools.ts | 2 +-
.../ChatGptExportHistory.test.ts | 194 -----------
.../externalHistory/ChatGptExportHistory.ts | 321 ------------------
.../externalHistory/ExternalHistory.test.ts | 52 +--
.../externalHistory/ExternalHistory.ts | 21 --
.../src/provider/agentContextBudget.json | 2 +-
.../ExternalHistoryDialog.browser.test.tsx | 8 +-
.../externalHistory/ExternalHistoryDialog.tsx | 52 +--
.../src/components/settings/ZackSettings.tsx | 2 +-
.../src/components/settings/settingsSearch.ts | 2 +-
docs/user/external-history.md | 12 +-
.../contracts/src/externalHistory.test.ts | 23 +-
packages/contracts/src/externalHistory.ts | 5 +-
15 files changed, 35 insertions(+), 719 deletions(-)
delete mode 100644 apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts
delete mode 100644 apps/server/src/project/externalHistory/ChatGptExportHistory.ts
diff --git a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx
index d63539b0c95eb..e774f398bfa6b 100644
--- a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx
+++ b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.test.tsx
@@ -169,7 +169,7 @@ const configFor = (supported: boolean, enabled = true) => ({
environment: { capabilities: supported ? { externalHistory: 1 as const } : {} },
settings: {
...DEFAULT_SERVER_SETTINGS,
- externalHistory: { enabled, chatGptExportPaths: ["/remote/export.json"] },
+ externalHistory: { enabled },
},
});
@@ -211,11 +211,6 @@ it("keeps remote history read-only without settings permission and stops reads w
expect(container.textContent).toContain("Update this server");
act(() => registry.set(config, configFor(true)));
expect(button("Allow external history search").disabled).toBe(true);
- expect(container.querySelector('[aria-label="ChatGPT export paths"]')).toBeNull();
- await click("Add a ChatGPT export");
- expect(
- container.querySelector('[aria-label="ChatGPT export paths"]')?.disabled,
- ).toBe(true);
const input = container.querySelector(
'[aria-label="Search external history"]',
)!;
diff --git a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
index 3cde7c0208925..18dbdaf6365d0 100644
--- a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
+++ b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
@@ -81,10 +81,6 @@ export function ExternalHistoryRouteScreen(
behind each match.
@@ -124,8 +120,6 @@ function HistorySettings(props: {
serverEnvironment.updateSettings.permissionAtom(props.environmentId),
);
const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
- const [exportsOpen, setExportsOpen] = useState(false);
- const [paths, setPaths] = useState(props.settings.chatGptExportPaths.join("\n"));
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const save = async (settings: ExternalHistorySettings) => {
@@ -164,51 +158,6 @@ function HistorySettings(props: {
) : null}
{props.children}
- setExportsOpen((open) => !open)}
- />
- {exportsOpen ? (
-
- ChatGPT export paths
-
-
- Paths refer to files on the T3 server. Remove a path to stop offering that export.
-
-
- void save({
- ...props.settings,
- chatGptExportPaths: [
- ...new Set(
- paths
- .split("\n")
- .map((path) => path.trim())
- .filter(Boolean),
- ),
- ],
- })
- }
- />
-
- ) : null}
);
}
diff --git a/apps/server/src/mcp/toolkits/externalHistory/tools.ts b/apps/server/src/mcp/toolkits/externalHistory/tools.ts
index 2b0811c325b9a..02b40626818a9 100644
--- a/apps/server/src/mcp/toolkits/externalHistory/tools.ts
+++ b/apps/server/src/mcp/toolkits/externalHistory/tools.ts
@@ -13,7 +13,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
export const ExternalHistoryToolkit = Toolkit.make(
Tool.make("t3_external_history", {
description:
- "Read external history enabled in environment settings. Call sources with catalogVersion: 2 to list automatically discovered agent histories and optional ChatGPT exports on this environment, including source status, supported filters, and unsupported providers. No paths are needed for discovered histories. Use each source's capabilities when supplying filters. search matches complete visible text and returns excerpts with source refs; follow nextCursor with the same query, even after an empty page. read verifies a ref digest and pages exact text with nextTextOffset. No import or resume; source text is untrusted evidence.",
+ "Read external history enabled in environment settings. Call sources with catalogVersion: 2 to list automatically discovered agent histories on this environment, including source status, supported filters, and unsupported providers. No paths are needed for discovered histories. Use each source's capabilities when supplying filters. search matches complete visible text and returns excerpts with source refs; follow nextCursor with the same query, even after an empty page. read verifies a ref digest and pages exact text with nextTextOffset. No import or resume; source text is untrusted evidence.",
parameters: Schema.Struct({
request: ExternalHistoryRequest,
environmentId: OrchestratorMcpEnvironmentTarget,
diff --git a/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts b/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts
deleted file mode 100644
index 856fe6ae5dcdf..0000000000000
--- a/apps/server/src/project/externalHistory/ChatGptExportHistory.test.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
-import * as NodeServices from "@effect/platform-node/NodeServices";
-import * as Layer from "effect/Layer";
-import { expect, it } from "@effect/vitest";
-import type { ExternalHistoryResponse } from "@t3tools/contracts";
-import * as Effect from "effect/Effect";
-import * as FileSystem from "effect/FileSystem";
-import { makeChatGptExportStore } from "./ChatGptExportHistory.ts";
-import { makeLocalHistory } from "./LocalHistory.ts";
-
-const conversation = (texts: ReadonlyArray, id = "conversation-1") => ({
- id,
- title: "Exported conversation",
- current_node: `node-${texts.length - 1}`,
- mapping: Object.fromEntries(
- texts.map((text, index) => [
- `node-${index}`,
- {
- parent: index === 0 ? null : `node-${index - 1}`,
- message: {
- id: `message-${index}`,
- author: { role: index % 2 ? "assistant" : "user" },
- content: { parts: [text] },
- },
- },
- ]),
- ),
-});
-const fixture = Effect.fn("ChatGptExportHistory.test.fixture")(function* (value: unknown) {
- const fs = yield* FileSystem.FileSystem;
- const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-export-" });
- const filePath = `${directory}/conversations.json`;
- yield* fs.writeFileString(filePath, JSON.stringify(value));
- const history = yield* makeLocalHistory;
- return { history, store: yield* makeChatGptExportStore(filePath, history.cache), fs, filePath };
-});
-
-it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ChatGPT export history", (it) => {
- it.effect("finds suffix matches and pages digest-verified exact Unicode text", () =>
- Effect.gen(function* () {
- const text = "İ😀" + "a".repeat(3_000) + "NEEDLE [literal]";
- const { history, store } = yield* fixture([conversation([text])]);
- const result = yield* history.search(store, {
- operation: "search",
- sourceId: "export",
- query: "needle [literal]",
- });
- expect(result.matches).toHaveLength(1);
- expect(result.matches[0]!.excerpt).toContain("NEEDLE [literal]");
- expect(result.matches[0]!.textOffset).toBe(2_803);
- const ref = result.matches[0]!.ref;
- let actual = "";
- let offset: number | null = 0;
- while (offset !== null) {
- const page: Extract = yield* history.read(
- store,
- { operation: "read", ref, textOffset: offset, maxChars: 101 },
- );
- actual += page.text;
- offset = page.nextTextOffset;
- }
- expect(actual).toBe(text);
- const split = yield* history
- .read(store, { operation: "read", ref, textOffset: 2 })
- .pipe(Effect.flip);
- expect(split.reason).toBe("invalid_cursor");
- const wrongDigest = yield* history
- .read(store, { operation: "read", ref: { ...ref, digest: "0".repeat(64) } })
- .pipe(Effect.flip);
- expect(wrongDigest.reason).toBe("changed");
- }).pipe(Effect.scoped),
- );
-
- it.effect("continues after empty pages and searches only the selected visible branch", () =>
- Effect.gen(function* () {
- const first = conversation(
- Array.from({ length: 21 }, (_, index) => (index === 20 ? "needle" : "other")),
- );
- const exportValue = [
- {
- ...first,
- mapping: {
- ...first.mapping,
- abandoned: {
- parent: "node-0",
- message: {
- id: "abandoned",
- author: { role: "assistant" },
- content: { parts: ["needle abandoned"] },
- },
- },
- hidden: {
- parent: "node-0",
- message: {
- id: "hidden",
- author: { role: "assistant" },
- content: { parts: ["needle hidden"] },
- metadata: { is_visually_hidden_from_conversation: true },
- },
- },
- },
- },
- conversation(["needle second conversation"], "conversation-2"),
- ];
- const { history, store } = yield* fixture(exportValue);
- const request = { operation: "search", sourceId: "export", query: "needle" } as const;
- const page = yield* history.search(store, request);
- expect(page.matches.map((entry) => [entry.ref.threadId, entry.itemId])).toEqual([
- ["0:conversation-1", "message-20"],
- ["1:conversation-2", "message-0"],
- ]);
- expect(page.examinedItems).toBe(22);
- expect(page.nextCursor).toBeNull();
- }).pipe(Effect.scoped),
- );
-
- it.effect("binds search cursors and exact source reads to the current file", () =>
- Effect.gen(function* () {
- const { history, store, fs, filePath } = yield* fixture([
- conversation(Array.from({ length: 25 }, () => "needle")),
- ]);
- const request = { operation: "search", sourceId: "export", query: "needle" } as const;
- const page = yield* history.search(store, request);
- const wrongQuery = yield* history
- .search(store, { ...request, query: "other", cursor: page.nextCursor! })
- .pipe(Effect.flip);
- expect(wrongQuery.reason).toBe("invalid_cursor");
- const wrongSource = yield* history
- .read(store, { operation: "read", ref: { ...page.matches[0]!.ref, sourceId: "other" } })
- .pipe(Effect.flip);
- expect(wrongSource.reason).toBe("invalid_cursor");
- yield* fs.writeFileString(
- filePath,
- JSON.stringify([conversation(["needle changed source"])]),
- );
- const changed = yield* history
- .read(store, { operation: "read", ref: page.matches[0]!.ref })
- .pipe(Effect.flip);
- expect(changed.reason).toBe("changed");
- const restartedPage = yield* history.search(store, { ...request, cursor: page.nextCursor! });
- expect(restartedPage.matches[0]?.excerpt).toBe("needle changed source");
- }).pipe(Effect.scoped),
- );
-
- it.effect("drops opaque media before assembly and refuses over-budget visible text", () =>
- Effect.gen(function* () {
- const source = conversation(["needle"]);
- const media = {
- ...source,
- mapping: {
- "node-0": {
- parent: null,
- message: {
- id: "message-0",
- author: { role: "user" },
- content: { parts: ["needle", { image_asset_pointer: "x".repeat(5_000_000) }] },
- metadata: { opaque: "y".repeat(5_000_000) },
- },
- },
- },
- };
- const { history, store, fs, filePath } = yield* fixture([media]);
- const request = { operation: "search", sourceId: "export", query: "needle" } as const;
- expect((yield* history.search(store, request)).matches[0]?.excerpt).toBe("needle");
- yield* fs.writeFileString(
- filePath,
- JSON.stringify([conversation(["x".repeat(5_000_000) + "needle"])]),
- );
- expect((yield* history.search(store, request)).matches).toEqual([]);
- }).pipe(Effect.scoped),
- );
-
- it.effect("fails explicitly for unsupported exports and skips ambiguous conversations", () =>
- Effect.gen(function* () {
- const { history, store, fs, filePath } = yield* fixture({ conversations: [] });
- const request = { operation: "search", sourceId: "export", query: "needle" } as const;
- expect((yield* history.search(store, request).pipe(Effect.flip)).reason).toBe("unsupported");
- const source = conversation(["needle"]);
- yield* fs.writeFileString(
- filePath,
- JSON.stringify([
- { ...source, mapping: { "node-0": { ...source.mapping["node-0"], parent: "node-0" } } },
- ]),
- );
- expect((yield* history.search(store, request)).matches).toEqual([]);
- const duplicate = conversation(["first message", "needle second message"]);
- duplicate.mapping["node-1"]!.message.id = duplicate.mapping["node-0"]!.message.id;
- yield* fs.writeFileString(filePath, JSON.stringify([duplicate]));
- expect((yield* history.search(store, request)).matches).toEqual([]);
- yield* fs.writeFileString(filePath, '[{"bad":');
- expect((yield* history.search(store, request).pipe(Effect.flip)).reason).toBe("unsupported");
- }).pipe(Effect.scoped),
- );
-});
diff --git a/apps/server/src/project/externalHistory/ChatGptExportHistory.ts b/apps/server/src/project/externalHistory/ChatGptExportHistory.ts
deleted file mode 100644
index f45c9a43a5357..0000000000000
--- a/apps/server/src/project/externalHistory/ChatGptExportHistory.ts
+++ /dev/null
@@ -1,321 +0,0 @@
-import { ExternalHistoryError } from "@t3tools/contracts";
-import * as Effect from "effect/Effect";
-import * as FileSystem from "effect/FileSystem";
-import * as Option from "effect/Option";
-import * as PlatformError from "effect/PlatformError";
-import * as Schema from "effect/Schema";
-
-import { createTranscriptJsonReader, TranscriptJsonLimitError } from "../AgentSessionJson.ts";
-import {
- fileVersion,
- type LocalHistoryStore,
- type LocalThreadRef,
- type ThreadCache,
-} from "./LocalHistoryStore.ts";
-
-const MAX_CONVERSATION_BYTES = 8 * 1024 * 1024;
-const CHUNK_BYTES = 64 * 1024;
-const Message = Schema.Struct({
- id: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(512)),
- author: Schema.Struct({ role: Schema.String }),
- content: Schema.Struct({
- parts: Schema.optionalKey(Schema.Array(Schema.Unknown)),
- text: Schema.optionalKey(Schema.String),
- }),
- metadata: Schema.optionalKey(
- Schema.Struct({
- is_visually_hidden_from_conversation: Schema.optionalKey(Schema.Boolean),
- }),
- ),
-});
-const Conversation = Schema.Struct({
- id: Schema.optionalKey(Schema.String),
- conversation_id: Schema.optionalKey(Schema.String),
- title: Schema.String,
- current_node: Schema.NullOr(Schema.String),
- mapping: Schema.Record(
- Schema.String,
- Schema.Struct({
- parent: Schema.NullOr(Schema.String),
- message: Schema.NullOr(Message),
- }),
- ),
-});
-const decodeConversation = Schema.decodeUnknownEffect(Conversation);
-const isHistoryError = Schema.is(ExternalHistoryError);
-const isTranscriptJsonLimitError = Schema.is(TranscriptJsonLimitError);
-const decodeMetadata = Schema.decodeUnknownSync(
- Schema.Struct({
- id: Schema.optionalKey(Schema.String),
- conversation_id: Schema.optionalKey(Schema.String),
- title: Schema.String,
- }),
-);
-
-interface VisibleMessage {
- readonly id: string;
- readonly role: "user" | "assistant" | "tool";
- readonly text: string;
-}
-
-function visibleBranch(conversation: typeof Conversation.Type): ReadonlyArray {
- const seen = new Set();
- const messageIds = new Set();
- const messages: Array = [];
- let nodeId = conversation.current_node;
- if (nodeId === null && Object.keys(conversation.mapping).length > 0)
- throw new ExternalHistoryError({ reason: "unsupported" });
- while (nodeId !== null) {
- if (seen.has(nodeId)) throw new ExternalHistoryError({ reason: "unsupported" });
- seen.add(nodeId);
- const node = Object.hasOwn(conversation.mapping, nodeId)
- ? conversation.mapping[nodeId]
- : undefined;
- if (!node) throw new ExternalHistoryError({ reason: "unsupported" });
- const message = node.message;
- if (message && !message.metadata?.is_visually_hidden_from_conversation) {
- const role = message.author.role;
- if (role === "user" || role === "assistant" || role === "tool") {
- const text =
- message.content.text ??
- message.content.parts
- ?.filter((part): part is string => typeof part === "string")
- .join("\n") ??
- "";
- if (text.length > 0) {
- if (messageIds.has(message.id)) throw new ExternalHistoryError({ reason: "unsupported" });
- messageIds.add(message.id);
- messages.push({ id: message.id, role, text });
- }
- }
- }
- nodeId = node.parent;
- }
- return messages.reverse();
-}
-
-// Select text and branch links before assembly. Media objects and unrelated conversations
-// never become a second in-memory copy of the export.
-function selectExportPath(path: ReadonlyArray, index: number): boolean {
- if (path.length === 0) return true;
- if (path[0] !== index) return false;
- if (path.length === 1) return true;
- const field = path[1];
- if (field !== "mapping")
- return (
- path.length === 2 &&
- (field === "id" ||
- field === "conversation_id" ||
- field === "title" ||
- field === "current_node")
- );
- if (path.length <= 3) return true;
- if (path[3] === "parent") return path.length === 4;
- if (path[3] !== "message") return false;
- if (path.length === 4) return true;
- if (path[4] === "id") return path.length === 5;
- if (path[4] === "author") return path.length === 5 || (path.length === 6 && path[5] === "role");
- if (path[4] === "metadata")
- return (
- path.length === 5 || (path.length === 6 && path[5] === "is_visually_hidden_from_conversation")
- );
- if (path[4] !== "content") return false;
- return (
- path.length === 5 ||
- (path[5] === "text" && path.length === 6) ||
- (path[5] === "parts" && path.length <= 7)
- );
-}
-
-export const makeChatGptExportStore = Effect.fn("ChatGptExportHistory.open")(function* (
- filePath: string,
- cache: ThreadCache,
-) {
- const fs = yield* FileSystem.FileSystem;
- const mapError = (cause: unknown) =>
- isHistoryError(cause)
- ? cause
- : new ExternalHistoryError({
- reason: isTranscriptJsonLimitError(cause)
- ? "limit_exceeded"
- : PlatformError.isPlatformError(cause) && cause.reason._tag === "NotFound"
- ? "missing"
- : "unavailable",
- cause,
- });
-
- const readConversation = Effect.fn("ChatGptExportHistory.readConversation")(function* (
- filePath: string,
- index: number,
- expected?: string,
- ) {
- return yield* Effect.scoped(
- Effect.gen(function* () {
- const file = yield* fs.open(filePath, { flag: "r" });
- const stat = yield* file.stat;
- if (stat.type !== "File") return yield* new ExternalHistoryError({ reason: "unsupported" });
- const identity = fileVersion(stat);
- const version = identity;
- if (expected !== undefined && expected !== version)
- return yield* new ExternalHistoryError({ reason: "changed" });
- let value: unknown;
- let found = false;
- let allocated = 0;
- const reader = createTranscriptJsonReader(
- (bytes) => {
- allocated += bytes;
- if (allocated > MAX_CONVERSATION_BYTES)
- throw new TranscriptJsonLimitError({ message: "Export conversation limit" });
- },
- (path) => selectExportPath(path, index),
- {
- onArrayItem: (current, record) => {
- allocated = 0;
- if (current !== index) return true;
- value = record;
- found = true;
- return false;
- },
- },
- );
- const decoder = new TextDecoder("utf-8", { fatal: true });
- while (!reader.stopped) {
- const bytes = yield* file.readAlloc(CHUNK_BYTES);
- if (Option.isNone(bytes)) break;
- yield* Effect.try({
- try: () => reader.write(decoder.decode(bytes.value, { stream: true })),
- catch: mapError,
- });
- }
- if (!reader.stopped) {
- const finished = yield* Effect.try({
- try: () => {
- reader.write(decoder.decode());
- return reader.finish();
- },
- catch: mapError,
- });
- if (!Array.isArray(finished))
- return yield* new ExternalHistoryError({ reason: "unsupported" });
- }
- if (
- identity !== fileVersion(yield* file.stat) ||
- identity !== fileVersion(yield* fs.stat(filePath))
- )
- return yield* new ExternalHistoryError({ reason: "changed" });
- if (!found) return { file: version, conversation: null };
- const conversation = yield* decodeConversation(value).pipe(
- Effect.mapError(() => new ExternalHistoryError({ reason: "unsupported" })),
- );
- const threadId = conversation.id ?? conversation.conversation_id;
- if (!threadId || threadId.length > 512)
- return yield* new ExternalHistoryError({ reason: "unsupported" });
- const messages = yield* Effect.try({
- try: () => visibleBranch(conversation),
- catch: mapError,
- });
- return {
- file: version,
- conversation: { threadId, title: conversation.title.slice(0, 512), messages },
- };
- }),
- ).pipe(Effect.mapError(mapError));
- });
-
- let listing: { version: string; refs: ReadonlyArray } | undefined;
- const listThreads = Effect.gen(function* () {
- const stat = yield* fs.stat(filePath);
- if (stat.type !== "File") return yield* new ExternalHistoryError({ reason: "unsupported" });
- const version = fileVersion(stat);
- if (listing?.version === version) return listing.refs;
- const refs: LocalThreadRef[] = [];
- yield* Effect.scoped(
- Effect.gen(function* () {
- const file = yield* fs.open(filePath, { flag: "r" });
- if (fileVersion(yield* file.stat) !== version)
- return yield* new ExternalHistoryError({ reason: "changed" });
- let allocated = 0;
- const reader = createTranscriptJsonReader(
- (bytes) => {
- allocated += bytes;
- if (allocated > MAX_CONVERSATION_BYTES)
- throw new TranscriptJsonLimitError({ message: "Export metadata limit" });
- },
- (path) =>
- path.length <= 1 ||
- (path.length === 2 &&
- (path[1] === "id" || path[1] === "conversation_id" || path[1] === "title")),
- {
- onArrayItem: (index, value) => {
- allocated = 0;
- const metadata = decodeMetadata(value);
- const id = metadata.id ?? metadata.conversation_id;
- if (!id) throw new ExternalHistoryError({ reason: "unsupported" });
- const key = `${index}:${id}`;
- if (key.length > 512 || refs.length >= 50_000)
- throw new ExternalHistoryError({ reason: "limit_exceeded" });
- refs.push({
- key,
- title: metadata.title,
- updatedAt: Option.map(stat.mtime, (d) => d.getTime()).pipe(
- Option.getOrElse(() => 0),
- ),
- version,
- });
- return true;
- },
- },
- );
- const decoder = new TextDecoder("utf-8", { fatal: true });
- while (true) {
- const bytes = yield* file.readAlloc(CHUNK_BYTES);
- if (Option.isNone(bytes)) break;
- yield* Effect.try({
- try: () => reader.write(decoder.decode(bytes.value, { stream: true })),
- catch: mapError,
- });
- }
- const result = yield* Effect.try({
- try: () => {
- reader.write(decoder.decode());
- return reader.finish();
- },
- catch: mapError,
- });
- if (!Array.isArray(result))
- return yield* new ExternalHistoryError({ reason: "unsupported" });
- if (
- fileVersion(yield* file.stat) !== version ||
- fileVersion(yield* fs.stat(filePath)) !== version
- )
- return yield* new ExternalHistoryError({ reason: "changed" });
- }),
- );
- listing = { version, refs };
- return refs;
- }).pipe(Effect.mapError(mapError));
- const loadThread = Effect.fn("ChatGptExportHistory.loadThread")(function* (ref: LocalThreadRef) {
- const version = fileVersion(yield* fs.stat(filePath));
- const match = /^(0|[1-9][0-9]*):(.+)$/.exec(ref.key);
- if (!match || !Number.isSafeInteger(Number(match[1])))
- return yield* new ExternalHistoryError({ reason: "invalid_cursor" });
- const thread = yield* cache(
- `${filePath}\0${ref.key}\0${version}`,
- Effect.gen(function* () {
- const result = yield* readConversation(filePath, Number(match[1]), version);
- if (!result.conversation || result.conversation.threadId !== match[2])
- return yield* new ExternalHistoryError({ reason: "changed" });
- return {
- version,
- title: result.conversation.title,
- cwd: null,
- items: result.conversation.messages,
- };
- }),
- );
- if (fileVersion(yield* fs.stat(filePath)) !== version)
- return yield* new ExternalHistoryError({ reason: "changed" });
- return thread;
- }, Effect.mapError(mapError));
- return { listThreads, loadThread } satisfies LocalHistoryStore;
-});
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.test.ts b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
index d1d4e7959d519..3f148ac4b1640 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.test.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
@@ -123,7 +123,7 @@ const makeFixture = Effect.fn("ExternalHistory.test.fixture")(function* () {
};
const state = {
instances: [instance] as Array,
- settings: { enabled: true, chatGptExportPaths: [] } as ExternalHistorySettings,
+ settings: { enabled: true } as ExternalHistorySettings,
registryReads: 0,
ownedNativeIds: new Set(),
providerInstances: DEFAULT_SERVER_SETTINGS.providerInstances,
@@ -281,7 +281,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
it.effect("checks the environment opt-in before touching providers and on every read", () =>
Effect.gen(function* () {
const { service, state, calls } = yield* makeFixture();
- state.settings = { enabled: false, chatGptExportPaths: [] };
+ state.settings = { enabled: false };
expect(yield* service.query({ operation: "sources" })).toMatchObject({
operation: "sources",
enabled: false,
@@ -293,10 +293,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
.pipe(Effect.flip);
expect(error.reason).toBe("disabled");
expect(calls.list).toEqual([]);
- state.settings = { enabled: true, chatGptExportPaths: [] };
+ state.settings = { enabled: true };
const sources = yield* service.query({ operation: "sources" });
if (sources.operation !== "sources") return yield* Effect.die("wrong response");
- state.settings = { enabled: false, chatGptExportPaths: [] };
+ state.settings = { enabled: false };
const disabledRead = yield* service
.query({
operation: "read",
@@ -418,50 +418,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
}),
);
- it.effect("reads only configured exports without copying or importing their transcript", () =>
- Effect.gen(function* () {
- const { service, state } = yield* makeFixture();
- const fs = yield* FileSystem.FileSystem;
- const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-service-" });
- const file = `${directory}/conversations.json`;
- const contents =
- '[{"id":"chatgpt","title":"Export","current_node":"one","mapping":{"one":{"parent":null,"message":{"id":"message","author":{"role":"user"},"content":{"parts":["needle"]}}}}}]';
- yield* fs.writeFileString(file, contents);
- state.instances = [];
- state.settings = { enabled: true, chatGptExportPaths: [file, file] };
- const sources = yield* service.query({ operation: "sources" });
- if (sources.operation !== "sources") return yield* Effect.die("wrong response");
- expect(sources.sources).toHaveLength(1);
- expect(sources.sources[0]?.kind).toBe("chatgpt_export");
- const result = yield* service.query({
- operation: "search",
- sourceId: sources.sources[0]!.id,
- query: "needle",
- });
- if (result.operation !== "search") return yield* Effect.die("wrong response");
- expect(result.matches[0]?.excerpt).toBe("needle");
- for (const sourceId of [file, "../../private/conversations.json", "chatgpt:../private"]) {
- expect(
- (yield* service
- .query({ operation: "search", sourceId, query: "needle" })
- .pipe(Effect.flip)).reason,
- ).toBe("missing");
- expect(
- (yield* service
- .query({ operation: "read", ref: { ...result.matches[0]!.ref, sourceId } })
- .pipe(Effect.flip)).reason,
- ).toBe("missing");
- }
- state.settings = { enabled: true, chatGptExportPaths: [] };
- expect(
- (yield* service.query({ operation: "read", ref: result.matches[0]!.ref }).pipe(Effect.flip))
- .reason,
- ).toBe("missing");
- expect(yield* fs.readFileString(file)).toBe(contents);
- expect(yield* fs.readDirectory(directory)).toEqual(["conversations.json"]);
- }).pipe(Effect.scoped),
- );
-
it.effect("excludes native Codex sessions marked as T3 conversations", () =>
Effect.gen(function* () {
const { service, state, instance, native, calls } = yield* makeFixture();
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.ts b/apps/server/src/project/externalHistory/ExternalHistory.ts
index 47e3b2ac20f20..3a12d8dc67628 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.ts
@@ -27,7 +27,6 @@ import { resolveOpenCodeDataDirs } from "@t3tools/provider-opencode/server/usage
import * as ServerConfig from "../../config.ts";
import * as ServerSettings from "../../serverSettings.ts";
import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
-import { makeChatGptExportStore } from "./ChatGptExportHistory.ts";
import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts";
import { makeLocalHistory, type OwnedNativeIds } from "./LocalHistory.ts";
import { makeHistorySupport } from "./historySupport.ts";
@@ -280,26 +279,6 @@ const make = Effect.gen(function* () {
}
}
}
- for (const configured of value.externalHistory.chatGptExportPaths) {
- const filePath = path.resolve(config.cwd, configured);
- const id = `chatgpt:${yield* digest(filePath)}`;
- next.set(
- id,
- localCatalog.get(id) ?? {
- _tag: "local",
- info: {
- id,
- label: `ChatGPT · ${path.basename(filePath)}`,
- kind: "chatgpt_export",
- capabilities: { cwdFilter: false, archived: false },
- status: "ready",
- },
- store: yield* makeChatGptExportStore(filePath, local.cache).pipe(
- Effect.provideService(FileSystem.FileSystem, fs),
- ),
- },
- );
- }
localCatalog = next;
fingerprint = nextFingerprint;
}
diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json
index ec45155e91358..b77cc9e1c170b 100644
--- a/apps/server/src/provider/agentContextBudget.json
+++ b/apps/server/src/provider/agentContextBudget.json
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 8646,
"codexAdditionalContextBytes": 10372,
- "mcpToolListJsonBytes": 67725
+ "mcpToolListJsonBytes": 67696
}
diff --git a/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx b/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx
index 614cc81f480fe..9bacb890fa505 100644
--- a/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx
+++ b/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx
@@ -67,7 +67,7 @@ const configFor = (enabled: boolean, supported = true) => ({
environment: { capabilities: supported ? { externalHistory: 1 as const } : {} },
settings: {
...DEFAULT_SERVER_SETTINGS,
- externalHistory: { enabled, chatGptExportPaths: ["/exports/conversations.json"] },
+ externalHistory: { enabled },
},
});
const success = (data: ExternalHistoryResponse) => Atom.make(AsyncResult.success(data));
@@ -164,11 +164,6 @@ it("continues an empty bounded search, opens exact source pages, and hides revok
await page.getByRole("button", { name: "External history", exact: true }).click();
expect(state.requests).toContainEqual({ operation: "sources", catalogVersion: 2 });
await expect.element(page.getByText("Grok · No verified history reader.")).toBeVisible();
- await expect.element(page.getByLabelText("ChatGPT export paths")).not.toBeVisible();
- await page.getByText("Add a ChatGPT export", { exact: true }).click();
- await expect
- .element(page.getByRole("textbox", { name: "ChatGPT export paths" }))
- .toHaveValue("/exports/conversations.json");
await page.getByRole("textbox", { name: "Search external history" }).fill("source");
await page.getByRole("button", { name: "This project", exact: true }).click();
await page.getByRole("switch", { name: "Include archived threads" }).click();
@@ -215,7 +210,6 @@ it("continues an empty bounded search, opens exact source pages, and hides revok
await expect
.element(page.getByRole("switch", { name: "Allow external history search" }))
.toBeDisabled();
- await expect.element(page.getByRole("textbox", { name: "ChatGPT export paths" })).toBeDisabled();
await page.getByRole("button", { name: "Claude Code history", exact: true }).click();
await expect
.element(page.getByRole("textbox", { name: "History directory filter" }))
diff --git a/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx b/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx
index 4ef4d5d6c9416..e2c9112b73b78 100644
--- a/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx
+++ b/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx
@@ -31,7 +31,6 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Switch } from "../ui/switch";
-import { Textarea } from "../ui/textarea";
type Search = Extract;
@@ -75,7 +74,6 @@ function ExternalHistorySettingsEditor(props: {
serverEnvironment.updateSettings.permissionAtom(props.environmentId),
);
const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
- const [paths, setPaths] = useState(props.settings.chatGptExportPaths.join("\n"));
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const save = async (settings: ExternalHistorySettings) => {
@@ -120,50 +118,6 @@ function ExternalHistorySettingsEditor(props: {
) : null}
{props.children}
-
- Add a ChatGPT export
-
-
- ChatGPT export paths
-
-
- Paths refer to files on the T3 server, including when you connect remotely. Remove a
- path to stop offering that export.
-
-
-
- void save({
- ...props.settings,
- chatGptExportPaths: [
- ...new Set(
- paths
- .split("\n")
- .map((path) => path.trim())
- .filter(Boolean),
- ),
- ],
- })
- }
- >
- Save export paths
-
-
-
-
);
}
@@ -182,11 +136,7 @@ function ExternalHistoryPanel(props: {
const settings = config.settings.externalHistory;
return (
-
+
{settings.enabled ? (
) : (
diff --git a/apps/web/src/components/settings/ZackSettings.tsx b/apps/web/src/components/settings/ZackSettings.tsx
index a39b0b782d687..0c43f70e7ebc3 100644
--- a/apps/web/src/components/settings/ZackSettings.tsx
+++ b/apps/web/src/components/settings/ZackSettings.tsx
@@ -212,7 +212,7 @@ function ExternalHistoryRow() {
return (
{connectedEnvironments.map((environment) => (
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index ad1821ff600ce..99eeb78c2eadf 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -862,7 +862,7 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/labs",
scope: "environment",
searchTerms: [
- "zack lcm lossless context codex native cli app vscode chatgpt export conversation search history",
+ "zack lcm lossless context codex native cli app vscode conversation search history",
],
},
{
diff --git a/docs/user/external-history.md b/docs/user/external-history.md
index 253b6bb19468a..71ae5aee7699f 100644
--- a/docs/user/external-history.md
+++ b/docs/user/external-history.md
@@ -2,7 +2,7 @@
Open **Settings → Labs → Zack → External history** and turn on **Allow external history search** for the selected environment. The same interface is available from conversation summaries. Web, desktop, and mobile search the selected T3 server's sources, including when you connect remotely.
-External history finds supported agent conversations automatically. You do not need to enter paths or enable a provider for local transcript discovery. Choose a ready source, enter text, and search. **Refresh sources** checks for newly available stores.
+External history finds supported agent conversations automatically. You never enter a path, and local transcript discovery does not need an enabled provider. Choose a ready source, enter text, and search. **Refresh sources** checks for newly available stores.
## Automatically discovered history
@@ -12,18 +12,10 @@ Claude sessions marked as T3 conversations and Codex conversations with a T3 ori
Directory filters appear when the selected source supports them. Archived conversations are available only for sources that support that filter. Providers without a verified readable history format appear with a reason under the source list.
-## Add a ChatGPT export
-
-ChatGPT exports are optional. Expand **Add a ChatGPT export**, then add the path to an extracted `conversations.json` file under **ChatGPT export paths**. [Export your ChatGPT data](https://help.openai.com/en/articles/7260999-exporting-your-chatgpt-history-and-data) first. Numbered conversation JSON files can each be added on a separate line. Paths refer to files on the selected T3 server.
-
-T3 supports the conversation-array export format with a message mapping and current branch. It searches visible user, assistant, and tool text on that branch. Media objects, hidden messages, and abandoned branches are excluded. An unsupported export shape or incomplete branch returns an error.
-
-Replace an export to include newer conversations, then search again. Remove its configured path to stop offering it.
-
## Search and verify evidence
Search matches literal text without regard to case across complete visible messages and tool evidence. It can find old messages and messages earlier in long conversations. Search automatically skips pages without matches and shows the total number of items examined during the search. You can stop and resume scanning. Use **Continue search** to look for more matches.
-Open a match to read its original visible evidence in pages. A digest binds the result to the complete evidence. Changed or missing sources require a new search. Appending to a local transcript can invalidate an earlier result. Pages preserve Unicode boundaries. Oversized evidence produces an explicit capacity error. Very large ChatGPT exports can take longer because loading a later conversation reads the file prefix again.
+Open a match to read its original visible evidence in pages. A digest binds the result to the complete evidence. Changed or missing sources require a new search. Appending to a local transcript can invalidate an earlier result. Pages preserve Unicode boundaries. Oversized evidence produces an explicit capacity error.
Connected agents can use `t3_external_history` to discover sources, search, and verify original text. Delegated subagents cannot use it. Search does not import or resume a conversation, generate summaries, or create a disk index. Turning it off refuses subsequent searches and agent reads without deleting the original history.
diff --git a/packages/contracts/src/externalHistory.test.ts b/packages/contracts/src/externalHistory.test.ts
index b5d290c0ce234..2cd780c743ab8 100644
--- a/packages/contracts/src/externalHistory.test.ts
+++ b/packages/contracts/src/externalHistory.test.ts
@@ -1,6 +1,11 @@
import * as Schema from "effect/Schema";
import { describe, expect, it } from "vite-plus/test";
-import { ExternalHistoryRequest, ExternalHistoryResponse } from "./externalHistory.ts";
+import {
+ ExternalHistoryRequest,
+ ExternalHistoryResponse,
+ ExternalHistorySettings,
+} from "./externalHistory.ts";
+import { ServerSettings, ServerSettingsPatch } from "./settings.ts";
const decode = Schema.decodeUnknownSync(ExternalHistoryResponse);
const encode = Schema.encodeSync(ExternalHistoryResponse);
@@ -12,12 +17,15 @@ const legacySources = Schema.Struct({
Schema.Struct({
id: Schema.String,
label: Schema.String,
- kind: Schema.Literals(["codex", "chatgpt_export"]),
+ kind: Schema.Literals(["codex", "local_transcript"]),
}),
),
});
const decodeLegacy = Schema.decodeUnknownSync(legacySources);
+const decodeSettings = Schema.decodeUnknownSync(ExternalHistorySettings);
+const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
+const decodePatch = Schema.decodeUnknownSync(ServerSettingsPatch);
describe("external history catalogs", () => {
it("defaults legacy-server capabilities without inferring them from the source kind", () => {
@@ -91,4 +99,15 @@ describe("external history catalogs", () => {
}).sources,
).toHaveLength(1);
});
+
+ it("ignores the removed chatGptExportPaths key in stored settings and old-client patches", () => {
+ const old = { enabled: true, chatGptExportPaths: ["/exports/conversations.json"] };
+ expect(decodeSettings(old)).toEqual({ enabled: true });
+ expect(decodeServerSettings({ externalHistory: old }).externalHistory).toEqual({
+ enabled: true,
+ });
+ expect(decodePatch({ externalHistory: old })).toEqual({
+ externalHistory: { enabled: true },
+ });
+ });
});
diff --git a/packages/contracts/src/externalHistory.ts b/packages/contracts/src/externalHistory.ts
index 78895365246d0..c4a8718e6e4c2 100644
--- a/packages/contracts/src/externalHistory.ts
+++ b/packages/contracts/src/externalHistory.ts
@@ -9,9 +9,6 @@ const Digest = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/));
export const ExternalHistorySettings = Schema.Struct({
enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
- chatGptExportPaths: Schema.Array(Schema.Trim.check(Schema.isNonEmpty(), Schema.isMaxLength(4096)))
- .check(Schema.isMaxLength(32))
- .pipe(Schema.withDecodingDefault(Effect.succeed([]))),
});
export type ExternalHistorySettings = typeof ExternalHistorySettings.Type;
@@ -50,7 +47,7 @@ export type ExternalHistoryRequest = typeof ExternalHistoryRequest.Type;
export const ExternalHistorySource = Schema.Struct({
id: Identifier,
label: Schema.String,
- kind: Schema.Literals(["codex", "chatgpt_export", "local_transcript"]),
+ kind: Schema.Literals(["codex", "local_transcript"]),
provider: Schema.optionalKey(ProviderDriverKind),
capabilities: Schema.Struct({ cwdFilter: Schema.Boolean, archived: Schema.Boolean }).pipe(
Schema.withDecodingDefault(Effect.succeed({ cwdFilter: false, archived: false })),
From 8c7a22ea34336b69aebb8fb61ce6f422e20bb38d Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Thu, 12 Nov 2026 16:43:07 -0800
Subject: [PATCH 11/15] docs: portable handoffs names the sources external
history finds
The page still said external history reads configured ChatGPT exports, which
no longer exist.
Co-Authored-By: Claude Fable 5.2
---
docs/user/portable-handoffs.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/user/portable-handoffs.md b/docs/user/portable-handoffs.md
index 626e41914a02a..18904d4b287eb 100644
--- a/docs/user/portable-handoffs.md
+++ b/docs/user/portable-handoffs.md
@@ -57,7 +57,7 @@ message summaries, but may report that summaries could not be read when an updat
To search conversations created outside T3, open **External history** from this view or from
**Settings → Labs → Zack**. [External history](./external-history.md) can read the selected server's
-Codex conversations and configured ChatGPT exports without importing another transcript copy.
+Claude Code, Codex, Pi, and OpenCode conversations without importing another transcript copy.
## Restart large threads from summaries
From f2c52b2825981f0adc1938d362639cf116b35769 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Fri, 13 Nov 2026 00:16:44 -0800
Subject: [PATCH 12/15] fix(history): preserve paging consistency and isolate
broken sources
Bind search continuations to the ordered listing and resumed transcript version. Recover from individual inaccessible or disappearing entries without caching incomplete listings. Isolate invalid Claude profiles while retaining full deletion of legacy export history.
---
.../externalHistory/ExternalHistory.test.ts | 5 ++
.../externalHistory/JsonlHistoryStore.test.ts | 81 ++++++++++++++++++-
.../externalHistory/JsonlHistoryStore.ts | 41 +++++++---
.../externalHistory/LocalHistory.test.ts | 12 +++
.../project/externalHistory/LocalHistory.ts | 14 +++-
.../project/externalHistory/historySupport.ts | 7 +-
6 files changed, 140 insertions(+), 20 deletions(-)
diff --git a/apps/server/src/project/externalHistory/ExternalHistory.test.ts b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
index 3f148ac4b1640..2c6a589136214 100644
--- a/apps/server/src/project/externalHistory/ExternalHistory.test.ts
+++ b/apps/server/src/project/externalHistory/ExternalHistory.test.ts
@@ -205,6 +205,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("external history
enabled: false,
config: { homePath: claude },
},
+ [ProviderInstanceId.make("invalid-claude")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ enabled: false,
+ config: { homePath: 17 },
+ },
[ProviderInstanceId.make("opencode-downloads")]: {
driver: ProviderDriverKind.make("opencode"),
environment: [
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
index 385eb5b50b4c2..a44dcf0545d98 100644
--- a/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
@@ -4,6 +4,7 @@ import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as PlatformError from "effect/PlatformError";
import { makeClaudeHistoryStore, makePiHistoryStore } from "./JsonlHistoryStore.ts";
import { makeLocalHistory } from "./LocalHistory.ts";
@@ -89,8 +90,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
.read(store, { operation: "read", ref: first.matches[0]!.ref })
.pipe(Effect.flip)).reason,
).toBe("changed");
- const restarted = yield* history.search(store, { ...paged, cursor: second.nextCursor! });
- expect(restarted.matches[0]?.itemId).toBe("new-0");
+ expect(
+ (yield* history.search(store, { ...paged, cursor: second.nextCursor! }).pipe(Effect.flip))
+ .reason,
+ ).toBe("changed");
expect(
(yield* store
.loadThread({ key: "../../outside.jsonl", version: "", updatedAt: 0 })
@@ -135,6 +138,80 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
}).pipe(Effect.scoped),
);
+ it.effect("isolates unreadable entries and retries incomplete listings after recovery", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const root = yield* fs.makeTempDirectoryScoped();
+ const directory = `${root}/projects`;
+ for (const project of ["good", "blocked"]) {
+ yield* fs.makeDirectory(`${directory}/${project}`, { recursive: true });
+ yield* fs.writeFileString(
+ `${directory}/${project}/session.jsonl`,
+ JSON.stringify({ type: "user", uuid: project, message: { content: "needle" } }) + "\n",
+ );
+ }
+ const history = yield* makeLocalHistory;
+ const allKeys = ["blocked/session.jsonl", "good/session.jsonl"];
+ for (const failure of [
+ { method: "stat", path: `${directory}/blocked`, reason: "NotFound" },
+ { method: "readDirectory", path: `${directory}/blocked`, reason: "PermissionDenied" },
+ { method: "stat", path: `${directory}/blocked/session.jsonl`, reason: "NotFound" },
+ { method: "stat", path: `${directory}/blocked/session.jsonl`, reason: "PermissionDenied" },
+ ] as const) {
+ let unavailable = true;
+ const error = PlatformError.systemError({
+ _tag: failure.reason,
+ module: "FileSystem",
+ method: failure.method,
+ pathOrDescriptor: failure.path,
+ });
+ const partialFs = FileSystem.make({
+ ...fs,
+ stat: (path) =>
+ unavailable && failure.method === "stat" && path === failure.path
+ ? Effect.fail(error)
+ : fs.stat(path),
+ readDirectory: (path) =>
+ unavailable && failure.method === "readDirectory" && path === failure.path
+ ? Effect.fail(error)
+ : fs.readDirectory(path),
+ });
+ const store = yield* makeClaudeHistoryStore(root, history.cache).pipe(
+ Effect.provideService(FileSystem.FileSystem, partialFs),
+ );
+ expect((yield* store.listThreads).map((ref) => ref.key)).toEqual(["good/session.jsonl"]);
+ unavailable = false;
+ expect((yield* store.listThreads).map((ref) => ref.key).toSorted()).toEqual(allKeys);
+ if (failure.method === "stat") {
+ unavailable = true;
+ expect((yield* store.listThreads).map((ref) => ref.key)).toEqual(["good/session.jsonl"]);
+ unavailable = false;
+ expect((yield* store.listThreads).map((ref) => ref.key).toSorted()).toEqual(allKeys);
+ }
+ }
+ const inaccessibleRoot = yield* makeClaudeHistoryStore(root, history.cache).pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.make({
+ ...fs,
+ readDirectory: (path) =>
+ path === directory
+ ? Effect.fail(
+ PlatformError.systemError({
+ _tag: "PermissionDenied",
+ module: "FileSystem",
+ method: "readDirectory",
+ pathOrDescriptor: path,
+ }),
+ )
+ : fs.readDirectory(path),
+ }),
+ ),
+ );
+ expect((yield* inaccessibleRoot.listThreads.pipe(Effect.flip)).reason).toBe("unavailable");
+ }).pipe(Effect.scoped),
+ );
+
it.effect("searches sessions larger than the old allocation cap and verifies their source", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
index 2a082f039bef1..a9ac418a8f786 100644
--- a/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.ts
@@ -89,9 +89,18 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
| { directories: ReadonlyMap; refs: ReadonlyArray }
| undefined;
const listThreads = Effect.gen(function* () {
- if (listing) {
+ let complete = true;
+ const skipEntryError = (error: PlatformError.PlatformError) => {
+ if (error.reason._tag === "NotFound" || error.reason._tag === "PermissionDenied") {
+ complete = false;
+ return Effect.undefined;
+ }
+ return Effect.fail(error);
+ };
+ const cachedListing = listing;
+ if (cachedListing) {
let unchanged = true;
- for (const [dir, version] of listing.directories) {
+ for (const [dir, version] of cachedListing.directories) {
const stat = yield* fs.stat(dir).pipe(Effect.option);
if (Option.isNone(stat) || fileVersion(stat.value) !== version) {
unchanged = false;
@@ -101,7 +110,7 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
if (unchanged) {
// Appending a transcript does not change its directory's mtime.
const refreshed = yield* Effect.forEach(
- listing.refs,
+ cachedListing.refs,
(ref) =>
fs.stat(path.join(directory, ...ref.key.split("/"))).pipe(
Effect.map((info) =>
@@ -115,16 +124,13 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
}
: undefined,
),
- Effect.catchTags({
- PlatformError: (error) =>
- error.reason._tag === "NotFound" ? Effect.undefined : Effect.fail(error),
- }),
+ Effect.catchTags({ PlatformError: skipEntryError }),
),
{ concurrency: 16 },
);
const refs = refreshed.filter((ref) => ref !== undefined);
refs.sort((a, b) => b.updatedAt - a.updatedAt || a.key.localeCompare(b.key));
- listing = { directories: listing.directories, refs };
+ listing = complete ? { directories: cachedListing.directories, refs } : undefined;
return refs;
}
}
@@ -138,13 +144,19 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
for (const child of children) {
const childPath = path.join(directory, child);
if (yield* Effect.isSuccess(fs.readLink(childPath))) continue;
- const info = yield* fs.stat(childPath);
+ const info = yield* fs
+ .stat(childPath)
+ .pipe(Effect.catchTags({ PlatformError: skipEntryError }));
+ if (!info) continue;
const files =
info.type === "Directory"
- ? yield* fs.readDirectory(childPath)
+ ? yield* fs
+ .readDirectory(childPath)
+ .pipe(Effect.catchTags({ PlatformError: skipEntryError }))
: source === "pi" && child.endsWith(".jsonl")
? [child]
: [];
+ if (!files) continue;
if (info.type === "Directory") directories.set(childPath, fileVersion(info));
if (directories.size > 50_000 || files.length + refs.length > 50_000)
return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
@@ -152,8 +164,10 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
if (!name.endsWith(".jsonl")) continue;
const filePath = info.type === "Directory" ? path.join(childPath, name) : childPath;
if (yield* Effect.isSuccess(fs.readLink(filePath))) continue;
- const stat = yield* fs.stat(filePath);
- if (stat.type !== "File") continue;
+ const stat = yield* fs
+ .stat(filePath)
+ .pipe(Effect.catchTags({ PlatformError: skipEntryError }));
+ if (stat?.type !== "File") continue;
const key = path.relative(directory, filePath).split(path.sep).join("/");
if (key.length > 512) return yield* new ExternalHistoryError({ reason: "limit_exceeded" });
refs.push({
@@ -164,7 +178,8 @@ const makeJsonlHistoryStore = Effect.fn("ExternalHistory.openJsonlStore")(functi
}
}
refs.sort((a, b) => b.updatedAt - a.updatedAt || a.key.localeCompare(b.key));
- listing = { directories, refs };
+ // An omitted entry may recover without changing its parent's mtime.
+ listing = complete ? { directories, refs } : undefined;
return refs;
}).pipe(Effect.mapError(mapError));
diff --git a/apps/server/src/project/externalHistory/LocalHistory.test.ts b/apps/server/src/project/externalHistory/LocalHistory.test.ts
index 48b56f1316c89..c33541f00a129 100644
--- a/apps/server/src/project/externalHistory/LocalHistory.test.ts
+++ b/apps/server/src/project/externalHistory/LocalHistory.test.ts
@@ -103,6 +103,18 @@ it.layer(NodeCrypto.layer)("local history search paging", (it) => {
"last:hit",
]);
expect(requests).toBeGreaterThan(3);
+
+ let refs = yield* store.listThreads;
+ const changingStore = { ...store, listThreads: Effect.sync(() => refs) };
+ const history = yield* makeLocalHistory;
+ const request = { operation: "search", sourceId: "local", query: "needle" } as const;
+ const first = yield* history.search(changingStore, request);
+ refs = [refs.at(-1)!, ...refs.slice(0, -1)];
+ expect(
+ (yield* history
+ .search(changingStore, { ...request, cursor: first.nextCursor! })
+ .pipe(Effect.flip)).reason,
+ ).toBe("changed");
}),
);
diff --git a/apps/server/src/project/externalHistory/LocalHistory.ts b/apps/server/src/project/externalHistory/LocalHistory.ts
index 91439617bfa0d..7d1d71488906b 100644
--- a/apps/server/src/project/externalHistory/LocalHistory.ts
+++ b/apps/server/src/project/externalHistory/LocalHistory.ts
@@ -20,10 +20,11 @@ import {
} from "./LocalHistoryStore.ts";
const SearchCursor = Schema.Struct({
- v: Schema.Literal(2),
+ v: Schema.Literal(3),
sourceId: Schema.String,
query: Schema.String,
filterCwd: Schema.NullOr(Schema.String),
+ listingDigest: Schema.String,
afterKey: Schema.NullOr(Schema.String),
itemOffset: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
version: Schema.NullOr(Schema.String),
@@ -86,6 +87,10 @@ export const makeLocalHistory = Effect.gen(function* () {
)
return yield* invalidCursor();
const refs = yield* store.listThreads;
+ // Appends can reorder sessions between pages; never silently skip or repeat them.
+ const listingDigest = yield* digest(JSON.stringify(refs.map((ref) => ref.key)));
+ if (cursor && cursor.listingDigest !== listingDigest)
+ return yield* new ExternalHistoryError({ reason: "changed" });
const first =
cursor?.afterKey == null ? 0 : refs.findIndex((ref) => ref.key === cursor.afterKey);
if (first < 0) return yield* new ExternalHistoryError({ reason: "changed" });
@@ -93,10 +98,11 @@ export const makeLocalHistory = Effect.gen(function* () {
const ref = refs[index];
return ref
? encodeSearchCursor({
- v: 2,
+ v: 3,
sourceId: input.sourceId,
query: input.query,
filterCwd: input.filterCwd ?? null,
+ listingDigest,
afterKey: ref.key,
itemOffset,
version,
@@ -137,6 +143,8 @@ export const makeLocalHistory = Effect.gen(function* () {
() => Effect.undefined,
),
);
+ if (resumed?.version != null && resumed.version !== thread?.version)
+ return yield* new ExternalHistoryError({ reason: "changed" });
if (!thread) {
examinedChars += FAILED_LOAD_CHARS;
continue;
@@ -150,7 +158,7 @@ export const makeLocalHistory = Effect.gen(function* () {
normalizeProjectPathForComparison(input.filterCwd)))
)
continue;
- const offset = resumed?.version === thread.version ? resumed.itemOffset : 0;
+ const offset = resumed?.itemOffset ?? 0;
if (offset > thread.items.length) return yield* invalidCursor();
for (let position = offset; position < thread.items.length; position++) {
const item = thread.items[position]!;
diff --git a/apps/server/src/project/externalHistory/historySupport.ts b/apps/server/src/project/externalHistory/historySupport.ts
index 195c138afeb9d..cba8f46947f64 100644
--- a/apps/server/src/project/externalHistory/historySupport.ts
+++ b/apps/server/src/project/externalHistory/historySupport.ts
@@ -4,6 +4,7 @@ import { piHome, resolvePiSessionsDir } from "@t3tools/provider-pi/server/usage"
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { resolveClaudeRuntime } from "../../provider/Drivers/ClaudeHome.ts";
@@ -32,7 +33,7 @@ type HistorySupport = { readonly label: string } & (
| { readonly kind: "unsupported"; readonly reason: string }
);
-const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
+const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);
const unsupported = (label: string): HistorySupport => ({
kind: "unsupported",
label,
@@ -54,9 +55,11 @@ export function makeHistorySupport(stores: {
label: "Claude Code",
open: stores.claudeAgent,
roots: Effect.fn("ExternalHistory.claudeRoots")(function* (profile) {
+ const config = decodeClaudeSettings(profile.config ?? {});
+ if (Option.isNone(config)) return [];
const runtime = yield* resolveClaudeRuntime({
...profile,
- config: decodeClaudeSettings(profile.config ?? {}),
+ config: config.value,
});
return [runtime.configDir];
}),
From d3e998120f23ffe88deaca34f1b7edf42711654d Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy
Date: Fri, 13 Nov 2026 14:41:45 -0800
Subject: [PATCH 13/15] fix(history): encode the search listing digest without
JSON.stringify
Effect's typecheck rejects JSON.stringify inside Effect code, which failed CI.
Co-Authored-By: Claude Opus 5.5
---
apps/server/src/project/externalHistory/LocalHistory.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/apps/server/src/project/externalHistory/LocalHistory.ts b/apps/server/src/project/externalHistory/LocalHistory.ts
index 7d1d71488906b..dbd731a785e49 100644
--- a/apps/server/src/project/externalHistory/LocalHistory.ts
+++ b/apps/server/src/project/externalHistory/LocalHistory.ts
@@ -58,6 +58,8 @@ const invalidCursor = () => new ExternalHistoryError({ reason: "invalid_cursor"
const evidence = (item: { readonly role: string; readonly text: string }) =>
`external-history-v2\n${item.role}\n${item.text}`;
+const encodeListingKeys = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String)));
+
export type OwnedNativeIds = (
nativeIds: ReadonlyArray,
) => Effect.Effect, ExternalHistoryError>;
@@ -88,7 +90,10 @@ export const makeLocalHistory = Effect.gen(function* () {
return yield* invalidCursor();
const refs = yield* store.listThreads;
// Appends can reorder sessions between pages; never silently skip or repeat them.
- const listingDigest = yield* digest(JSON.stringify(refs.map((ref) => ref.key)));
+ const listingDigest = yield* encodeListingKeys(refs.map((ref) => ref.key)).pipe(
+ Effect.mapError((cause) => new ExternalHistoryError({ reason: "unavailable", cause })),
+ Effect.flatMap(digest),
+ );
if (cursor && cursor.listingDigest !== listingDigest)
return yield* new ExternalHistoryError({ reason: "changed" });
const first =
From ce43a2658922fb91a0c40e687583253167ab5c69 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:57:38 -0800
Subject: [PATCH 14/15] fix(mobile): external history search field stays above
the keyboard
Co-Authored-By: Claude Fable 5.2
---
.../features/external-history/ExternalHistoryRouteScreen.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
index 18dbdaf6365d0..f4d24078a91e6 100644
--- a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
+++ b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx
@@ -17,7 +17,7 @@ import {
type ProjectId,
} from "@t3tools/contracts";
import { useEffect, useReducer, useState, type ReactNode } from "react";
-import { ScrollView, View } from "react-native";
+import { Platform, ScrollView, View } from "react-native";
import { AppBar } from "../../components/ui/AppBar";
import { AppText as Text, AppTextInput } from "../../components/ui/AppText";
@@ -74,6 +74,8 @@ export function ExternalHistoryRouteScreen(
From cf6ddc2253ddb818c9aa523a652ec2f5d69b6041 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:57:49 -0800
Subject: [PATCH 15/15] test(server): JSONL history store tests pass on macOS
temp paths
Co-Authored-By: Claude Fable 5.2
---
.../externalHistory/JsonlHistoryStore.test.ts | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
index a44dcf0545d98..384acb5377afb 100644
--- a/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
+++ b/apps/server/src/project/externalHistory/JsonlHistoryStore.test.ts
@@ -14,7 +14,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
- const root = yield* fs.makeTempDirectoryScoped();
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped());
const project = `${root}/projects/project`;
yield* fs.makeDirectory(project, { recursive: true });
const file = `${project}/session.jsonl`;
@@ -105,7 +105,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
it.effect("keeps valid messages around malformed and undecodable records", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
- const root = yield* fs.makeTempDirectoryScoped();
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped());
const project = `${root}/projects/project`;
yield* fs.makeDirectory(project, { recursive: true });
const message = (uuid: string, content = "needle") =>
@@ -141,7 +141,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
it.effect("isolates unreadable entries and retries incomplete listings after recovery", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
- const root = yield* fs.makeTempDirectoryScoped();
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped());
const directory = `${root}/projects`;
for (const project of ["good", "blocked"]) {
yield* fs.makeDirectory(`${directory}/${project}`, { recursive: true });
@@ -215,7 +215,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
it.effect("searches sessions larger than the old allocation cap and verifies their source", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
- const root = yield* fs.makeTempDirectoryScoped();
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped());
const project = `${root}/projects/project`;
yield* fs.makeDirectory(project, { recursive: true });
const file = `${project}/large.jsonl`;
@@ -262,7 +262,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
it.effect("finds queries split across read chunks and queries that JSON escapes", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
- const root = yield* fs.makeTempDirectoryScoped();
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped());
const project = `${root}/projects/project`;
yield* fs.makeDirectory(project, { recursive: true });
const head = '{"type":"user","uuid":"split","message":{"content":"';
@@ -298,7 +298,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
it.effect("refreshes newest-first order when an existing session is appended", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
- const root = yield* fs.makeTempDirectoryScoped();
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped());
const project = `${root}/projects/project`;
yield* fs.makeDirectory(project, { recursive: true });
const message = (uuid: string) =>
@@ -330,7 +330,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("local JSONL hist
it.effect("excludes T3 Claude sessions and follows the active Pi branch", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
- const root = yield* fs.makeTempDirectoryScoped();
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped());
const project = `${root}/projects/project`;
yield* fs.makeDirectory(project, { recursive: true });
yield* fs.writeFileString(