mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
* feat(cli): orca search over the agent session index `orca search <query>` calls PR 5's `aiVault.searchSessions` over the CLI's existing runtime RPC, against the host `--environment` / `--pairing-code` selects and no other. `orca search --index-status` calls `aiVault.searchStatus`. It is the proof the contract works with no panel. Every flag maps onto a contract field and nothing else: `--scope`, `--fresh`, `--limit`, `--cursor`, repeatable `--agent` and `--path`, `--since`, `--sort`, `--debug`, `--json`. No fan-out, no merged output, no `--host`. One command rather than a `search status` subcommand: the query is a bare positional, so `orca search status` could not be told apart from searching for the word "status". `--status` is unavailable because `orchestration task-list --status <state>` already owns the name as a valued flag. No new runtime capability. PR 5 decided an explicit `method_not_found` refusal maps to `unavailable/no-service`, so reusing `createSessionSearchClient` gives an old host a plain "this host runs no session search service" answer at exit 0 instead of a raw JSON-RPC error. `CommandSpec.repeatableFlags` scopes repeatability per command, because `--agent` must repeat for search and stay single-valued for `worktree create`. `help.ts` sat exactly at max-lines, so `skills-command-flag-help.ts` becomes `command-scoped-flag-help.ts` carrying both tables at the same call-site size. * refactor(cli): drop the search type assertions main's casting gate now rejects Main gained a `consistent-type-assertions: never` scan in the changed-code gate after this branch was cut, and it reported twelve assertions in the new files. The four in the argument parser were avoidable. `readEnum` now keeps the value `find` returns, which already carries the narrow type, and the agent filter goes through an `isAiVaultAgent` predicate over a `Set<string>` instead of widening the agent tuple. The test now narrows the printed envelope by shape and re-reads the printed result through `AiVaultSearchResponseSchema`, so the JSON assertions are checked rather than claimed, and the flag table is typed so its callback needs no cast. One assertion is left, for the structural fake client, with the SAFETY rationale AGENTS.md requires. * fix(cli): sanitize host strings and scope pre-command repeatable flags Route every host-supplied string the search formatter prints through the escape stripper, and resolve the repeatable-flag set from the command tokens ahead when a flag sits before the command. * refactor(cli): resolve repeatable flag rules once per command * fix(cli): clarify session search availability and SSH scope * feat(cli): hide orca search until the settings toggle ships `orca search` stays dispatchable but leaves every discovery surface: root help, group help, unknown-command suggestions, and `agent-context --json`. `buildAgentContext` did not filter hidden specs, so it also stops leaking the hidden `terminal stop`.
163 lines
5.2 KiB
TypeScript
163 lines
5.2 KiB
TypeScript
import {
|
|
AI_VAULT_AGENTS,
|
|
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
|
|
type AiVaultAgent
|
|
} from '../shared/ai-vault-types'
|
|
import { AiVaultSearchFiltersSchema } from '../shared/ai-vault-search-contract'
|
|
import type { AiVaultSearchRequest } from '../shared/ai-vault-search-types'
|
|
import {
|
|
getOptionalPositiveIntegerFlag,
|
|
getOptionalStringFlag,
|
|
getRepeatedStringFlag
|
|
} from './flags'
|
|
import { RuntimeClientError } from './runtime/types'
|
|
|
|
export type SearchCommand =
|
|
| { kind: 'index-status' }
|
|
| { kind: 'search'; request: AiVaultSearchRequest }
|
|
|
|
// Why enumerated: --index-status calls a different RPC that reads none of these,
|
|
// so ignoring one would answer a question the caller did not ask.
|
|
const QUERY_ONLY_FLAGS = [
|
|
'query',
|
|
'scope',
|
|
'fresh',
|
|
'limit',
|
|
'cursor',
|
|
'agent',
|
|
'path',
|
|
'since',
|
|
'sort',
|
|
'debug'
|
|
] as const
|
|
|
|
function readEnum<TValue extends string>(
|
|
flags: Map<string, string | boolean>,
|
|
name: string,
|
|
allowed: readonly TValue[]
|
|
): TValue | undefined {
|
|
const value = getOptionalStringFlag(flags, name)
|
|
if (value === undefined) {
|
|
return undefined
|
|
}
|
|
// Why find and not includes: the match carries the narrow type, so nothing is asserted.
|
|
const matched = allowed.find((candidate) => candidate === value)
|
|
if (matched === undefined) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Unsupported --${name} "${value}". Use ${allowed.join(' or ')}.`
|
|
)
|
|
}
|
|
return matched
|
|
}
|
|
|
|
const KNOWN_AGENTS = new Set<string>(AI_VAULT_AGENTS)
|
|
|
|
function isAiVaultAgent(value: string): value is AiVaultAgent {
|
|
return KNOWN_AGENTS.has(value)
|
|
}
|
|
|
|
function readAgents(flags: Map<string, string | boolean>): AiVaultAgent[] | undefined {
|
|
const agents = getRepeatedStringFlag(flags, 'agent')
|
|
if (agents.length === 0) {
|
|
return undefined
|
|
}
|
|
const unknown = agents.filter((agent) => !isAiVaultAgent(agent))
|
|
if (unknown.length > 0) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Unknown --agent ${unknown.map((agent) => `"${agent}"`).join(', ')}. Known agents: ${AI_VAULT_AGENTS.join(', ')}.`
|
|
)
|
|
}
|
|
return agents.filter(isAiVaultAgent)
|
|
}
|
|
|
|
function readScopePaths(flags: Map<string, string | boolean>): string[] | undefined {
|
|
const paths = getRepeatedStringFlag(flags, 'path')
|
|
if (paths.length === 0) {
|
|
return undefined
|
|
}
|
|
if (paths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Too many --path values (${paths.length}); at most ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} are accepted.`
|
|
)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// Why the contract's own schema: the offset requirement lives there, and a
|
|
// second copy here would drift from what the host accepts.
|
|
function readSince(flags: Map<string, string | boolean>): string | undefined {
|
|
const since = getOptionalStringFlag(flags, 'since')
|
|
if (since === undefined) {
|
|
return undefined
|
|
}
|
|
if (!AiVaultSearchFiltersSchema.shape.since.safeParse(since).success) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`Invalid --since "${since}". Use an ISO 8601 timestamp with an offset, for example 2026-08-01T00:00:00Z.`
|
|
)
|
|
}
|
|
return since
|
|
}
|
|
|
|
function readQuery(flags: Map<string, string | boolean>): string {
|
|
const query = getOptionalStringFlag(flags, 'query')
|
|
if (query === undefined) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
'Missing a search query. Pass it as `orca search "<query>"` or --query "<query>", or ask for the index report with --index-status.'
|
|
)
|
|
}
|
|
return query
|
|
}
|
|
|
|
function readFilters(
|
|
flags: Map<string, string | boolean>
|
|
): AiVaultSearchRequest['filters'] | undefined {
|
|
const agents = readAgents(flags)
|
|
const scopePaths = readScopePaths(flags)
|
|
const since = readSince(flags)
|
|
const sort = readEnum(flags, 'sort', ['relevance', 'newest'] as const)
|
|
const filters = {
|
|
...(agents ? { agents } : {}),
|
|
...(scopePaths ? { scopePaths } : {}),
|
|
...(since ? { since } : {}),
|
|
...(sort ? { sort } : {})
|
|
}
|
|
return Object.keys(filters).length > 0 ? filters : undefined
|
|
}
|
|
|
|
/** Maps `orca search` flags onto the session-search contract; nothing it does not have. */
|
|
export function parseSearchCommand(flags: Map<string, string | boolean>): SearchCommand {
|
|
if (flags.has('index-status')) {
|
|
const conflicting = QUERY_ONLY_FLAGS.filter((flag) => flags.has(flag))
|
|
if (conflicting.length > 0) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`--index-status reports on the index and takes no query, so it cannot be combined with ${conflicting.map((flag) => `--${flag}`).join(', ')}.`
|
|
)
|
|
}
|
|
return { kind: 'index-status' }
|
|
}
|
|
|
|
const query = readQuery(flags)
|
|
const scope = readEnum(flags, 'scope', ['conversation', 'all'] as const)
|
|
const limit = getOptionalPositiveIntegerFlag(flags, 'limit')
|
|
const cursor = getOptionalStringFlag(flags, 'cursor')
|
|
const filters = readFilters(flags)
|
|
return {
|
|
kind: 'search',
|
|
request: {
|
|
query,
|
|
...(scope ? { scope } : {}),
|
|
...(flags.has('fresh') ? { freshness: 'wait-until-current' as const } : {}),
|
|
...(limit === undefined ? {} : { limit }),
|
|
...(cursor ? { cursor } : {}),
|
|
...(filters ? { filters } : {}),
|
|
...(flags.has('debug') ? { debug: true } : {})
|
|
}
|
|
}
|
|
}
|