mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 16:02:38 +00:00
* feat(session-search): add ranked history panel search and consent * test: wait for initial session indexing before refreshing results * feat(session-history): add local search settings and index controls * Use shared local host identifier for session index status * feat(session-search): merge all-computers search across hosts The `all` scope on `aiVault:searchSessions` now fans out from the desktop to every host the session list enumerates and merges the pages into one. Legs run in parallel: the local index through the search service, SSH and runtime hosts through the existing remote search client. Two fixed orders, because relevance scores from independent indexes are not comparable. `newest` asks every leg for recency and k-way merges on `updatedAt`, nulls last, ties broken on execution host id. `relevance` rotates hosts in host-id order by their own rank. The merged cursor is an opaque base64url payload holding each host's cursor, how many of its current page were already emitted, and the generation that offset counts into, plus the page size and sort the cursor belongs to. A host whose index moved is fenced to `stale` and stops contributing; the rest keep paging. Per-host outcomes ride back on one new optional `hosts` field on the results response. `aiVault:searchStatus` with `all` stays refused, and neither the runtime RPC nor the CLI gains the scope, so a fan-out is never two hops. * fix(preload): let the search bridge address the all-computers scope * feat(settings): live index status, enable confirm, advanced delete * feat(session-search): search every computer from the history panel The panel's "All computers" scope produced no request: the hook parsed the scope into a single host id and stopped when that was null, so the panel answered "Choose one computer to search its sessions." The desktop already merges every enumerated host behind `aiVault:searchSessions`, so pass the scope straight through and stamp each hit with the host it came back on. Hosts the merge could not search are named under the results header with a short reason, since a silent partial answer reads as "no such session". (cherry picked from commitc6b9179316) * feat(session-search): enable indexing on paired servers from a client Adds `aiVault.setSearchEnabled` so a desktop can turn a paired Orca server's transcript index on or off and have the server apply it without a restart. The runtime method refuses any caller without a `pairedDeviceId` with a `forbidden`-class error, writes the whole resolved policy through the runtime store so retention rides along untouched, then reaches the index through a host-supplied hook: `applySessionSearchSettingsChange` on the desktop, the in-process instance's new `apply` on orcad. The relay is unchanged. Wire compatibility is Rule 1 shaped: a new optional method. A server that predates it answers method-not-found, which the desktop IPC handler maps to an error whose message is exactly `host-too-old`. Old clients never call it. The method is deliberately absent from the mobile allowlist, and `aiVaultSearch` stays out of the paired settings projection. (cherry picked from commit640c715fbd) * fix(session-search): report a paired server without session search as host-too-old on status reads (cherry picked from commit1463e8a4bc) * fix(settings): let Button and Collapsible own their spacing and type
80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
/**
|
|
* Consent and retention for the agent-session transcript index.
|
|
*
|
|
* Off until the user turns it on: building the index reads every transcript on
|
|
* the machine, so nothing constructs an indexer, opens the database or reads a
|
|
* transcript for it before that choice is recorded.
|
|
*
|
|
* There is no `paused`. The indexer is immutable after construction, so every
|
|
* change here is close-and-construct (see session-search-instance.ts).
|
|
*/
|
|
export type AiVaultSearchSettings = {
|
|
enabled: boolean
|
|
/** null = all history; otherwise only transcripts modified within this many days. */
|
|
historyDays: number | null
|
|
}
|
|
|
|
export const DEFAULT_AI_VAULT_SEARCH_SETTINGS: AiVaultSearchSettings = {
|
|
enabled: false,
|
|
historyDays: null
|
|
}
|
|
|
|
const HISTORY_DAYS_MAX = 3_650
|
|
|
|
export const AiVaultSearchSettingsSchema: z.ZodType<AiVaultSearchSettings> = z.object({
|
|
enabled: z.boolean(),
|
|
historyDays: z.number().int().positive().max(HISTORY_DAYS_MAX).nullable()
|
|
})
|
|
|
|
export function normalizeAiVaultSearchHistoryDays(value: unknown): number | null {
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
return null
|
|
}
|
|
// A fractional day floors to 0, which reads as "all history" on one side and
|
|
// "now" on the other; make the two agree.
|
|
const days = Math.floor(value)
|
|
return days <= 0 ? null : Math.min(HISTORY_DAYS_MAX, days)
|
|
}
|
|
|
|
/**
|
|
* The persisted shape, from whatever a settings write or an old profile left behind.
|
|
*
|
|
* The input is `unknown` on purpose: this is the sanitizer, and what it reads is a
|
|
* JSON profile that may predate either field or hold a value no version wrote.
|
|
*/
|
|
export function resolveAiVaultSearchSettings(
|
|
settings: { aiVaultSearch?: unknown } | null | undefined
|
|
): AiVaultSearchSettings {
|
|
const raw = settings?.aiVaultSearch
|
|
if (typeof raw !== 'object' || raw === null) {
|
|
return { ...DEFAULT_AI_VAULT_SEARCH_SETTINGS }
|
|
}
|
|
return {
|
|
enabled: 'enabled' in raw && raw.enabled === true,
|
|
historyDays: normalizeAiVaultSearchHistoryDays('historyDays' in raw ? raw.historyDays : null)
|
|
}
|
|
}
|
|
|
|
export function sameAiVaultSearchSettings(
|
|
a: AiVaultSearchSettings,
|
|
b: AiVaultSearchSettings
|
|
): boolean {
|
|
return a.enabled === b.enabled && a.historyDays === b.historyDays
|
|
}
|
|
|
|
/**
|
|
* The policy a settings write moves to, or null when nothing about it changed.
|
|
*
|
|
* Every host that owns an index closes and reconstructs on apply, so an unchanged
|
|
* value has to be filtered here rather than at the indexer.
|
|
*/
|
|
export function changedAiVaultSearchSettings(
|
|
before: { aiVaultSearch?: unknown } | null | undefined,
|
|
after: { aiVaultSearch?: unknown } | null | undefined
|
|
): AiVaultSearchSettings | null {
|
|
const next = resolveAiVaultSearchSettings(after)
|
|
return sameAiVaultSearchSettings(resolveAiVaultSearchSettings(before), next) ? null : next
|
|
}
|