Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/adapters/channel/protocol/ChannelCommandRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
*/

import type { Gateway } from "../../../gateway/index.js";
import { resolvePilotHome } from "../../../pilot/index.js";
import { runChatSearchFormatted } from "../../../cli/commands/chatSearch.js";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -242,6 +244,31 @@ const commands: ChannelCommand[] = [
},
},

{
name: "search",
aliases: ["find", "grep", "搜索"],
description: "Search chat history across sessions",
systemLevel: true,
handler: async (ctx, arg) => {
const projectRoot = ctx.getProject?.();
const parsed = arg.trim();
if (!parsed) {
await ctx.reply(
"用法:/search <关键词> [--all] [--limit N] [--role user|assistant]\n示例:/search docker 部署",
);
return;
}

const { text } = await runChatSearchFormatted({
arg: parsed,
projectRoot,
pilotHome: resolvePilotHome(process.env),
locale: "zh",
});
await ctx.reply(text);
},
},

{
name: "help",
aliases: ["帮助"],
Expand Down
118 changes: 118 additions & 0 deletions src/cli/commands/chatSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { resolvePilotHome } from "../../pilot/index.js";
import {
formatChatHistorySearchResults,
} from "../../session/search/formatChatHistorySearch.js";
import {
parseChatSearchArgs,
searchChatHistory,
type SearchChatHistoryResult,
} from "../../session/search/searchChatHistory.js";

export type RunChatSearchOptions = {
pilotHome?: string;
projectRoot?: string;
arg: string;
locale?: "zh" | "en";
};

export async function runChatSearch(options: RunChatSearchOptions): Promise<SearchChatHistoryResult> {
const parsed = parseChatSearchArgs(options.arg);
const pilotHome = options.pilotHome ?? resolvePilotHome(process.env);

return searchChatHistory({
pilotHome,
projectRoot: parsed.allProjects ? undefined : options.projectRoot,
query: parsed.query,
limit: parsed.limit,
regex: parsed.regex,
caseSensitive: parsed.caseSensitive,
role: parsed.role,
sessionId: parsed.sessionId,
});
}

export async function runChatSearchFormatted(options: RunChatSearchOptions): Promise<{
result: SearchChatHistoryResult;
text: string;
}> {
const result = await runChatSearch(options);
const text = formatChatHistorySearchResults(result, {
locale: options.locale,
includeProject: parseChatSearchArgs(options.arg).allProjects || !options.projectRoot,
});
return { result, text };
}

function readStringFlag(argv: string[], flag: string): string | undefined {
const index = argv.indexOf(flag);
if (index === -1) return undefined;
return argv[index + 1];
}

function readNumberFlag(argv: string[], flag: string): number | undefined {
const value = readStringFlag(argv, flag);
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}

export async function runChatSearchCli(argv: string[]): Promise<void> {
const subcommand = argv[0];
if (subcommand !== "search") {
console.error(
"Usage: pilotdeck chat search <keyword> [--project <path>] [--all-projects] [--limit N] [--json] [--regex] [--case-sensitive] [--role user|assistant|all] [--session <id>]",
);
process.exitCode = 1;
return;
}

const json = argv.includes("--json");
const allProjects = argv.includes("--all-projects");
const projectRoot = readStringFlag(argv, "--project") ?? process.cwd();
const limit = readNumberFlag(argv, "--limit");
const regex = argv.includes("--regex");
const caseSensitive = argv.includes("--case-sensitive");
const roleFlag = readStringFlag(argv, "--role");
const role = roleFlag === "user" || roleFlag === "assistant" || roleFlag === "all" ? roleFlag : undefined;
const sessionId = readStringFlag(argv, "--session");

const queryParts = argv
.slice(1)
.filter((token, index, all) => {
if (token.startsWith("--")) return false;
const prev = all[index - 1];
if (prev === "--project" || prev === "--limit" || prev === "--role" || prev === "--session") {
return false;
}
return true;
});

const query = queryParts.join(" ").trim();
if (!query) {
console.error("Error: search keyword is required.");
process.exitCode = 1;
return;
}

const pilotHome = readStringFlag(argv, "--pilot-home") ?? resolvePilotHome(process.env);
const result = await searchChatHistory({
pilotHome,
projectRoot: allProjects ? undefined : projectRoot,
query,
limit,
regex,
caseSensitive,
role,
sessionId,
});

if (json) {
console.log(JSON.stringify(result, null, 2));
return;
}

console.log(formatChatHistorySearchResults(result, {
locale: "en",
includeProject: allProjects,
}));
}
6 changes: 6 additions & 0 deletions src/cli/pilotdeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,12 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
return;
}

