diff --git a/.changeset/tidy-ravens-search.md b/.changeset/tidy-ravens-search.md new file mode 100644 index 0000000..eb3472c --- /dev/null +++ b/.changeset/tidy-ravens-search.md @@ -0,0 +1,5 @@ +--- +"t3code-cli": patch +--- + +add conversation content search command diff --git a/README.md b/README.md index d1493a6..87595d3 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ t3cli start [message] ```sh t3cli list [--project ] [--archived | --all] +t3cli search [--limit <1-50>] # Search conversation content t3cli show [--thread ] # Show thread details t3cli send [--thread ] [message] # Send message to thread t3cli transcript [--thread ] [--limit] # View messages diff --git a/skills/t3code-cli/SKILL.md b/skills/t3code-cli/SKILL.md index 2acf1a1..7077c96 100644 --- a/skills/t3code-cli/SKILL.md +++ b/skills/t3code-cli/SKILL.md @@ -110,6 +110,7 @@ Use cases: handoff long tasks, parallel work notifications, async workflows. ```sh t3cli list --format json +t3cli search "conversation text" --format json t3cli transcript --thread --format json printf '%s' "$PROMPT" | t3cli start --stdin --format json ``` diff --git a/skills/t3code-cli/reference/commands.md b/skills/t3code-cli/reference/commands.md index afae326..33d3b43 100644 --- a/skills/t3code-cli/reference/commands.md +++ b/skills/t3code-cli/reference/commands.md @@ -7,7 +7,7 @@ t3cli ├── env list|use|remove ├── project list|add|delete ├── model list -├── list|start|send|show|transcript|wait +├── list|search|start|send|show|transcript|wait ├── terminal list|create|attach|read|stream|wait|write|destroy └── thread approve|respond|archive|interrupt|unarchive|update|delete|callback ``` @@ -96,6 +96,7 @@ Mutations dispatch `project.meta.update` with the next full scripts array and wa ```sh t3cli list [--project ] [--archived | --all] [--format json] +t3cli search [--limit <1-50>] [--format auto|human|json] t3cli start [message] [--project ] [--stdin] [--title ] [--worktree <path>] diff --git a/src/application/index.ts b/src/application/index.ts index e0f63b8..df482a5 100644 --- a/src/application/index.ts +++ b/src/application/index.ts @@ -31,3 +31,4 @@ export type { WaitEvent, } from "./service.ts"; export type { ApplicationError } from "./error.ts"; +export type { ThreadSearchResult } from "./threads.ts"; diff --git a/src/application/service.ts b/src/application/service.ts index 580e276..30c4857 100644 --- a/src/application/service.ts +++ b/src/application/service.ts @@ -6,6 +6,7 @@ import type { ModelSelection, OrchestrationMessage, OrchestrationProjectShell, + OrchestrationSearchThreadsInput, OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadShell, @@ -20,7 +21,7 @@ import type { } from "@t3tools/contracts"; import type { ApplicationError } from "./error.ts"; -import type { ThreadShow } from "./threads.ts"; +import type { ThreadSearchResult, ThreadShow } from "./threads.ts"; export type StartThreadInput = { readonly projectRef?: string; @@ -196,6 +197,9 @@ export class T3ProjectApplication extends Context.Service< >()("t3cli/T3ProjectApplication") {} export type T3ThreadApplicationService = { + readonly searchThreads: ( + input: OrchestrationSearchThreadsInput, + ) => Effect.Effect<ReadonlyArray<ThreadSearchResult>, ApplicationError>; readonly listThreads: ( projectRef: string, options?: { diff --git a/src/application/threads.ts b/src/application/threads.ts index 772e6e8..456daa8 100644 --- a/src/application/threads.ts +++ b/src/application/threads.ts @@ -9,7 +9,11 @@ import { resolveProjectScope } from "../domain/helpers.ts"; import { type ListThreadsInclude, type StartThreadInput } from "./service.ts"; import type { CallbackThreadInput, SendThreadInput } from "./service.ts"; import type { T3ThreadApplicationService } from "./service.ts"; -import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import type { + OrchestrationSearchThreadsInput, + OrchestrationThreadSearchMatch, + OrchestrationThreadShell, +} from "@t3tools/contracts"; import { mergeModelOptions } from "./model-selection.ts"; import { derivePendingApprovals, derivePendingUserInputs } from "../domain/thread-activities.ts"; import { @@ -73,6 +77,30 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function threads: snapshot.threads.filter((thread) => thread.projectId === scope.project.id), }; }); + const searchThreads = Effect.fn("T3ApplicationLive.searchThreads")(function* ( + input: OrchestrationSearchThreadsInput, + ) { + const result = yield* orchestration.searchThreads(input); + const snapshot = yield* orchestration.getShellSnapshot(); + const threadsById = new Map(snapshot.threads.map((thread) => [thread.id, thread])); + const projectsById = new Map(snapshot.projects.map((project) => [project.id, project])); + return result.matches.map((match) => { + const thread = threadsById.get(match.threadId); + const project = projectsById.get(match.projectId); + return { + threadId: match.threadId, + threadTitle: thread?.title ?? null, + projectId: match.projectId, + projectTitle: project?.title ?? null, + workspaceRoot: project?.workspaceRoot ?? null, + branch: thread?.branch ?? null, + worktreePath: thread?.worktreePath ?? null, + source: match.source, + snippet: match.snippet, + messageCreatedAt: match.messageCreatedAt, + } satisfies ThreadSearchResult; + }); + }); const getThreadMessages = Effect.fn("T3ApplicationLive.getThreadMessages")(function* ( threadId: string, ) { @@ -276,6 +304,7 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function updateThread, unarchiveThread, listThreads, + searchThreads, getThreadMessages, respondToThread, sendThread, @@ -287,6 +316,19 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function } satisfies T3ThreadApplicationService; }); +export type ThreadSearchResult = { + readonly threadId: OrchestrationThreadSearchMatch["threadId"]; + readonly threadTitle: string | null; + readonly projectId: OrchestrationThreadSearchMatch["projectId"]; + readonly projectTitle: string | null; + readonly workspaceRoot: string | null; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly source: OrchestrationThreadSearchMatch["source"]; + readonly snippet: OrchestrationThreadSearchMatch["snippet"]; + readonly messageCreatedAt: OrchestrationThreadSearchMatch["messageCreatedAt"]; +}; + const loadThreadsSnapshot = Effect.fn("loadThreadsSnapshot")(function* ( include: ListThreadsInclude, ) { diff --git a/src/cli/app.ts b/src/cli/app.ts index 4b31c39..e8b7d42 100644 --- a/src/cli/app.ts +++ b/src/cli/app.ts @@ -7,6 +7,7 @@ import { cliEnvironmentSetting } from "./env/flag.ts"; import { createTerminalCommandGroup } from "./terminal.ts"; import { createModelCommand } from "./model.ts"; import { createProjectCommand } from "./project.ts"; +import { searchThreadsCommand } from "./search.ts"; import { createThreadCommand } from "./thread.ts"; import { listThreadsCommand } from "./threads/list.ts"; import { sendThreadCommand } from "./threads/send.ts"; @@ -26,6 +27,7 @@ export function createCliCommand() { listThreadsCommand, createModelCommand(), createProjectCommand(), + searchThreadsCommand, createTerminalCommandGroup(), startThreadCommand, sendThreadCommand, diff --git a/src/cli/format/thread.ts b/src/cli/format/thread.ts index 92fc8dc..95b6e93 100644 --- a/src/cli/format/thread.ts +++ b/src/cli/format/thread.ts @@ -1,4 +1,4 @@ -import type { ThreadShow } from "../../application/threads.ts"; +import type { ThreadSearchResult, ThreadShow } from "../../application/threads.ts"; import type { WaitEvent } from "../../application/service.ts"; import type { OrchestrationThread, OrchestrationThreadShell } from "@t3tools/contracts"; import { latestAssistantMessage, threadStatus } from "../../domain/thread-lifecycle.ts"; @@ -98,6 +98,28 @@ export function formatThreadsHuman(threads: ReadonlyArray<OrchestrationThreadShe )}\n`; } +export function formatThreadSearchHuman(matches: ReadonlyArray<ThreadSearchResult>) { + if (matches.length === 0) { + return "no matches\n"; + } + return `${matches + .map((match) => + formatRecord([ + { field: "thread", value: match.threadTitle ?? "-" }, + { field: "thread id", value: match.threadId }, + { field: "project", value: match.projectTitle ?? "-" }, + { field: "project id", value: match.projectId }, + { field: "workspace", value: match.workspaceRoot ?? "-" }, + { field: "branch", value: match.branch ?? "-" }, + { field: "worktree", value: match.worktreePath ?? "-" }, + { field: "source", value: match.source }, + { field: "created", value: match.messageCreatedAt ?? "-" }, + { field: "snippet", value: match.snippet }, + ]), + ) + .join("\n\n")}\n`; +} + export function formatThreadDeletedHuman(input: { readonly threadId: string; readonly dispatch: { readonly sequence: number }; diff --git a/src/cli/search.ts b/src/cli/search.ts new file mode 100644 index 0000000..39287ac --- /dev/null +++ b/src/cli/search.ts @@ -0,0 +1,48 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Argument, Command, Flag } from "effect/unstable/cli"; + +import { T3Application } from "../application/service.ts"; +import { loadT3CliEnv } from "../config/env/env.ts"; +import { InvalidLimitError } from "./error.ts"; +import { extraArgsConfig } from "./extra-args.ts"; +import { formatFlag } from "./flags.ts"; +import { formatThreadSearchHuman } from "./format/thread.ts"; +import { resolveOutputFormat } from "./format/output.ts"; +import { T3Output } from "./output/service.ts"; +import { CliRuntime } from "./runtime/service.ts"; + +export const searchThreadsCommand = Command.make( + "search", + { + query: Argument.string("query"), + limit: Flag.integer("limit").pipe( + Flag.withDescription("Maximum matches (1-50, default: 50)"), + Flag.optional, + ), + format: formatFlag, + ...extraArgsConfig, + }, + ({ query, limit, format }) => + Effect.gen(function* () { + const value = Option.getOrUndefined(limit); + if (value !== undefined && (value < 1 || value > 50)) { + return yield* Effect.fail( + new InvalidLimitError({ message: `invalid limit: ${value}`, value: String(value) }), + ); + } + const application = yield* T3Application; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; + const output = yield* T3Output; + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); + const matches = yield* application.searchThreads({ + query, + ...(value !== undefined ? { limit: value } : {}), + }); + if (resolvedFormat === "json") { + return yield* output.printJson(matches); + } + return yield* output.writeStdout(formatThreadSearchHuman(matches)); + }), +).pipe(Command.withDescription("search threads by conversation content")); diff --git a/src/orchestration/layer.ts b/src/orchestration/layer.ts index 9816249..94d1197 100644 --- a/src/orchestration/layer.ts +++ b/src/orchestration/layer.ts @@ -72,6 +72,11 @@ export const makeT3Orchestration = Effect.fn("makeT3Orchestration")(function* () ); }, ); + const searchThreads = Effect.fn("T3OrchestrationLive.searchThreads")(function* (input) { + return yield* rpc.run(ORCHESTRATION_WS_METHODS.searchThreads, (client) => + client[ORCHESTRATION_WS_METHODS.searchThreads](input), + ); + }); const getThreadSnapshot = Effect.fn("T3OrchestrationLive.getThreadSnapshot")(function* ( threadId: string, ) { @@ -125,6 +130,7 @@ export const makeT3Orchestration = Effect.fn("makeT3Orchestration")(function* () getServerConfig, getShellSnapshot, getArchivedShellSnapshot, + searchThreads, getThreadSnapshot, watchShellSequence, watchThreadItems, diff --git a/src/orchestration/service.ts b/src/orchestration/service.ts index 0b415bf..61682b2 100644 --- a/src/orchestration/service.ts +++ b/src/orchestration/service.ts @@ -7,6 +7,8 @@ import type { DispatchResult, OrchestrationEvent, OrchestrationShellSnapshot, + OrchestrationSearchThreadsInput, + OrchestrationSearchThreadsResult, OrchestrationThread, OrchestrationThreadStreamItem, ServerProviders, @@ -35,6 +37,9 @@ export type Orchestration = { OrchestrationShellSnapshot, OrchestrationError >; + readonly searchThreads: ( + input: OrchestrationSearchThreadsInput, + ) => Effect.Effect<OrchestrationSearchThreadsResult, OrchestrationError>; readonly getThreadSnapshot: ( threadId: string, ) => Effect.Effect<OrchestrationThread, OrchestrationError>; diff --git a/src/rpc/error.ts b/src/rpc/error.ts index abdf037..4ccf87a 100644 --- a/src/rpc/error.ts +++ b/src/rpc/error.ts @@ -7,6 +7,7 @@ import { KeybindingsConfigError, OrchestrationDispatchCommandError, OrchestrationGetSnapshotError, + OrchestrationSearchThreadsError, ServerSettingsError, TerminalError, } from "@t3tools/contracts"; @@ -21,6 +22,7 @@ const RpcErrorCauseSchema = Schema.Union([ KeybindingsConfigError, OrchestrationDispatchCommandError, OrchestrationGetSnapshotError, + OrchestrationSearchThreadsError, ServerSettingsError, TerminalError, ConnectionBlockedError, diff --git a/src/rpc/operation.ts b/src/rpc/operation.ts index eff7e70..1a05f01 100644 --- a/src/rpc/operation.ts +++ b/src/rpc/operation.ts @@ -3,6 +3,7 @@ import type { KeybindingsConfigError, OrchestrationDispatchCommandError, OrchestrationGetSnapshotError, + OrchestrationSearchThreadsError, ServerSettingsError, TerminalError, } from "@t3tools/contracts"; @@ -22,6 +23,7 @@ export type CliRpcOperationError = | KeybindingsConfigError | OrchestrationDispatchCommandError | OrchestrationGetSnapshotError + | OrchestrationSearchThreadsError | RpcClientError.RpcClientError | ServerSettingsError | TerminalError; diff --git a/src/rpc/ws-group.ts b/src/rpc/ws-group.ts index 39d512d..c0dd95d 100644 --- a/src/rpc/ws-group.ts +++ b/src/rpc/ws-group.ts @@ -6,6 +6,7 @@ import { WS_METHODS, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetArchivedShellSnapshotRpc, + WsOrchestrationSearchThreadsRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, WsServerProbeRpc, @@ -46,6 +47,7 @@ export const CliWsRpcGroup = RpcGroup.make( WsSubscribeTerminalMetadataRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetArchivedShellSnapshotRpc, + WsOrchestrationSearchThreadsRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, WsServerProbeRpc, diff --git a/upstream-t3code b/upstream-t3code index e698796..4b71a2a 160000 --- a/upstream-t3code +++ b/upstream-t3code @@ -1 +1 @@ -Subproject commit e6987965f65914861f0dabd0db03729fe5cd2508 +Subproject commit 4b71a2ae2ffbbb7b6936051552094b71364cefd4