@gmickel/gno 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +69 -6
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +98 -1
  4. package/assets/skill/cli-reference.md +140 -0
  5. package/assets/skill/examples.md +45 -0
  6. package/assets/skill/mcp-reference.md +62 -0
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +358 -24
  17. package/spec/compiled-context.md +68 -0
  18. package/spec/mcp.md +223 -2
  19. package/spec/output-schemas/capture-receipt.schema.json +3 -0
  20. package/spec/output-schemas/compiled-context-check.schema.json +44 -0
  21. package/spec/output-schemas/compiled-context-file.schema.json +165 -0
  22. package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
  23. package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
  24. package/spec/output-schemas/memory-remember.schema.json +8 -2
  25. package/spec/output-schemas/request-status.schema.json +113 -0
  26. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  27. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  28. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  29. package/spec/output-schemas/sessions-status.schema.json +432 -0
  30. package/src/app/compiled-context-files.ts +361 -0
  31. package/src/app/compiled-context.ts +240 -0
  32. package/src/app/context-surface.ts +7 -2
  33. package/src/cli/commands/ask.ts +14 -2
  34. package/src/cli/commands/audit.ts +4 -0
  35. package/src/cli/commands/capture.ts +55 -96
  36. package/src/cli/commands/context-compiled.ts +136 -0
  37. package/src/cli/commands/daemon.ts +41 -0
  38. package/src/cli/commands/ls.ts +3 -0
  39. package/src/cli/commands/memory.ts +12 -3
  40. package/src/cli/commands/request-status.ts +59 -0
  41. package/src/cli/commands/reset.ts +39 -5
  42. package/src/cli/commands/sessions.ts +713 -0
  43. package/src/cli/commands/shared.ts +14 -1
  44. package/src/cli/errors.ts +10 -3
  45. package/src/cli/program.ts +458 -1
  46. package/src/cli/session-binding.ts +49 -0
  47. package/src/config/types.ts +8 -0
  48. package/src/core/capture-publish.ts +239 -0
  49. package/src/core/capture-sync.ts +3 -0
  50. package/src/core/compiled-context.ts +254 -0
  51. package/src/core/context-budget.ts +2 -10
  52. package/src/core/file-lock.ts +22 -5
  53. package/src/core/folder-setup-planning.ts +2 -1
  54. package/src/core/memory-remember.ts +233 -122
  55. package/src/core/memory-types.ts +11 -0
  56. package/src/core/network-boundary-inventory.ts +16 -0
  57. package/src/core/request-receipts.ts +671 -0
  58. package/src/core/setup-receipt.ts +27 -20
  59. package/src/core/typed-metadata.ts +4 -0
  60. package/src/core/validation.ts +9 -2
  61. package/src/core/windows-private-path.ts +96 -0
  62. package/src/index.ts +11 -2
  63. package/src/ingestion/compiled-context.ts +15 -0
  64. package/src/ingestion/sync.ts +40 -8
  65. package/src/ingestion/walker.ts +5 -4
  66. package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
  67. package/src/mcp/context.ts +8 -0
  68. package/src/mcp/http-egress.ts +15 -3
  69. package/src/mcp/http-transport.ts +2 -0
  70. package/src/mcp/retrieval-warnings.ts +24 -0
  71. package/src/mcp/tools/ask.ts +14 -1
  72. package/src/mcp/tools/capture.ts +87 -83
  73. package/src/mcp/tools/context.ts +46 -2
  74. package/src/mcp/tools/index.ts +119 -7
  75. package/src/mcp/tools/memory-remember.ts +7 -0
  76. package/src/mcp/tools/memory-shared.ts +7 -1
  77. package/src/mcp/tools/query.ts +3 -1
  78. package/src/mcp/tools/request-status.ts +73 -0
  79. package/src/mcp/tools/search.ts +3 -1
  80. package/src/mcp/tools/sessions.ts +208 -0
  81. package/src/mcp/tools/vsearch.ts +3 -1
  82. package/src/sdk/client.ts +252 -89
  83. package/src/sdk/index.ts +13 -0
  84. package/src/sdk/types.ts +82 -3
  85. package/src/serve/capture-service.ts +98 -32
  86. package/src/serve/compiled-context.ts +84 -0
  87. package/src/serve/config-sync.ts +3 -2
  88. package/src/serve/public/app.tsx +15 -1
  89. package/src/serve/public/components/CaptureModal.tsx +26 -8
  90. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  91. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  92. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  93. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  94. package/src/serve/public/components/sessions/api.ts +40 -0
  95. package/src/serve/public/components/sessions/snippet.tsx +53 -0
  96. package/src/serve/public/globals.built.css +1 -1
  97. package/src/serve/public/hooks/use-api.ts +10 -2
  98. package/src/serve/public/lib/request-intent.ts +69 -0
  99. package/src/serve/public/lib/workspace-actions.ts +12 -1
  100. package/src/serve/public/lib/workspace-tabs.ts +4 -0
  101. package/src/serve/public/pages/CompiledContext.tsx +364 -0
  102. package/src/serve/public/pages/Dashboard.tsx +17 -0
  103. package/src/serve/public/pages/DocView.tsx +15 -1
  104. package/src/serve/public/pages/DocumentEditor.tsx +139 -96
  105. package/src/serve/public/pages/Sessions.tsx +350 -0
  106. package/src/serve/resident-runtime.ts +43 -3
  107. package/src/serve/routes/api.ts +476 -147
  108. package/src/serve/routes/sessions.ts +766 -0
  109. package/src/serve/security.ts +9 -0
  110. package/src/serve/server.ts +248 -3
  111. package/src/serve/session-automation.ts +146 -0
  112. package/src/serve/spa-production-build.ts +6 -5
  113. package/src/sessions/archive.ts +348 -0
  114. package/src/sessions/automation-state.ts +444 -0
  115. package/src/sessions/automation-status.ts +239 -0
  116. package/src/sessions/automation.ts +1169 -0
  117. package/src/sessions/binding.ts +105 -0
  118. package/src/sessions/claude-hook.ts +240 -0
  119. package/src/sessions/config.ts +176 -0
  120. package/src/sessions/format.ts +191 -0
  121. package/src/sessions/import-child-env.ts +8 -0
  122. package/src/sessions/import-child.ts +152 -0
  123. package/src/sessions/parsers/claude-code.ts +259 -0
  124. package/src/sessions/parsers/codex.ts +303 -0
  125. package/src/sessions/parsers/hermes.ts +248 -0
  126. package/src/sessions/parsers/openclaw.ts +496 -0
  127. package/src/sessions/parsers/shared.ts +184 -0
  128. package/src/sessions/sanitize.ts +222 -0
  129. package/src/sessions/service.ts +1533 -0
  130. package/src/sessions/setup.ts +477 -0
  131. package/src/sessions/sources.ts +518 -0
  132. package/src/sessions/state.ts +118 -0
  133. package/src/sessions/types.ts +457 -0
  134. package/src/store/sqlite/adapter.ts +68 -16
  135. package/src/store/sqlite/scoped-index.ts +9 -0
  136. package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.zip.sha256 +0 -1