if (command === "chat") {
const { runChatSearchCli } = await import("./commands/chatSearch.js");
await runChatSearchCli(argv.slice(1));
return;
}

if (command === "tui") {
if (!process.stdin.isTTY) {
console.error("pilotdeck tui requires an interactive terminal.");
Expand Down
13 changes: 13 additions & 0 deletions src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ export {
type SearchSessionsByTitleOptions,
type SessionInfo,
} from "./storage/SessionList.js";
export {
formatChatHistorySearchResults,
type FormatChatHistorySearchOptions,
} from "./search/formatChatHistorySearch.js";
export {
parseChatSearchArgs,
searchChatHistory,
type ChatHistorySearchMatch,
type ChatHistorySearchRole,
type ParsedChatSearchArgs,
type SearchChatHistoryOptions,
type SearchChatHistoryResult,
} from "./search/searchChatHistory.js";
export {
buildConversationChain,
type TranscriptChainNode,
Expand Down
94 changes: 94 additions & 0 deletions src/session/search/formatChatHistorySearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { ChatHistorySearchMatch, SearchChatHistoryResult } from "./searchChatHistory.js";

export type FormatChatHistorySearchOptions = {
locale?: "zh" | "en";
includeProject?: boolean;
};

export function formatChatHistorySearchResults(
result: SearchChatHistoryResult,
options: FormatChatHistorySearchOptions = {},
): string {
const locale = options.locale ?? "zh";
const includeProject = options.includeProject ?? false;

if (!result.query.trim()) {
return locale === "zh"
? "用法:/search <关键词> [--all] [--limit N] [--role user|assistant]\n示例:/search docker 部署"
: "Usage: /search <keyword> [--all] [--limit N] [--role user|assistant]\nExample: /search docker deploy";
}

if (result.matches.length === 0) {
const scope = locale === "zh"
? `已扫描 ${result.sessionsScanned} 个会话`
: `Scanned ${result.sessionsScanned} session(s)`;
return locale === "zh"
? `未找到包含「${result.query}」的聊天记录。\n${scope}。`
: `No chat history matches for "${result.query}".\n${scope}.`;
}

const header = locale === "zh"
? `🔍 找到 ${result.matches.length} 条匹配「${result.query}」${result.truncated ? "(结果已截断,可加大 --limit)" : ""}`
: `🔍 ${result.matches.length} match(es) for "${result.query}"${result.truncated ? " (truncated — try a higher --limit)" : ""}`;

const lines = [header, ""];
result.matches.forEach((match, index) => {
lines.push(formatMatchLine(match, index + 1, { locale, includeProject }));
lines.push("");
});

lines.push(
locale === "zh"
? "提示:在 Web UI 中打开对应会话后,可用 /search 结果中的片段定位消息。"
: "Tip: open the session in the Web UI to jump to the matched message.",
);

return lines.join("\n").trimEnd();
}

function formatMatchLine(
match: ChatHistorySearchMatch,
index: number,
options: { locale: "zh" | "en"; includeProject: boolean },
): string {
const roleLabel = match.role === "user"
? (options.locale === "zh" ? "用户" : "user")
: (options.locale === "zh" ? "助手" : "assistant");
const shortId = shortenId(match.sessionId);
const when = formatWhen(match.createdAt, options.locale);
const projectSuffix = options.includeProject && match.projectKey
? ` · ${basename(match.projectKey)}`
: "";

return [
`${index}. **${match.sessionTitle}** (\`${shortId}\`)${projectSuffix}`,
` ${roleLabel} · ${when}`,
` > ${match.snippet}`,
].join("\n");
}

function shortenId(sessionId: string): string {
if (sessionId.length <= 12) return sessionId;
return `${sessionId.slice(0, 8)}…`;
}

function formatWhen(createdAt: string, locale: "zh" | "en"): string {
if (!createdAt) {
return locale === "zh" ? "未知时间" : "unknown time";
}
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) {
return createdAt;
}
return date.toLocaleString(locale === "zh" ? "zh-CN" : "en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

function basename(value: string): string {
const parts = value.split(/[\\/]/);
return parts[parts.length - 1] || value;
}
Loading