@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.
- package/README.md +69 -6
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +98 -1
- package/assets/skill/cli-reference.md +140 -0
- package/assets/skill/examples.md +45 -0
- package/assets/skill/mcp-reference.md +62 -0
- package/assets/skill/recipes/capture-and-file.md +6 -0
- package/assets/skill/recipes/memory-file-decision.md +6 -0
- package/assets/skill/recipes/memory-supersede-fact.md +5 -0
- package/assets/skill/recipes/session-evidence-lookup.md +98 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +358 -24
- package/spec/compiled-context.md +68 -0
- package/spec/mcp.md +223 -2
- package/spec/output-schemas/capture-receipt.schema.json +3 -0
- package/spec/output-schemas/compiled-context-check.schema.json +44 -0
- package/spec/output-schemas/compiled-context-file.schema.json +165 -0
- package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
- package/spec/output-schemas/memory-remember.schema.json +8 -2
- package/spec/output-schemas/request-status.schema.json +113 -0
- package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
- package/spec/output-schemas/sessions-discovery.schema.json +38 -0
- package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
- package/spec/output-schemas/sessions-status.schema.json +432 -0
- package/src/app/compiled-context-files.ts +361 -0
- package/src/app/compiled-context.ts +240 -0
- package/src/app/context-surface.ts +7 -2
- package/src/cli/commands/ask.ts +14 -2
- package/src/cli/commands/audit.ts +4 -0
- package/src/cli/commands/capture.ts +55 -96
- package/src/cli/commands/context-compiled.ts +136 -0
- package/src/cli/commands/daemon.ts +41 -0
- package/src/cli/commands/ls.ts +3 -0
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/request-status.ts +59 -0
- package/src/cli/commands/reset.ts +39 -5
- package/src/cli/commands/sessions.ts +713 -0
- package/src/cli/commands/shared.ts +14 -1
- package/src/cli/errors.ts +10 -3
- package/src/cli/program.ts +458 -1
- package/src/cli/session-binding.ts +49 -0
- package/src/config/types.ts +8 -0
- package/src/core/capture-publish.ts +239 -0
- package/src/core/capture-sync.ts +3 -0
- package/src/core/compiled-context.ts +254 -0
- package/src/core/context-budget.ts +2 -10
- package/src/core/file-lock.ts +22 -5
- package/src/core/folder-setup-planning.ts +2 -1
- package/src/core/memory-remember.ts +233 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +16 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/core/setup-receipt.ts +27 -20
- package/src/core/typed-metadata.ts +4 -0
- package/src/core/validation.ts +9 -2
- package/src/core/windows-private-path.ts +96 -0
- package/src/index.ts +11 -2
- package/src/ingestion/compiled-context.ts +15 -0
- package/src/ingestion/sync.ts +40 -8
- package/src/ingestion/walker.ts +5 -4
- package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
- package/src/mcp/context.ts +8 -0
- package/src/mcp/http-egress.ts +15 -3
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/retrieval-warnings.ts +24 -0
- package/src/mcp/tools/ask.ts +14 -1
- package/src/mcp/tools/capture.ts +87 -83
- package/src/mcp/tools/context.ts +46 -2
- package/src/mcp/tools/index.ts +119 -7
- package/src/mcp/tools/memory-remember.ts +7 -0
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/query.ts +3 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/search.ts +3 -1
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/mcp/tools/vsearch.ts +3 -1
- package/src/sdk/client.ts +252 -89
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +82 -3
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/compiled-context.ts +84 -0
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/public/app.tsx +15 -1
- package/src/serve/public/components/CaptureModal.tsx +26 -8
- package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
- package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
- package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
- package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
- package/src/serve/public/components/sessions/api.ts +40 -0
- package/src/serve/public/components/sessions/snippet.tsx +53 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +10 -2
- package/src/serve/public/lib/request-intent.ts +69 -0
- package/src/serve/public/lib/workspace-actions.ts +12 -1
- package/src/serve/public/lib/workspace-tabs.ts +4 -0
- package/src/serve/public/pages/CompiledContext.tsx +364 -0
- package/src/serve/public/pages/Dashboard.tsx +17 -0
- package/src/serve/public/pages/DocView.tsx +15 -1
- package/src/serve/public/pages/DocumentEditor.tsx +139 -96
- package/src/serve/public/pages/Sessions.tsx +350 -0
- package/src/serve/resident-runtime.ts +43 -3
- package/src/serve/routes/api.ts +476 -147
- package/src/serve/routes/sessions.ts +766 -0
- package/src/serve/security.ts +9 -0
- package/src/serve/server.ts +248 -3
- package/src/serve/session-automation.ts +146 -0
- package/src/serve/spa-production-build.ts +6 -5
- package/src/sessions/archive.ts +348 -0
- package/src/sessions/automation-state.ts +444 -0
- package/src/sessions/automation-status.ts +239 -0
- package/src/sessions/automation.ts +1169 -0
- package/src/sessions/binding.ts +105 -0
- package/src/sessions/claude-hook.ts +240 -0
- package/src/sessions/config.ts +176 -0
- package/src/sessions/format.ts +191 -0
- package/src/sessions/import-child-env.ts +8 -0
- package/src/sessions/import-child.ts +152 -0
- package/src/sessions/parsers/claude-code.ts +259 -0
- package/src/sessions/parsers/codex.ts +303 -0
- package/src/sessions/parsers/hermes.ts +248 -0
- package/src/sessions/parsers/openclaw.ts +496 -0
- package/src/sessions/parsers/shared.ts +184 -0
- package/src/sessions/sanitize.ts +222 -0
- package/src/sessions/service.ts +1533 -0
- package/src/sessions/setup.ts +477 -0
- package/src/sessions/sources.ts +518 -0
- package/src/sessions/state.ts +118 -0
- package/src/sessions/types.ts +457 -0
- package/src/store/sqlite/adapter.ts +68 -16
- package/src/store/sqlite/scoped-index.ts +9 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.zip.sha256 +0 -1
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DownloadIcon,
|
|
3
|
+
EyeIcon,
|
|
4
|
+
Loader2Icon,
|
|
5
|
+
RadarIcon,
|
|
6
|
+
Trash2Icon,
|
|
7
|
+
} from "lucide-react";
|
|
8
|
+
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
SessionDiscoveryCandidate,
|
|
12
|
+
SessionImportReceipt,
|
|
13
|
+
SessionsDiscovery,
|
|
14
|
+
SessionSourceStatus,
|
|
15
|
+
} from "./api";
|
|
16
|
+
|
|
17
|
+
import { SESSION_HARNESS_LABELS } from "../../../../sessions/types";
|
|
18
|
+
import { Badge } from "../ui/badge";
|
|
19
|
+
import { Button } from "../ui/button";
|
|
20
|
+
import { Input } from "../ui/input";
|
|
21
|
+
import { sessionsApi } from "./api";
|
|
22
|
+
import { ImportReceipt } from "./ImportReceipt";
|
|
23
|
+
|
|
24
|
+
const SOURCE_ID_PATTERN = "[a-z0-9][a-z0-9_-]{0,63}";
|
|
25
|
+
/** Select value for "type a collection name that does not exist yet". */
|
|
26
|
+
const NEW_COLLECTION = "__new__";
|
|
27
|
+
const SELECT_CLASS =
|
|
28
|
+
"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";
|
|
29
|
+
const FOCUS_RING =
|
|
30
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Focus `ref` whenever `trigger` changes to a new truthy value after mount.
|
|
34
|
+
* Buttons disable while their request runs, which drops keyboard focus to
|
|
35
|
+
* <body>; this hands it to the region that reports the outcome instead.
|
|
36
|
+
*/
|
|
37
|
+
function useFocusOnChange<T>(
|
|
38
|
+
trigger: T,
|
|
39
|
+
ref: { current: HTMLElement | null }
|
|
40
|
+
): void {
|
|
41
|
+
const previous = useRef(trigger);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (trigger && trigger !== previous.current) ref.current?.focus();
|
|
44
|
+
previous.current = trigger;
|
|
45
|
+
}, [trigger, ref]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatWhen(iso: string | null): string {
|
|
49
|
+
if (!iso) return "never";
|
|
50
|
+
const date = new Date(iso);
|
|
51
|
+
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatBytes(bytes: number): string {
|
|
55
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
56
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
57
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parse "prefix=collection" lines into project mappings. */
|
|
61
|
+
export function parseProjectLines(
|
|
62
|
+
text: string
|
|
63
|
+
): Array<{ prefix: string; collection: string }> | string {
|
|
64
|
+
const mappings: Array<{ prefix: string; collection: string }> = [];
|
|
65
|
+
for (const raw of text.split("\n")) {
|
|
66
|
+
const line = raw.trim();
|
|
67
|
+
if (!line) continue;
|
|
68
|
+
const split = line.lastIndexOf("=");
|
|
69
|
+
const prefix = split > 0 ? line.slice(0, split).trim() : "";
|
|
70
|
+
const collection = split > 0 ? line.slice(split + 1).trim() : "";
|
|
71
|
+
if (!(prefix && collection)) {
|
|
72
|
+
return `Project mapping "${line}" must look like /absolute/prefix=collection`;
|
|
73
|
+
}
|
|
74
|
+
mappings.push({ prefix, collection });
|
|
75
|
+
}
|
|
76
|
+
return mappings;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface SourceRowProps {
|
|
80
|
+
source: SessionSourceStatus;
|
|
81
|
+
localClient: boolean;
|
|
82
|
+
busy: string | null;
|
|
83
|
+
receipt: SessionImportReceipt | undefined;
|
|
84
|
+
onImport: (sourceId: string, dryRun: boolean) => void;
|
|
85
|
+
onRemove: (sourceId: string) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function SourceRow({
|
|
89
|
+
source,
|
|
90
|
+
localClient,
|
|
91
|
+
busy,
|
|
92
|
+
receipt,
|
|
93
|
+
onImport,
|
|
94
|
+
onRemove,
|
|
95
|
+
}: SourceRowProps) {
|
|
96
|
+
const [confirmRemove, setConfirmRemove] = useState(false);
|
|
97
|
+
const receiptRef = useRef<HTMLElement>(null);
|
|
98
|
+
useFocusOnChange(receipt, receiptRef);
|
|
99
|
+
const running = busy?.startsWith(`${source.id}:`) ?? false;
|
|
100
|
+
const { units } = source;
|
|
101
|
+
return (
|
|
102
|
+
<li className="min-w-0 space-y-3 rounded-lg border border-border/60 p-4">
|
|
103
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
104
|
+
<div className="min-w-0 space-y-1">
|
|
105
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
106
|
+
<h3 className="break-all font-mono font-semibold">{source.id}</h3>
|
|
107
|
+
<Badge variant="secondary">
|
|
108
|
+
{SESSION_HARNESS_LABELS[source.harness]}
|
|
109
|
+
</Badge>
|
|
110
|
+
{source.available ? (
|
|
111
|
+
<Badge variant="outline">available</Badge>
|
|
112
|
+
) : (
|
|
113
|
+
<Badge variant="destructive">unavailable — archive kept</Badge>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
<p className="text-muted-foreground text-sm">
|
|
117
|
+
→ collection{" "}
|
|
118
|
+
<span className="font-mono text-foreground">
|
|
119
|
+
{source.collection}
|
|
120
|
+
</span>{" "}
|
|
121
|
+
· last import {formatWhen(source.lastImportAt)}
|
|
122
|
+
</p>
|
|
123
|
+
<p className="text-muted-foreground text-xs">
|
|
124
|
+
Units: {units.total} total · {units.complete} complete ·{" "}
|
|
125
|
+
<span
|
|
126
|
+
className={
|
|
127
|
+
units.incomplete > 0 ? "text-amber-700 dark:text-amber-300" : ""
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
{units.incomplete} incomplete
|
|
131
|
+
</span>{" "}
|
|
132
|
+
·{" "}
|
|
133
|
+
<span className={units.failed > 0 ? "text-destructive" : ""}>
|
|
134
|
+
{units.failed} failed
|
|
135
|
+
</span>{" "}
|
|
136
|
+
· {units.pending} pending · {source.archivedThreads} archived
|
|
137
|
+
threads
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="flex flex-wrap gap-2">
|
|
141
|
+
<Button
|
|
142
|
+
aria-label={`Preview import of ${source.id} (dry run)`}
|
|
143
|
+
disabled={busy !== null || !source.available}
|
|
144
|
+
onClick={() => onImport(source.id, true)}
|
|
145
|
+
size="sm"
|
|
146
|
+
variant="outline"
|
|
147
|
+
>
|
|
148
|
+
{busy === `${source.id}:preview` ? (
|
|
149
|
+
<Loader2Icon className="animate-spin" />
|
|
150
|
+
) : (
|
|
151
|
+
<EyeIcon />
|
|
152
|
+
)}
|
|
153
|
+
Preview (dry run)
|
|
154
|
+
</Button>
|
|
155
|
+
<Button
|
|
156
|
+
aria-label={`Import ${source.id}`}
|
|
157
|
+
disabled={busy !== null || !source.available}
|
|
158
|
+
onClick={() => onImport(source.id, false)}
|
|
159
|
+
size="sm"
|
|
160
|
+
>
|
|
161
|
+
{busy === `${source.id}:import` ? (
|
|
162
|
+
<Loader2Icon className="animate-spin" />
|
|
163
|
+
) : (
|
|
164
|
+
<DownloadIcon />
|
|
165
|
+
)}
|
|
166
|
+
Import
|
|
167
|
+
</Button>
|
|
168
|
+
{localClient &&
|
|
169
|
+
(confirmRemove ? (
|
|
170
|
+
<>
|
|
171
|
+
<Button
|
|
172
|
+
disabled={running}
|
|
173
|
+
onClick={() => onRemove(source.id)}
|
|
174
|
+
size="sm"
|
|
175
|
+
variant="destructive"
|
|
176
|
+
>
|
|
177
|
+
Confirm remove
|
|
178
|
+
</Button>
|
|
179
|
+
<Button
|
|
180
|
+
onClick={() => setConfirmRemove(false)}
|
|
181
|
+
size="sm"
|
|
182
|
+
variant="ghost"
|
|
183
|
+
>
|
|
184
|
+
Cancel
|
|
185
|
+
</Button>
|
|
186
|
+
</>
|
|
187
|
+
) : (
|
|
188
|
+
<Button
|
|
189
|
+
aria-label={`Remove source ${source.id}`}
|
|
190
|
+
disabled={busy !== null}
|
|
191
|
+
onClick={() => setConfirmRemove(true)}
|
|
192
|
+
size="sm"
|
|
193
|
+
variant="ghost"
|
|
194
|
+
>
|
|
195
|
+
<Trash2Icon />
|
|
196
|
+
Remove
|
|
197
|
+
</Button>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
{confirmRemove && (
|
|
202
|
+
<p className="text-muted-foreground text-xs">
|
|
203
|
+
Removing unregisters the source. Its archived sessions stay in the
|
|
204
|
+
archive and index.
|
|
205
|
+
</p>
|
|
206
|
+
)}
|
|
207
|
+
{receipt && <ImportReceipt receipt={receipt} ref={receiptRef} />}
|
|
208
|
+
</li>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface RegisterFormProps {
|
|
213
|
+
candidate: SessionDiscoveryCandidate;
|
|
214
|
+
archiveCollections: string[];
|
|
215
|
+
onRegistered: (sourceId: string, collection: string) => Promise<void>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function RegisterForm({
|
|
219
|
+
candidate,
|
|
220
|
+
archiveCollections,
|
|
221
|
+
onRegistered,
|
|
222
|
+
}: RegisterFormProps) {
|
|
223
|
+
const formId = useId();
|
|
224
|
+
const [id, setId] = useState(`${candidate.harness}-main`);
|
|
225
|
+
// No default: the destination collection is a privacy boundary, so the
|
|
226
|
+
// user must pick it explicitly.
|
|
227
|
+
const [collectionChoice, setCollectionChoice] = useState("");
|
|
228
|
+
const [newCollection, setNewCollection] = useState("");
|
|
229
|
+
const [projects, setProjects] = useState("");
|
|
230
|
+
const [error, setError] = useState<{ text: string } | null>(null);
|
|
231
|
+
const [saving, setSaving] = useState(false);
|
|
232
|
+
const errorRef = useRef<HTMLParagraphElement>(null);
|
|
233
|
+
useFocusOnChange(error, errorRef);
|
|
234
|
+
|
|
235
|
+
const creatingCollection = collectionChoice === NEW_COLLECTION;
|
|
236
|
+
const collection = creatingCollection
|
|
237
|
+
? newCollection.trim()
|
|
238
|
+
: collectionChoice;
|
|
239
|
+
const hintId = `${formId}-collection-hint`;
|
|
240
|
+
|
|
241
|
+
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
|
242
|
+
event.preventDefault();
|
|
243
|
+
if (!collection) {
|
|
244
|
+
setError({ text: "Choose a destination archive collection." });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const mappings = parseProjectLines(projects);
|
|
248
|
+
if (typeof mappings === "string") {
|
|
249
|
+
setError({ text: mappings });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
setSaving(true);
|
|
253
|
+
const result = await sessionsApi("/api/sessions/sources", {
|
|
254
|
+
method: "POST",
|
|
255
|
+
body: JSON.stringify({
|
|
256
|
+
id: id.trim(),
|
|
257
|
+
harness: candidate.harness,
|
|
258
|
+
path: candidate.path,
|
|
259
|
+
collection,
|
|
260
|
+
...(mappings.length > 0 ? { projects: mappings } : {}),
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
setSaving(false);
|
|
264
|
+
if (result.error) {
|
|
265
|
+
setError({ text: result.error });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
setError(null);
|
|
269
|
+
await onRegistered(id.trim(), collection);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<form
|
|
274
|
+
aria-label={`Register ${SESSION_HARNESS_LABELS[candidate.harness]} source`}
|
|
275
|
+
className="grid gap-3 sm:grid-cols-2"
|
|
276
|
+
onSubmit={(event) => void submit(event)}
|
|
277
|
+
>
|
|
278
|
+
<label className="grid gap-1 text-sm" htmlFor={`${formId}-id`}>
|
|
279
|
+
Source ID
|
|
280
|
+
<Input
|
|
281
|
+
id={`${formId}-id`}
|
|
282
|
+
onChange={(event) => setId(event.currentTarget.value)}
|
|
283
|
+
pattern={SOURCE_ID_PATTERN}
|
|
284
|
+
required
|
|
285
|
+
value={id}
|
|
286
|
+
/>
|
|
287
|
+
</label>
|
|
288
|
+
<label className="grid gap-1 text-sm" htmlFor={`${formId}-collection`}>
|
|
289
|
+
Destination archive collection
|
|
290
|
+
<select
|
|
291
|
+
aria-describedby={collection ? undefined : hintId}
|
|
292
|
+
className={SELECT_CLASS}
|
|
293
|
+
id={`${formId}-collection`}
|
|
294
|
+
onChange={(event) => setCollectionChoice(event.currentTarget.value)}
|
|
295
|
+
required
|
|
296
|
+
value={collectionChoice}
|
|
297
|
+
>
|
|
298
|
+
<option disabled value="">
|
|
299
|
+
Choose a collection…
|
|
300
|
+
</option>
|
|
301
|
+
{archiveCollections.map((name) => (
|
|
302
|
+
<option key={name} value={name}>
|
|
303
|
+
{name}
|
|
304
|
+
</option>
|
|
305
|
+
))}
|
|
306
|
+
<option value={NEW_COLLECTION}>New collection…</option>
|
|
307
|
+
</select>
|
|
308
|
+
</label>
|
|
309
|
+
{creatingCollection && (
|
|
310
|
+
<label
|
|
311
|
+
className="grid gap-1 text-sm sm:col-start-2"
|
|
312
|
+
htmlFor={`${formId}-new-collection`}
|
|
313
|
+
>
|
|
314
|
+
New collection name
|
|
315
|
+
<Input
|
|
316
|
+
id={`${formId}-new-collection`}
|
|
317
|
+
onChange={(event) => setNewCollection(event.currentTarget.value)}
|
|
318
|
+
pattern={SOURCE_ID_PATTERN}
|
|
319
|
+
required
|
|
320
|
+
value={newCollection}
|
|
321
|
+
/>
|
|
322
|
+
</label>
|
|
323
|
+
)}
|
|
324
|
+
<label
|
|
325
|
+
className="grid gap-1 text-sm sm:col-span-2"
|
|
326
|
+
htmlFor={`${formId}-projects`}
|
|
327
|
+
>
|
|
328
|
+
Project mappings (optional, one per line: /absolute/prefix=collection)
|
|
329
|
+
<textarea
|
|
330
|
+
className="min-h-16 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
331
|
+
id={`${formId}-projects`}
|
|
332
|
+
onChange={(event) => setProjects(event.currentTarget.value)}
|
|
333
|
+
value={projects}
|
|
334
|
+
/>
|
|
335
|
+
</label>
|
|
336
|
+
{error && (
|
|
337
|
+
<p
|
|
338
|
+
className={`break-words text-destructive text-sm sm:col-span-2 ${FOCUS_RING}`}
|
|
339
|
+
ref={errorRef}
|
|
340
|
+
role="alert"
|
|
341
|
+
tabIndex={-1}
|
|
342
|
+
>
|
|
343
|
+
{error.text}
|
|
344
|
+
</p>
|
|
345
|
+
)}
|
|
346
|
+
<div className="sm:col-span-2">
|
|
347
|
+
<Button
|
|
348
|
+
aria-describedby={collection ? undefined : hintId}
|
|
349
|
+
disabled={saving || !collection}
|
|
350
|
+
size="sm"
|
|
351
|
+
type="submit"
|
|
352
|
+
>
|
|
353
|
+
{saving && <Loader2Icon className="animate-spin" />}
|
|
354
|
+
Register source
|
|
355
|
+
</Button>
|
|
356
|
+
<span className="ml-2 text-muted-foreground text-xs">
|
|
357
|
+
Registering imports nothing.
|
|
358
|
+
</span>
|
|
359
|
+
{!collection && (
|
|
360
|
+
<p className="mt-1 text-muted-foreground text-xs" id={hintId}>
|
|
361
|
+
Choose a destination collection to register this source. Its
|
|
362
|
+
sessions become searchable only in that collection.
|
|
363
|
+
</p>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</form>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface SourcesPanelProps {
|
|
371
|
+
sources: SessionSourceStatus[];
|
|
372
|
+
archiveCollections: string[];
|
|
373
|
+
localClient: boolean;
|
|
374
|
+
busy: string | null;
|
|
375
|
+
receipts: Record<string, SessionImportReceipt>;
|
|
376
|
+
onImport: (sourceId: string, dryRun: boolean) => void;
|
|
377
|
+
onRemove: (sourceId: string) => void;
|
|
378
|
+
onChanged: () => Promise<void>;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function SourcesPanel({
|
|
382
|
+
sources,
|
|
383
|
+
archiveCollections,
|
|
384
|
+
localClient,
|
|
385
|
+
busy,
|
|
386
|
+
receipts,
|
|
387
|
+
onImport,
|
|
388
|
+
onRemove,
|
|
389
|
+
onChanged,
|
|
390
|
+
}: SourcesPanelProps) {
|
|
391
|
+
const [discovery, setDiscovery] = useState<SessionsDiscovery | null>(null);
|
|
392
|
+
const [discovering, setDiscovering] = useState(false);
|
|
393
|
+
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
|
394
|
+
const [notice, setNotice] = useState<{ text: string } | null>(null);
|
|
395
|
+
const noticeRef = useRef<HTMLParagraphElement>(null);
|
|
396
|
+
useFocusOnChange(notice, noticeRef);
|
|
397
|
+
|
|
398
|
+
const discover = async () => {
|
|
399
|
+
setDiscovering(true);
|
|
400
|
+
const result = await sessionsApi<SessionsDiscovery>(
|
|
401
|
+
"/api/sessions/discover"
|
|
402
|
+
);
|
|
403
|
+
setDiscovering(false);
|
|
404
|
+
setDiscoverError(result.error);
|
|
405
|
+
setDiscovery(result.data);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const registered = async (sourceId: string, collection: string) => {
|
|
409
|
+
setNotice({
|
|
410
|
+
text: `Registered source ${sourceId} → collection ${collection}. Nothing was imported yet.`,
|
|
411
|
+
});
|
|
412
|
+
await onChanged();
|
|
413
|
+
await discover();
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<section
|
|
418
|
+
aria-labelledby="sessions-sources-heading"
|
|
419
|
+
className="min-w-0 space-y-4"
|
|
420
|
+
>
|
|
421
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
422
|
+
<h2 className="font-semibold text-xl" id="sessions-sources-heading">
|
|
423
|
+
Sources
|
|
424
|
+
</h2>
|
|
425
|
+
{localClient && (
|
|
426
|
+
<Button
|
|
427
|
+
disabled={discovering}
|
|
428
|
+
onClick={() => void discover()}
|
|
429
|
+
size="sm"
|
|
430
|
+
variant="outline"
|
|
431
|
+
>
|
|
432
|
+
{discovering ? (
|
|
433
|
+
<Loader2Icon className="animate-spin" />
|
|
434
|
+
) : (
|
|
435
|
+
<RadarIcon />
|
|
436
|
+
)}
|
|
437
|
+
Discover local sources
|
|
438
|
+
</Button>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
{sources.length === 0 ? (
|
|
443
|
+
<p className="text-muted-foreground text-sm">
|
|
444
|
+
No sources registered yet.{" "}
|
|
445
|
+
{localClient
|
|
446
|
+
? "Discover local sources and register the ones you permit."
|
|
447
|
+
: "Sources can only be registered from a browser on this machine."}
|
|
448
|
+
</p>
|
|
449
|
+
) : (
|
|
450
|
+
<ul className="space-y-3">
|
|
451
|
+
{sources.map((source) => (
|
|
452
|
+
<SourceRow
|
|
453
|
+
busy={busy}
|
|
454
|
+
key={source.id}
|
|
455
|
+
localClient={localClient}
|
|
456
|
+
onImport={onImport}
|
|
457
|
+
onRemove={onRemove}
|
|
458
|
+
receipt={receipts[source.id]}
|
|
459
|
+
source={source}
|
|
460
|
+
/>
|
|
461
|
+
))}
|
|
462
|
+
</ul>
|
|
463
|
+
)}
|
|
464
|
+
|
|
465
|
+
{notice && (
|
|
466
|
+
<p
|
|
467
|
+
className={`break-words rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-emerald-700 text-sm dark:text-emerald-300 ${FOCUS_RING}`}
|
|
468
|
+
ref={noticeRef}
|
|
469
|
+
role="status"
|
|
470
|
+
tabIndex={-1}
|
|
471
|
+
>
|
|
472
|
+
{notice.text}
|
|
473
|
+
</p>
|
|
474
|
+
)}
|
|
475
|
+
|
|
476
|
+
{discoverError && (
|
|
477
|
+
<p className="break-words text-destructive text-sm" role="alert">
|
|
478
|
+
{discoverError}
|
|
479
|
+
</p>
|
|
480
|
+
)}
|
|
481
|
+
{discovery && (
|
|
482
|
+
<div className="space-y-3 rounded-lg border border-dashed border-border/70 p-4">
|
|
483
|
+
<h3 className="font-medium">Discovered on this machine</h3>
|
|
484
|
+
<p className="text-muted-foreground text-xs">
|
|
485
|
+
Preview only: discovery reads nothing into the archive. Register a
|
|
486
|
+
source to permit manual imports from it.
|
|
487
|
+
</p>
|
|
488
|
+
{discovery.candidates.length === 0 && (
|
|
489
|
+
<p className="text-muted-foreground text-sm">
|
|
490
|
+
No supported session stores were found.
|
|
491
|
+
</p>
|
|
492
|
+
)}
|
|
493
|
+
<ul className="space-y-4">
|
|
494
|
+
{discovery.candidates.map((candidate) => (
|
|
495
|
+
<li
|
|
496
|
+
className="min-w-0 space-y-2"
|
|
497
|
+
key={`${candidate.harness}:${candidate.path}`}
|
|
498
|
+
>
|
|
499
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
500
|
+
<Badge variant="secondary">
|
|
501
|
+
{SESSION_HARNESS_LABELS[candidate.harness]}
|
|
502
|
+
</Badge>
|
|
503
|
+
<span className="min-w-0 break-all font-mono text-xs">
|
|
504
|
+
{candidate.path}
|
|
505
|
+
</span>
|
|
506
|
+
</div>
|
|
507
|
+
<p className="text-muted-foreground text-xs">
|
|
508
|
+
{candidate.units}
|
|
509
|
+
{candidate.truncated ? "+" : ""} units ·{" "}
|
|
510
|
+
{formatBytes(candidate.bytes)}
|
|
511
|
+
{candidate.formatVersions.length > 0 &&
|
|
512
|
+
` · format ${candidate.formatVersions.join(", ")}`}
|
|
513
|
+
</p>
|
|
514
|
+
{candidate.registeredAs ? (
|
|
515
|
+
<p className="text-sm">
|
|
516
|
+
Registered as{" "}
|
|
517
|
+
<span className="font-mono">{candidate.registeredAs}</span>
|
|
518
|
+
</p>
|
|
519
|
+
) : (
|
|
520
|
+
<RegisterForm
|
|
521
|
+
candidate={candidate}
|
|
522
|
+
archiveCollections={archiveCollections}
|
|
523
|
+
onRegistered={registered}
|
|
524
|
+
/>
|
|
525
|
+
)}
|
|
526
|
+
</li>
|
|
527
|
+
))}
|
|
528
|
+
</ul>
|
|
529
|
+
{discovery.warnings.map((warning) => (
|
|
530
|
+
<p
|
|
531
|
+
className="text-amber-800 text-xs dark:text-amber-200"
|
|
532
|
+
key={warning}
|
|
533
|
+
>
|
|
534
|
+
{warning}
|
|
535
|
+
</p>
|
|
536
|
+
))}
|
|
537
|
+
</div>
|
|
538
|
+
)}
|
|
539
|
+
</section>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client helpers for /api/sessions/*: failures keep the stable
|
|
3
|
+
* `details.sessionsCode` so the page can branch on it (e.g. show the archive
|
|
4
|
+
* setup state for SESSIONS_NOT_CONFIGURED).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { apiFetch } from "../../hooks/use-api";
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
SessionAutomationRunResult,
|
|
11
|
+
SessionAutomationStatus,
|
|
12
|
+
SessionDiscoveryCandidate,
|
|
13
|
+
SessionProfileStatus,
|
|
14
|
+
SessionHarness,
|
|
15
|
+
SessionImportReceipt,
|
|
16
|
+
SessionsDiscovery,
|
|
17
|
+
SessionSourceStatus,
|
|
18
|
+
SessionsStatus,
|
|
19
|
+
SessionUnitReceipt,
|
|
20
|
+
} from "../../../../sessions/types";
|
|
21
|
+
|
|
22
|
+
export interface SessionsApiResult<T> {
|
|
23
|
+
data: T | null;
|
|
24
|
+
error: string | null;
|
|
25
|
+
sessionsCode: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** apiFetch plus the stable `details.sessionsCode` of a failed request. */
|
|
29
|
+
export async function sessionsApi<T>(
|
|
30
|
+
endpoint: string,
|
|
31
|
+
init?: RequestInit
|
|
32
|
+
): Promise<SessionsApiResult<T>> {
|
|
33
|
+
const result = await apiFetch<T>(endpoint, init);
|
|
34
|
+
const code = result.details?.sessionsCode;
|
|
35
|
+
return {
|
|
36
|
+
data: result.data,
|
|
37
|
+
error: result.error,
|
|
38
|
+
sessionsCode: typeof code === "string" ? code : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
const MARK_OPEN = "<mark>";
|
|
4
|
+
const MARK_CLOSE = "</mark>";
|
|
5
|
+
|
|
6
|
+
/** CommonMark backslash escape: `\` before any ASCII punctuation character. */
|
|
7
|
+
const MARKDOWN_ESCAPE = /\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g;
|
|
8
|
+
|
|
9
|
+
/** Drop Markdown backslash escapes (`state.db\#` -> `state.db#`). */
|
|
10
|
+
export function unescapeMarkdown(text: string): string {
|
|
11
|
+
return text.replace(MARKDOWN_ESCAPE, "$1");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a search snippet: FTS `<mark>` highlights become real <mark>
|
|
16
|
+
* elements, everything else stays React text (never parsed as HTML).
|
|
17
|
+
* Mirrors the Search page renderer, plus Markdown-escape removal because
|
|
18
|
+
* archived turns are indexed as escaped Markdown.
|
|
19
|
+
*/
|
|
20
|
+
export function renderSessionSnippet(snippet: string): ReactNode[] {
|
|
21
|
+
const parts: ReactNode[] = [];
|
|
22
|
+
let remaining = snippet;
|
|
23
|
+
let key = 0;
|
|
24
|
+
|
|
25
|
+
while (remaining.length > 0) {
|
|
26
|
+
const markStart = remaining.indexOf(MARK_OPEN);
|
|
27
|
+
if (markStart === -1) {
|
|
28
|
+
parts.push(unescapeMarkdown(remaining));
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
if (markStart > 0) {
|
|
32
|
+
parts.push(unescapeMarkdown(remaining.slice(0, markStart)));
|
|
33
|
+
}
|
|
34
|
+
const markEnd = remaining.indexOf(MARK_CLOSE, markStart);
|
|
35
|
+
if (markEnd === -1) {
|
|
36
|
+
parts.push(unescapeMarkdown(remaining.slice(markStart)));
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
parts.push(
|
|
40
|
+
<mark
|
|
41
|
+
className="rounded bg-primary/20 px-0.5 font-medium text-primary"
|
|
42
|
+
key={key++}
|
|
43
|
+
>
|
|
44
|
+
{unescapeMarkdown(
|
|
45
|
+
remaining.slice(markStart + MARK_OPEN.length, markEnd)
|
|
46
|
+
)}
|
|
47
|
+
</mark>
|
|
48
|
+
);
|
|
49
|
+
remaining = remaining.slice(markEnd + MARK_CLOSE.length);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return parts;
|
|
53
|
+
}
|