@@ -0,0 +1,238 @@
1
+ import type { Ref } from "react";
2
+
3
+ import {
4
+ AlertTriangleIcon,
5
+ CheckCircle2Icon,
6
+ CircleSlashIcon,
7
+ EyeIcon,
8
+ XCircleIcon,
9
+ } from "lucide-react";
10
+
11
+ import type { SessionImportReceipt, SessionUnitReceipt } from "./api";
12
+
13
+ import { cn } from "../../lib/utils";
14
+ import { Badge } from "../ui/badge";
15
+
16
+ const STATUS_COPY: Record<
17
+ SessionImportReceipt["status"],
18
+ { label: string; tone: string; Icon: typeof CheckCircle2Icon }
19
+ > = {
20
+ complete: {
21
+ label: "Complete",
22
+ tone: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
23
+ Icon: CheckCircle2Icon,
24
+ },
25
+ partial: {
26
+ label: "Partial — some units were not fully read",
27
+ tone: "border-amber-500/50 bg-amber-500/10 text-amber-800 dark:text-amber-200",
28
+ Icon: AlertTriangleIcon,
29
+ },
30
+ failed: {
31
+ label: "Failed",
32
+ tone: "border-destructive/50 bg-destructive/10 text-destructive",
33
+ Icon: XCircleIcon,
34
+ },
35
+ nothing_to_do: {
36
+ label: "Nothing to do — everything is current",
37
+ tone: "border-border bg-muted/40 text-muted-foreground",
38
+ Icon: CircleSlashIcon,
39
+ },
40
+ };
41
+
42
+ /** Unit outcomes worth listing individually (unchanged units are noise). */
43
+ const NOTABLE_OUTCOMES = new Set<SessionUnitReceipt["outcome"]>([
44
+ "imported",
45
+ "updated",
46
+ "skipped_policy",
47
+ "unsupported",
48
+ "incomplete",
49
+ "failed",
50
+ ]);
51
+
52
+ const OUTCOME_TONE: Partial<Record<SessionUnitReceipt["outcome"], string>> = {
53
+ incomplete: "text-amber-700 dark:text-amber-300",
54
+ failed: "text-destructive",
55
+ unsupported: "text-destructive",
56
+ skipped_policy: "text-amber-700 dark:text-amber-300",
57
+ };
58
+
59
+ function Stat({ label, value }: { label: string; value: number | string }) {
60
+ return (
61
+ <div className="min-w-0 rounded-md border border-border/50 bg-muted/30 p-2">
62
+ <dt className="break-words text-muted-foreground text-xs">{label}</dt>
63
+ <dd className="font-mono font-semibold text-sm">{value}</dd>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ interface ImportReceiptProps {
69
+ receipt: SessionImportReceipt;
70
+ /** Focus target after a preview/import completes (region is tabIndex -1). */
71
+ ref?: Ref<HTMLElement>;
72
+ }
73
+
74
+ export function ImportReceipt({ receipt, ref }: ImportReceiptProps) {
75
+ const status = STATUS_COPY[receipt.status];
76
+ const { counts, turns } = receipt;
77
+ const notable = receipt.units.filter((unit) =>
78
+ NOTABLE_OUTCOMES.has(unit.outcome)
79
+ );
80
+ const destinations = [
81
+ ...new Set(receipt.units.flatMap((unit) => unit.collections)),
82
+ ].sort();
83
+
84
+ return (
85
+ <section
86
+ aria-label={receipt.dryRun ? "Import preview" : "Import receipt"}
87
+ className="min-w-0 space-y-4 rounded-lg border border-border/60 bg-card/60 p-4 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
88
+ ref={ref}
89
+ tabIndex={-1}
90
+ >
91
+ <div className="flex flex-wrap items-center gap-2">
92
+ {receipt.dryRun && (
93
+ <Badge className="gap-1" variant="outline">
94
+ <EyeIcon />
95
+ Preview only — nothing was written
96
+ </Badge>
97
+ )}
98
+ <span
99
+ className={cn(
100
+ "inline-flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1 font-medium text-sm",
101
+ status.tone
102
+ )}
103
+ data-status={receipt.status}
104
+ role="status"
105
+ >
106
+ <status.Icon aria-hidden="true" className="size-4 shrink-0" />
107
+ <span className="break-words">{status.label}</span>
108
+ </span>
109
+ <span className="text-muted-foreground text-xs">
110
+ Sources: {receipt.sourceIds.join(", ") || "—"} · index{" "}
111
+ <span className="font-mono">{receipt.index}</span>
112
+ </span>
113
+ </div>
114
+
115
+ <div>
116
+ <h4 className="mb-1 font-medium text-sm">Threads and units</h4>
117
+ <dl className="grid grid-cols-2 gap-2 sm:grid-cols-4">
118
+ <Stat label="Imported" value={counts.imported} />
119
+ <Stat label="Updated" value={counts.updated} />
120
+ <Stat label="Unchanged" value={counts.unchanged} />
121
+ <Stat label="Skipped by policy" value={counts.skippedPolicy} />
122
+ <Stat label="Unsupported" value={counts.unsupported} />
123
+ <Stat label="Incomplete" value={counts.incomplete} />
124
+ <Stat label="Failed" value={counts.failed} />
125
+ <Stat label="Deferred units" value={receipt.deferredUnits} />
126
+ </dl>
127
+ </div>
128
+
129
+ <div>
130
+ <h4 className="mb-1 font-medium text-sm">Turns and redaction</h4>
131
+ <dl className="grid grid-cols-2 gap-2 sm:grid-cols-3">
132
+ <Stat label="Human turns" value={turns.human} />
133
+ <Stat label="Assistant turns" value={turns.assistant} />
134
+ <Stat label="Redactions" value={turns.redactions} />
135
+ <Stat
136
+ label="Injected context skipped"
137
+ value={turns.injectedSkipped}
138
+ />
139
+ <Stat
140
+ label="Copied history skipped"
141
+ value={turns.copiedHistorySkipped}
142
+ />
143
+ <Stat label="Over limit" value={turns.overLimit} />
144
+ </dl>
145
+ </div>
146
+
147
+ <p className="text-sm">
148
+ <span className="text-muted-foreground">Destination collections: </span>
149
+ {destinations.length > 0 ? (
150
+ <span className="font-mono break-all">{destinations.join(", ")}</span>
151
+ ) : (
152
+ <span className="text-muted-foreground">none in this run</span>
153
+ )}
154
+ </p>
155
+
156
+ {!receipt.dryRun && (
157
+ <p className="text-sm">
158
+ <span className="text-muted-foreground">Lexical search: </span>
159
+ <span
160
+ className={cn(
161
+ "font-medium",
162
+ receipt.lexical.status === "failed" && "text-destructive"
163
+ )}
164
+ >
165
+ {receipt.lexical.status}
166
+ </span>
167
+ {receipt.lexical.error && (
168
+ <span className="block break-words text-destructive text-xs">
169
+ {receipt.lexical.error}
170
+ </span>
171
+ )}
172
+ <span className="text-muted-foreground">
173
+ {" "}
174
+ · embedding backlog: {receipt.embedding.backlog ?? "n/a"}
175
+ </span>
176
+ </p>
177
+ )}
178
+
179
+ {receipt.warnings.length > 0 && (
180
+ <ul className="list-disc space-y-1 pl-5 text-amber-800 text-sm dark:text-amber-200">
181
+ {receipt.warnings.map((warning) => (
182
+ <li className="break-words" key={warning}>
183
+ {warning}
184
+ </li>
185
+ ))}
186
+ </ul>
187
+ )}
188
+
189
+ {notable.length > 0 && (
190
+ <div className="min-w-0">
191
+ <h4 className="mb-1 font-medium text-sm">Unit outcomes</h4>
192
+ <ul className="divide-y divide-border/40 rounded-md border border-border/50">
193
+ {notable.map((unit) => (
194
+ <li
195
+ className="min-w-0 space-y-0.5 p-2 text-sm"
196
+ key={`${unit.sourceId}:${unit.locator}`}
197
+ >
198
+ <div className="flex flex-wrap items-baseline gap-x-2">
199
+ <span
200
+ className={cn(
201
+ "font-medium",
202
+ OUTCOME_TONE[unit.outcome] ?? "text-foreground"
203
+ )}
204
+ >
205
+ {unit.outcome.replace("_", " ")}
206
+ </span>
207
+ <span className="min-w-0 break-all font-mono text-xs">
208
+ {unit.locator}
209
+ </span>
210
+ </div>
211
+ <div className="text-muted-foreground text-xs">
212
+ {unit.threads} thread{unit.threads === 1 ? "" : "s"} ·{" "}
213
+ {unit.turns} turn{unit.turns === 1 ? "" : "s"}
214
+ {unit.collections.length > 0 &&
215
+ ` → ${unit.collections.join(", ")}`}
216
+ {unit.reason && ` · reason: ${unit.reason}`}
217
+ </div>
218
+ {unit.warnings?.map((warning) => (
219
+ <div
220
+ className="break-words text-amber-800 text-xs dark:text-amber-200"
221
+ key={warning}
222
+ >
223
+ {warning}
224
+ </div>
225
+ ))}
226
+ </li>
227
+ ))}
228
+ </ul>
229
+ {receipt.unitsTruncated && (
230
+ <p className="mt-1 text-muted-foreground text-xs">
231
+ More units were processed than are listed here.
232
+ </p>
233
+ )}
234
+ </div>
235
+ )}
236
+ </section>
237
+ );
238
+ }
@@ -0,0 +1,286 @@
1
+ import { Loader2Icon, SearchIcon } from "lucide-react";
2
+ import { type FormEvent, useEffect, useId, useState } from "react";
3
+
4
+ import {
5
+ SESSION_HARNESS_LABELS,
6
+ SESSION_HARNESSES,
7
+ type SessionHarness,
8
+ } from "../../../../sessions/types";
9
+ import { apiFetch } from "../../hooks/use-api";
10
+ import { buildDocDeepLink } from "../../lib/deep-links";
11
+ import { Badge } from "../ui/badge";
12
+ import { Button } from "../ui/button";
13
+ import { Input } from "../ui/input";
14
+ import { renderSessionSnippet } from "./snippet";
15
+
16
+ interface SessionSearchResult {
17
+ docid: string;
18
+ uri: string;
19
+ title?: string;
20
+ snippet: string;
21
+ categories?: string[];
22
+ record?: {
23
+ author?: string;
24
+ categories?: string[];
25
+ dateFields?: Record<string, string>;
26
+ };
27
+ }
28
+
29
+ interface SearchResponse {
30
+ results: SessionSearchResult[];
31
+ }
32
+
33
+ interface TagsResponse {
34
+ tags: Array<{ tag: string; count: number }>;
35
+ }
36
+
37
+ type Role = "human" | "assistant";
38
+
39
+ const SELECT_CLASS =
40
+ "h-9 w-full min-w-0 rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
41
+
42
+ const tagValue = (tags: readonly string[], prefix: string): string | null =>
43
+ tags.find((tag) => tag.startsWith(`${prefix}/`))?.slice(prefix.length + 1) ??
44
+ null;
45
+
46
+ function resultRole(result: SessionSearchResult, tags: string[]): Role | null {
47
+ const author = result.record?.author?.toLowerCase();
48
+ if (author === "human" || author === "assistant") return author;
49
+ const role = tagValue(tags, "role");
50
+ if (role === "human" || role === "assistant") return role;
51
+ const title = result.title ?? "";
52
+ if (title.startsWith("Human")) return "human";
53
+ if (title.startsWith("Assistant")) return "assistant";
54
+ return null;
55
+ }
56
+
57
+ function AuthorBadge({ role }: { role: Role | null }) {
58
+ if (role === "human") {
59
+ return (
60
+ <Badge className="bg-sky-600 text-white dark:bg-sky-500">Human</Badge>
61
+ );
62
+ }
63
+ if (role === "assistant") {
64
+ return (
65
+ <Badge className="whitespace-normal text-left" variant="outline">
66
+ Assistant — suggestion, not a user decision
67
+ </Badge>
68
+ );
69
+ }
70
+ return <Badge variant="outline">Author unknown</Badge>;
71
+ }
72
+
73
+ function formatRecorded(iso: string | undefined): string {
74
+ if (!iso) return "time unknown";
75
+ const date = new Date(iso);
76
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
77
+ }
78
+
79
+ interface SessionSearchProps {
80
+ collections: string[];
81
+ navigate: (to: string) => void;
82
+ }
83
+
84
+ export function SessionSearch({ collections, navigate }: SessionSearchProps) {
85
+ const id = useId();
86
+ const [query, setQuery] = useState("");
87
+ const [harness, setHarness] = useState<"" | SessionHarness>("");
88
+ const [project, setProject] = useState("");
89
+ const [role, setRole] = useState<"" | Role>("");
90
+ const [collection, setCollection] = useState("");
91
+ const [projects, setProjects] = useState<string[]>([]);
92
+ const [results, setResults] = useState<SessionSearchResult[] | null>(null);
93
+ const [error, setError] = useState<string | null>(null);
94
+ const [loading, setLoading] = useState(false);
95
+
96
+ useEffect(() => {
97
+ void apiFetch<TagsResponse>("/api/tags?prefix=project").then(({ data }) => {
98
+ setProjects(
99
+ (data?.tags ?? [])
100
+ .map((entry) => entry.tag)
101
+ .filter((tag) => tag.startsWith("project/"))
102
+ .map((tag) => tag.slice("project/".length))
103
+ .sort()
104
+ );
105
+ });
106
+ }, []);
107
+
108
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
109
+ event.preventDefault();
110
+ const trimmed = query.trim();
111
+ if (!trimmed) return;
112
+ const tagsAll = [
113
+ "session",
114
+ harness ? `harness/${harness}` : null,
115
+ project ? `project/${project}` : null,
116
+ role ? `role/${role}` : null,
117
+ ].filter((tag): tag is string => tag !== null);
118
+ setLoading(true);
119
+ const { data, error: err } = await apiFetch<SearchResponse>("/api/search", {
120
+ method: "POST",
121
+ body: JSON.stringify({
122
+ query: trimmed,
123
+ limit: 20,
124
+ tagsAll: tagsAll.join(","),
125
+ ...(collection ? { collection } : {}),
126
+ }),
127
+ });
128
+ setLoading(false);
129
+ setError(err);
130
+ setResults(data?.results ?? null);
131
+ };
132
+
133
+ return (
134
+ <section aria-labelledby={`${id}-heading`} className="min-w-0 space-y-4">
135
+ <h2 className="font-semibold text-xl" id={`${id}-heading`}>
136
+ Search sessions
137
+ </h2>
138
+ <form
139
+ className="grid min-w-0 gap-3 sm:grid-cols-2 lg:grid-cols-4"
140
+ onSubmit={(event) => void submit(event)}
141
+ role="search"
142
+ >
143
+ <div className="grid min-w-0 gap-1 text-sm sm:col-span-2 lg:col-span-4">
144
+ <label htmlFor={`${id}-query`}>Session search query</label>
145
+ <div className="flex min-w-0 gap-2">
146
+ <Input
147
+ id={`${id}-query`}
148
+ onChange={(event) => setQuery(event.currentTarget.value)}
149
+ placeholder="What was decided about…"
150
+ type="search"
151
+ value={query}
152
+ />
153
+ <Button disabled={loading || !query.trim()} type="submit">
154
+ {loading ? (
155
+ <Loader2Icon className="animate-spin" />
156
+ ) : (
157
+ <SearchIcon />
158
+ )}
159
+ Search
160
+ </Button>
161
+ </div>
162
+ </div>
163
+ <label className="grid gap-1 text-sm" htmlFor={`${id}-harness`}>
164
+ Harness
165
+ <select
166
+ className={SELECT_CLASS}
167
+ id={`${id}-harness`}
168
+ onChange={(event) =>
169
+ setHarness(event.currentTarget.value as "" | SessionHarness)
170
+ }
171
+ value={harness}
172
+ >
173
+ <option value="">All harnesses</option>
174
+ {SESSION_HARNESSES.map((value) => (
175
+ <option key={value} value={value}>
176
+ {SESSION_HARNESS_LABELS[value]}
177
+ </option>
178
+ ))}
179
+ </select>
180
+ </label>
181
+ <label className="grid gap-1 text-sm" htmlFor={`${id}-project`}>
182
+ Project
183
+ <select
184
+ className={SELECT_CLASS}
185
+ id={`${id}-project`}
186
+ onChange={(event) => setProject(event.currentTarget.value)}
187
+ value={project}
188
+ >
189
+ <option value="">All projects</option>
190
+ {projects.map((value) => (
191
+ <option key={value} value={value}>
192
+ {value}
193
+ </option>
194
+ ))}
195
+ </select>
196
+ </label>
197
+ <label className="grid gap-1 text-sm" htmlFor={`${id}-role`}>
198
+ Speaker
199
+ <select
200
+ className={SELECT_CLASS}
201
+ id={`${id}-role`}
202
+ onChange={(event) =>
203
+ setRole(event.currentTarget.value as "" | Role)
204
+ }
205
+ value={role}
206
+ >
207
+ <option value="">Human and assistant</option>
208
+ <option value="human">Human only</option>
209
+ <option value="assistant">Assistant only</option>
210
+ </select>
211
+ </label>
212
+ <label className="grid gap-1 text-sm" htmlFor={`${id}-collection`}>
213
+ Collection
214
+ <select
215
+ className={SELECT_CLASS}
216
+ id={`${id}-collection`}
217
+ onChange={(event) => setCollection(event.currentTarget.value)}
218
+ value={collection}
219
+ >
220
+ <option value="">All archive collections</option>
221
+ {collections.map((value) => (
222
+ <option key={value} value={value}>
223
+ {value}
224
+ </option>
225
+ ))}
226
+ </select>
227
+ </label>
228
+ </form>
229
+
230
+ {error && (
231
+ <p className="break-words text-destructive text-sm" role="alert">
232
+ {error}
233
+ </p>
234
+ )}
235
+ {results && results.length === 0 && (
236
+ <p className="text-muted-foreground text-sm">
237
+ No archived turns matched.
238
+ </p>
239
+ )}
240
+ {results && results.length > 0 && (
241
+ <ol aria-label="Session search results" className="space-y-3">
242
+ {results.map((result) => {
243
+ const tags = result.categories ?? result.record?.categories ?? [];
244
+ const resultHarness = tagValue(tags, "harness");
245
+ const resultProject = tagValue(tags, "project");
246
+ const href = buildDocDeepLink({ uri: result.uri });
247
+ return (
248
+ <li
249
+ className="min-w-0 space-y-2 rounded-lg border border-border/60 p-3"
250
+ key={result.docid}
251
+ >
252
+ <div className="flex flex-wrap items-center gap-2">
253
+ <AuthorBadge role={resultRole(result, tags)} />
254
+ <Badge variant="secondary">
255
+ {resultHarness
256
+ ? (SESSION_HARNESS_LABELS[
257
+ resultHarness as SessionHarness
258
+ ] ?? resultHarness)
259
+ : "harness unknown"}
260
+ </Badge>
261
+ <span className="text-muted-foreground text-xs">
262
+ project {resultProject ?? "none"} ·{" "}
263
+ {formatRecorded(result.record?.dateFields?.recorded)}
264
+ </span>
265
+ </div>
266
+ <a
267
+ className="block break-words font-medium text-primary underline-offset-2 hover:underline"
268
+ href={href}
269
+ onClick={(event) => {
270
+ event.preventDefault();
271
+ navigate(href);
272
+ }}
273
+ >
274
+ {result.title || result.uri}
275
+ </a>
276
+ <p className="line-clamp-3 break-words text-muted-foreground text-sm">
277
+ {renderSessionSnippet(result.snippet)}
278
+ </p>
279
+ </li>
280
+ );
281
+ })}
282
+ </ol>
283
+ )}
284
+ </section>
285
+ );
286
+ }