feat(app): find text throughout chat history

Discover candidate transcript locations on the daemon, then reuse prompt navigation to load history and verify rendered occurrences on the client. Keep search state inside its own modules without changing timeline or task presentation contracts.
This commit is contained in:
Mohamed Boudra
2026-09-12 12:08:23 +02:00
parent db8c22e135
commit 2bb62bb8e4
30 changed files with 1448 additions and 68 deletions
+2
View File
@@ -88,6 +88,7 @@
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.15",
"openai": "^6.44.0",
@@ -110,6 +111,7 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/express": "^4.17.20",
"@types/markdown-it": "^14.2.0",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.7",
@@ -0,0 +1,67 @@
import { expect, it } from "vitest";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import { searchTimeline } from "./index.js";
function rows(items: AgentTimelineItem[]) {
return items.map((item, index) => ({ item, seq: index + 1, timestamp: "2026-09-12T00:00:00Z" }));
}
it("returns source locations for formatted assistant text and literal user text", async () => {
const result = await searchTimeline({
rows: rows([
{ type: "user_message", text: "hello **world**" },
{ type: "assistant_message", text: "İ😀hello **world** and hello *world*" },
{
type: "assistant_message",
text: "[hello & world](https://example.com)",
messageId: "separate",
},
]),
query: "hello world",
});
expect(result).toEqual({ locations: [{ seq: 2, role: "assistant" }], nextCursor: null });
});
it("finds code and literal punctuation without searching tools, reasoning, or task state", async () => {
const result = await searchTimeline({
rows: rows([
{ type: "assistant_message", text: "```ts\nconst value = 'a.b';\n```" },
{ type: "reasoning", text: "a.b" },
{ type: "todo", items: [{ text: "a.b", completed: false }] },
{ type: "user_message", text: "axb" },
{ type: "user_message", text: "a.b" },
]),
query: "a.b",
});
expect(result.locations).toEqual([
{ seq: 1, role: "assistant" },
{ seq: 5, role: "user" },
]);
});
it("bounds results and resumes without losing locations", async () => {
const source = rows(
Array.from({ length: 202 }, () => ({ type: "user_message", text: "target" })),
);
const first = await searchTimeline({ rows: source, query: "target" });
expect(first.locations).toHaveLength(200);
expect(first.nextCursor).toBe(200);
const second = await searchTimeline({ rows: source, query: "target", cursor: first.nextCursor! });
expect(second).toEqual({
locations: [
{ seq: 201, role: "user" },
{ seq: 202, role: "user" },
],
nextCursor: null,
});
});
it("finds ordinary Markdown entities and line breaks without requiring identical parsers", async () => {
const source = rows([{ type: "assistant_message", text: "hello & **world**\nnext line" }]);
expect((await searchTimeline({ rows: source, query: "hello & world" })).locations).toEqual([
{ seq: 1, role: "assistant" },
]);
expect((await searchTimeline({ rows: source, query: "world next" })).locations).toEqual([
{ seq: 1, role: "assistant" },
]);
});
@@ -0,0 +1,69 @@
import MarkdownIt from "markdown-it";
import { setImmediate } from "node:timers/promises";
import type { AgentTimelineRow } from "../agent-timeline-store-types.js";
import { projectTimelineRows } from "../timeline-projection.js";
const markdown = new MarkdownIt({ html: false });
const PAGE_SIZE = 200;
interface SearchLocation {
seq: number;
role: "user" | "assistant";
}
interface SearchInput {
rows: readonly AgentTimelineRow[];
query: string;
cursor?: number;
}
// Discovery is deliberately approximate. Only the client can verify displayed text.
function searchableMarkdown(text: string): string {
return markdown
.parse(text, {})
.map((token) => {
if (token.type === "inline") {
return (token.children ?? [])
.map((child) => {
if (child.type === "text" || child.type === "code_inline") return child.content;
if (child.type === "softbreak" || child.type === "hardbreak") return "\n";
return "";
})
.join("");
}
if (token.type === "fence" || token.type === "code_block") return token.content;
return "";
})
.filter(Boolean)
.join("\n");
}
export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) {
const locations: SearchLocation[] = [];
if (!query.trim()) return { locations, nextCursor: null };
const needle = normalizeSearchText(query);
let lastYield = performance.now();
for (const entry of projectTimelineRows({ rows, mode: "projected" })) {
if (entry.seqEnd <= cursor) continue;
const item = entry.item;
if (item.type !== "user_message" && item.type !== "assistant_message") continue;
const raw = item.text.replace(/\r/g, "");
const text = item.type === "assistant_message" ? searchableMarkdown(raw) : raw;
if (normalizeSearchText(raw).includes(needle) || normalizeSearchText(text).includes(needle)) {
if (locations.length === PAGE_SIZE) {
return { locations, nextCursor: locations[PAGE_SIZE - 1]!.seq };
}
locations.push({
seq: entry.seqEnd,
role: item.type === "user_message" ? "user" : "assistant",
});
}
if (performance.now() - lastYield >= 8) {
await setImmediate();
lastYield = performance.now();
}
}
return { locations, nextCursor: null };
}
function normalizeSearchText(text: string): string {
return text.replace(/\s+/g, " ").trim().toLowerCase();
}
@@ -18,6 +18,7 @@ const INBOUND_PERMISSION = {
"agent.skills.reconcile.request": "daemon.manage",
"agent.skills.save_selection.request": "daemon.manage",
"agent.skills.uninstall.request": "daemon.manage",
"agent.timeline.search.request": "workspace.read",
"agent.timeline.list_prompts.request": "workspace.read",
"agent.timeline.append.request": "workspace.write",
"session.events.set_subscription.request": ["workspace.read", "daemon.read", "hub.execute"],
@@ -219,6 +220,7 @@ const OUTBOUND_PERMISSION = {
"agent.skills.reconcile.response": "daemon.manage",
"agent.skills.save_selection.response": "daemon.manage",
"agent.skills.uninstall.response": "daemon.manage",
"agent.timeline.search.response": "workspace.read",
"agent.timeline.list_prompts.response": "workspace.read",
"agent.timeline.append.response": "workspace.write",
"agent.timeline.replacement": ["workspace.read", "hub.execute"],
+56
View File
@@ -1,3 +1,4 @@
import { searchTimeline } from "./agent/chat-search/index.js";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import type { AgentRequests } from "./agent/requests/index.js";
import equal from "fast-deep-equal";
@@ -2328,6 +2329,8 @@ export class Session {
return this.handleFetchAgentTimelineRequest(msg, source);
case "agent.timeline.append.request":
return this.handleAgentTimelineAppendRequest(msg);
case "agent.timeline.search.request":
return this.handleAgentTimelineSearchRequest(msg, source);
case "agent.timeline.list_prompts.request":
return this.handleAgentTimelineListPromptsRequest(msg, source);
case "agent.provider_subagents.list.request":
@@ -7354,6 +7357,59 @@ export class Session {
});
}
private async handleAgentTimelineSearchRequest(
msg: Extract<SessionInboundMessage, { type: "agent.timeline.search.request" }>,
source?: object,
): Promise<void> {
try {
await ensureAgentLoaded(msg.agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
const rows = await this.agentManager.getTimelineRows(msg.agentId);
const { epoch } = this.agentManager.fetchTimeline(msg.agentId, {
direction: "tail",
limit: 1,
});
const result = await searchTimeline({ rows, query: msg.query, cursor: msg.cursor });
if (
this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 1 }).epoch !==
epoch
) {
throw new Error("History changed; search again");
}
this.emitForSource(
{
type: "agent.timeline.search.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
epoch,
...result,
error: null,
},
},
source,
);
} catch (error) {
this.emitForSource(
{
type: "agent.timeline.search.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
epoch: "",
locations: [],
nextCursor: null,
error: error instanceof Error ? error.message : String(error),
},
},
source,
);
}
}
private async handleAgentTimelineListPromptsRequest(
msg: Extract<SessionInboundMessage, { type: "agent.timeline.list_prompts.request" }>,
source?: object,