mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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:
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user