@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,766 @@
1
+ /**
2
+ * REST adapters for the session archive: status, manual import of
3
+ * registered sources, and owner-only discovery/registration/init.
4
+ *
5
+ * Thin adapters over the transport-neutral sessions service. The instance is
6
+ * archive-bound iff its loaded config carries a `sessions` block; there is no
7
+ * cross-index routing. Remote callers may read status and import a registered
8
+ * source by ID; they never name host paths and never learn host directories.
9
+ * Discovery, source registration/removal and archive init are same-host only.
10
+ *
11
+ * @module src/serve/routes/sessions
12
+ */
13
+
14
+ import type { Config } from "../../config/types";
15
+ import type { SqliteAdapter } from "../../store/sqlite/adapter";
16
+ import type { RequestPeerServer } from "../request-locality";
17
+ import type { ContextHolder } from "./api";
18
+
19
+ import { getIndexDbPath } from "../../app/constants";
20
+ import { getConfigPaths, loadConfig } from "../../config";
21
+ import { withContentTypeRules } from "../../ingestion";
22
+ import {
23
+ type AutomationContext,
24
+ disableAutomation,
25
+ enableAutomation,
26
+ previewAutomationProfile,
27
+ removeAutomationProfile,
28
+ runAutomationProfile,
29
+ setAutomationProfile,
30
+ } from "../../sessions/automation";
31
+ import { assertSessionBinding } from "../../sessions/binding";
32
+ import { SessionSourceSchema, watchedCollections } from "../../sessions/config";
33
+ import { importInChildProcess } from "../../sessions/import-child";
34
+ import { SessionsService } from "../../sessions/service";
35
+ import {
36
+ addSessionSource,
37
+ initSessionArchive,
38
+ removeSessionSource,
39
+ } from "../../sessions/setup";
40
+ import {
41
+ SESSION_HARNESSES,
42
+ type SessionHarness,
43
+ type SessionImportReceipt,
44
+ remoteSafeSessionsError,
45
+ type SessionsErrorCode,
46
+ SESSIONS_VALIDATION_CODES,
47
+ } from "../../sessions/types";
48
+ import { isLocalClientRequest } from "../request-locality";
49
+
50
+ const HTTP_BAD_REQUEST = 400;
51
+ const HTTP_FORBIDDEN = 403;
52
+ const HTTP_CONFLICT = 409;
53
+ const HTTP_INTERNAL = 500;
54
+
55
+ /** Generic REST error code per status; `details.sessionsCode` is specific. */
56
+ const WIRE_CODES: Readonly<Record<number, string>> = {
57
+ [HTTP_BAD_REQUEST]: "VALIDATION",
58
+ [HTTP_FORBIDDEN]: "FORBIDDEN",
59
+ [HTTP_CONFLICT]: "BUSY",
60
+ [HTTP_INTERNAL]: "RUNTIME",
61
+ };
62
+
63
+ /** Keys accepted by POST /api/sessions/import. */
64
+ const IMPORT_KEYS = new Set(["sourceId", "dryRun", "limit"]);
65
+ /** Path-import keys that only local CLI/SDK callers may use. */
66
+ const IMPORT_PATH_KEYS = ["collection", "format"] as const;
67
+ const SOURCE_KEYS = new Set([
68
+ "id",
69
+ "harness",
70
+ "path",
71
+ "collection",
72
+ "projects",
73
+ ]);
74
+ const INIT_KEYS = new Set(["archive", "collection"]);
75
+
76
+ export interface SessionsRouteDeps {
77
+ /** Peer lookup for same-host checks; absent means remote (fail closed). */
78
+ server?: RequestPeerServer;
79
+ /** Discovery environment override (tests use a synthetic home). */
80
+ env?: NodeJS.ProcessEnv;
81
+ }
82
+
83
+ export interface SessionsInitResponse {
84
+ schemaVersion: "1";
85
+ index: string;
86
+ collection: string;
87
+ archiveRoot: string;
88
+ created: boolean;
89
+ }
90
+
91
+ export interface SessionsSourceAddResponse {
92
+ id: string;
93
+ registered: true;
94
+ }
95
+
96
+ export interface SessionsSourceRemoveResponse {
97
+ id: string;
98
+ removed: true;
99
+ archiveRetained: true;
100
+ }
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // Helpers
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+
106
+ function sessionsError(
107
+ code: SessionsErrorCode,
108
+ message: string,
109
+ status: number
110
+ ): Response {
111
+ return Response.json(
112
+ {
113
+ error: {
114
+ code: WIRE_CODES[status] ?? "RUNTIME",
115
+ message,
116
+ details: { sessionsCode: code },
117
+ },
118
+ },
119
+ { status }
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Map a thrown error onto the REST envelope; `details.sessionsCode` is
125
+ * stable and messages never carry host paths.
126
+ */
127
+ export function sessionsErrorResponse(error: unknown): Response {
128
+ const typed = remoteSafeSessionsError(error);
129
+ if (typed.code === "SESSIONS_BUSY") {
130
+ return sessionsError(typed.code, typed.message, HTTP_CONFLICT);
131
+ }
132
+ return sessionsError(
133
+ typed.code,
134
+ typed.message,
135
+ SESSIONS_VALIDATION_CODES.has(typed.code) ? HTTP_BAD_REQUEST : HTTP_INTERNAL
136
+ );
137
+ }
138
+
139
+ const invalid = (message: string): Response =>
140
+ sessionsError("SESSIONS_INVALID_INPUT", message, HTTP_BAD_REQUEST);
141
+
142
+ function localOnly(
143
+ req: Request,
144
+ deps: SessionsRouteDeps,
145
+ action: string
146
+ ): Response | null {
147
+ if (isLocalClientRequest(req, deps.server)) return null;
148
+ return Response.json(
149
+ {
150
+ error: {
151
+ code: "FORBIDDEN",
152
+ message: `${action} is only available to a same-host browser`,
153
+ },
154
+ },
155
+ { status: HTTP_FORBIDDEN }
156
+ );
157
+ }
158
+
159
+ async function readObjectBody(
160
+ req: Request
161
+ ): Promise<
162
+ { ok: true; body: Record<string, unknown> } | { ok: false; res: Response }
163
+ > {
164
+ let body: unknown;
165
+ try {
166
+ const text = await req.text();
167
+ body = text.trim() ? JSON.parse(text) : {};
168
+ } catch {
169
+ return { ok: false, res: invalid("Invalid JSON body") };
170
+ }
171
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
172
+ return { ok: false, res: invalid("Request body must be a JSON object") };
173
+ }
174
+ return { ok: true, body: body as Record<string, unknown> };
175
+ }
176
+
177
+ function instanceIdentity(ctxHolder: ContextHolder): {
178
+ configPath: string;
179
+ indexName: string;
180
+ } {
181
+ return {
182
+ configPath: ctxHolder.actualConfigPath ?? "",
183
+ indexName: ctxHolder.current.indexName,
184
+ };
185
+ }
186
+
187
+ /** Refuse an archive config opened against a different index (and vice versa). */
188
+ async function assertInstanceBinding(ctxHolder: ContextHolder): Promise<void> {
189
+ const { configPath, indexName } = instanceIdentity(ctxHolder);
190
+ if (!ctxHolder.config.sessions) return;
191
+ await assertSessionBinding({
192
+ config: ctxHolder.config,
193
+ configPath,
194
+ indexName,
195
+ dbPath: getIndexDbPath(indexName),
196
+ });
197
+ }
198
+
199
+ /** Service over the instance's own config/index pair, binding checked. */
200
+ async function archiveService(
201
+ ctxHolder: ContextHolder,
202
+ store?: SqliteAdapter
203
+ ): Promise<SessionsService> {
204
+ await assertInstanceBinding(ctxHolder);
205
+ const { configPath, indexName } = instanceIdentity(ctxHolder);
206
+ return new SessionsService({
207
+ config: ctxHolder.config,
208
+ configPath,
209
+ indexName,
210
+ store,
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Adopt a config the sessions service already persisted: project collections
216
+ * and contexts into the open store, swap the in-memory context, and refresh
217
+ * the watcher, egress policy and mutation generations (same sequence as the
218
+ * config-sync route helpers).
219
+ */
220
+ async function adoptConfig(
221
+ ctxHolder: ContextHolder,
222
+ store: SqliteAdapter,
223
+ config: Config
224
+ ): Promise<void> {
225
+ const collections = await store.syncCollections(config.collections);
226
+ if (!collections.ok) {
227
+ throw new Error(
228
+ `Config saved but collection sync failed: ${collections.error.message}`
229
+ );
230
+ }
231
+ const contexts = await store.syncContexts(config.contexts ?? []);
232
+ if (!contexts.ok) {
233
+ throw new Error(
234
+ `Config saved but context sync failed: ${contexts.error.message}`
235
+ );
236
+ }
237
+ ctxHolder.config = config;
238
+ ctxHolder.current = { ...ctxHolder.current, config };
239
+ ctxHolder.watchService?.updateCollections(
240
+ watchedCollections(config),
241
+ withContentTypeRules({}, config)
242
+ );
243
+ await ctxHolder.invalidateEgressPolicy?.();
244
+ ctxHolder.markContentMutation?.();
245
+ ctxHolder.markIndexMutation?.();
246
+ }
247
+
248
+ // ─────────────────────────────────────────────────────────────────────────────
249
+ // Handlers
250
+ // ─────────────────────────────────────────────────────────────────────────────
251
+
252
+ /** GET /api/sessions/status */
253
+ export async function handleSessionsStatus(
254
+ ctxHolder: ContextHolder
255
+ ): Promise<Response> {
256
+ try {
257
+ const service = await archiveService(ctxHolder);
258
+ return Response.json(await service.status());
259
+ } catch (error) {
260
+ return sessionsErrorResponse(error);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * POST /api/sessions/import
266
+ * Body: { sourceId, dryRun?, limit? }. Host paths are refused before any read.
267
+ */
268
+ export async function handleSessionsImport(
269
+ ctxHolder: ContextHolder,
270
+ req: Request
271
+ ): Promise<Response> {
272
+ const parsed = await readObjectBody(req);
273
+ if (!parsed.ok) return parsed.res;
274
+ const { body } = parsed;
275
+ if ("paths" in body) {
276
+ return sessionsError(
277
+ "SESSIONS_UNSAFE_PATH",
278
+ "Host paths cannot be named over REST; import a registered source by its ID.",
279
+ HTTP_BAD_REQUEST
280
+ );
281
+ }
282
+ for (const key of IMPORT_PATH_KEYS) {
283
+ if (key in body) {
284
+ return invalid(
285
+ `"${key}" applies to local path imports only; a registered source imports into its registered collections.`
286
+ );
287
+ }
288
+ }
289
+ const unknown = Object.keys(body).filter((key) => !IMPORT_KEYS.has(key));
290
+ if (unknown.length > 0) {
291
+ return invalid(`Unknown field(s): ${unknown.join(", ")}`);
292
+ }
293
+ if (body.sourceId === undefined) {
294
+ return sessionsError(
295
+ "SESSIONS_SELECTION_REQUIRED",
296
+ "Select a registered source: { sourceId }.",
297
+ HTTP_BAD_REQUEST
298
+ );
299
+ }
300
+ if (typeof body.sourceId !== "string" || !body.sourceId.trim()) {
301
+ return invalid("sourceId must be a non-empty string");
302
+ }
303
+ if (body.dryRun !== undefined && typeof body.dryRun !== "boolean") {
304
+ return invalid("dryRun must be a boolean");
305
+ }
306
+ if (
307
+ body.limit !== undefined &&
308
+ (typeof body.limit !== "number" || !Number.isSafeInteger(body.limit))
309
+ ) {
310
+ return invalid("limit must be an integer");
311
+ }
312
+
313
+ let receipt: SessionImportReceipt;
314
+ try {
315
+ await assertInstanceBinding(ctxHolder);
316
+ const { configPath, indexName } = instanceIdentity(ctxHolder);
317
+ // A child process keeps this server answering during a long import.
318
+ receipt = await importInChildProcess({
319
+ config: ctxHolder.config,
320
+ configPath: configPath || getConfigPaths().configFile,
321
+ indexName,
322
+ sourceId: body.sourceId.trim(),
323
+ dryRun: body.dryRun === true,
324
+ limit: body.limit as number | undefined,
325
+ });
326
+ } catch (error) {
327
+ return sessionsErrorResponse(error);
328
+ }
329
+ if (!receipt.dryRun && receipt.lexical.collections.length > 0) {
330
+ ctxHolder.markContentMutation?.();
331
+ ctxHolder.markIndexMutation?.();
332
+ // Debounced embedding pass over the newly synced archive records.
333
+ ctxHolder.scheduler?.notifySyncComplete(receipt.lexical.collections);
334
+ }
335
+ return Response.json(receipt);
336
+ }
337
+
338
+ /** GET /api/sessions/discover (same-host only: returns host paths). */
339
+ export async function handleSessionsDiscover(
340
+ ctxHolder: ContextHolder,
341
+ req: Request,
342
+ deps: SessionsRouteDeps = {}
343
+ ): Promise<Response> {
344
+ const refused = localOnly(req, deps, "Session discovery");
345
+ if (refused) return refused;
346
+ try {
347
+ const { configPath, indexName } = instanceIdentity(ctxHolder);
348
+ return Response.json(
349
+ await new SessionsService({
350
+ config: ctxHolder.config,
351
+ configPath,
352
+ indexName,
353
+ env: deps.env,
354
+ }).discover()
355
+ );
356
+ } catch (error) {
357
+ return sessionsErrorResponse(error);
358
+ }
359
+ }
360
+
361
+ /** POST /api/sessions/sources (same-host only). */
362
+ export async function handleSessionsAddSource(
363
+ ctxHolder: ContextHolder,
364
+ store: SqliteAdapter,
365
+ req: Request,
366
+ deps: SessionsRouteDeps = {}
367
+ ): Promise<Response> {
368
+ const refused = localOnly(req, deps, "Registering a session source");
369
+ if (refused) return refused;
370
+ const parsed = await readObjectBody(req);
371
+ if (!parsed.ok) return parsed.res;
372
+ const { body } = parsed;
373
+ const unknown = Object.keys(body).filter((key) => !SOURCE_KEYS.has(key));
374
+ if (unknown.length > 0) {
375
+ return invalid(`Unknown field(s): ${unknown.join(", ")}`);
376
+ }
377
+ if (
378
+ typeof body.harness !== "string" ||
379
+ !(SESSION_HARNESSES as readonly string[]).includes(body.harness)
380
+ ) {
381
+ return sessionsError(
382
+ "SESSIONS_UNSUPPORTED_FORMAT",
383
+ `harness must be one of: ${SESSION_HARNESSES.join(", ")}.`,
384
+ HTTP_BAD_REQUEST
385
+ );
386
+ }
387
+ const source = SessionSourceSchema.safeParse(body);
388
+ if (!source.success) {
389
+ const issue = source.error.issues[0];
390
+ return invalid(
391
+ `${issue?.path.join(".") || "body"}: ${issue?.message ?? "invalid source"}`
392
+ );
393
+ }
394
+ try {
395
+ const { configPath } = instanceIdentity(ctxHolder);
396
+ await assertInstanceBinding(ctxHolder);
397
+ const config = await addSessionSource({
398
+ configPath,
399
+ id: source.data.id,
400
+ harness: source.data.harness as SessionHarness,
401
+ path: source.data.path,
402
+ collection: source.data.collection,
403
+ projects: source.data.projects,
404
+ });
405
+ await adoptConfig(ctxHolder, store, config);
406
+ } catch (error) {
407
+ return sessionsErrorResponse(error);
408
+ }
409
+ const response: SessionsSourceAddResponse = {
410
+ id: source.data.id,
411
+ registered: true,
412
+ };
413
+ return Response.json(response);
414
+ }
415
+
416
+ /** DELETE /api/sessions/sources/:id (same-host only; archive retained). */
417
+ export async function handleSessionsRemoveSource(
418
+ ctxHolder: ContextHolder,
419
+ store: SqliteAdapter,
420
+ id: string,
421
+ req: Request,
422
+ deps: SessionsRouteDeps = {}
423
+ ): Promise<Response> {
424
+ const refused = localOnly(req, deps, "Removing a session source");
425
+ if (refused) return refused;
426
+ if (!id) return invalid("Source ID is required");
427
+ try {
428
+ const { configPath } = instanceIdentity(ctxHolder);
429
+ await assertInstanceBinding(ctxHolder);
430
+ const config = await removeSessionSource({ configPath, id });
431
+ await adoptConfig(ctxHolder, store, config);
432
+ } catch (error) {
433
+ return sessionsErrorResponse(error);
434
+ }
435
+ const response: SessionsSourceRemoveResponse = {
436
+ id,
437
+ removed: true,
438
+ archiveRetained: true,
439
+ };
440
+ return Response.json(response);
441
+ }
442
+
443
+ /**
444
+ * POST /api/sessions/init (same-host only)
445
+ * Body: { archive, collection }. Binds this instance's own config/index pair;
446
+ * the service refuses the default config and the default index.
447
+ */
448
+ export async function handleSessionsInit(
449
+ ctxHolder: ContextHolder,
450
+ store: SqliteAdapter,
451
+ req: Request,
452
+ deps: SessionsRouteDeps = {}
453
+ ): Promise<Response> {
454
+ const refused = localOnly(req, deps, "Creating a session archive");
455
+ if (refused) return refused;
456
+ const parsed = await readObjectBody(req);
457
+ if (!parsed.ok) return parsed.res;
458
+ const { body } = parsed;
459
+ const unknown = Object.keys(body).filter((key) => !INIT_KEYS.has(key));
460
+ if (unknown.length > 0) {
461
+ return invalid(`Unknown field(s): ${unknown.join(", ")}`);
462
+ }
463
+ if (
464
+ typeof body.archive !== "string" ||
465
+ !body.archive.trim() ||
466
+ typeof body.collection !== "string" ||
467
+ !body.collection.trim()
468
+ ) {
469
+ return sessionsError(
470
+ "SESSIONS_DESTINATION_REQUIRED",
471
+ "Archive init needs { archive: <absolute dir>, collection: <name> }.",
472
+ HTTP_BAD_REQUEST
473
+ );
474
+ }
475
+ const { configPath, indexName } = instanceIdentity(ctxHolder);
476
+ if (!configPath) {
477
+ return invalid("This server has no resolved config path.");
478
+ }
479
+ try {
480
+ const result = await initSessionArchive({
481
+ configPath,
482
+ indexName,
483
+ archiveRoot: body.archive.trim(),
484
+ collection: body.collection.trim(),
485
+ });
486
+ await adoptConfig(ctxHolder, store, result.config);
487
+ const response: SessionsInitResponse = {
488
+ schemaVersion: "1",
489
+ index: indexName,
490
+ collection: body.collection.trim(),
491
+ archiveRoot: result.archiveRoot,
492
+ created: result.created,
493
+ };
494
+ return Response.json(response);
495
+ } catch (error) {
496
+ return sessionsErrorResponse(error);
497
+ }
498
+ }
499
+
500
+ // ─────────────────────────────────────────────────────────────────────────────
501
+ // Automation
502
+ // ─────────────────────────────────────────────────────────────────────────────
503
+
504
+ const PROFILE_KEYS = new Set(["sources", "cadence", "limit", "retries"]);
505
+ const ENABLE_KEYS = new Set(["hook", "schedule"]);
506
+ const DISABLE_KEYS = new Set(["hook", "schedule"]);
507
+
508
+ async function automationContext(
509
+ ctxHolder: ContextHolder
510
+ ): Promise<AutomationContext> {
511
+ await assertInstanceBinding(ctxHolder);
512
+ const { configPath, indexName } = instanceIdentity(ctxHolder);
513
+ return { configPath, indexName };
514
+ }
515
+
516
+ /** Keep the served config current after an automation change. */
517
+ async function adoptSavedConfig(
518
+ ctxHolder: ContextHolder,
519
+ store: SqliteAdapter
520
+ ): Promise<void> {
521
+ const loaded = await loadConfig(instanceIdentity(ctxHolder).configPath);
522
+ if (loaded.ok) await adoptConfig(ctxHolder, store, loaded.value);
523
+ }
524
+
525
+ function unknownKeys(
526
+ body: Record<string, unknown>,
527
+ allowed: ReadonlySet<string>
528
+ ): Response | null {
529
+ const unknown = Object.keys(body).filter((key) => !allowed.has(key));
530
+ return unknown.length > 0
531
+ ? invalid(`Unknown field(s): ${unknown.join(", ")}`)
532
+ : null;
533
+ }
534
+
535
+ /**
536
+ * POST /api/sessions/automation/run
537
+ * Body: { profileId }. Any allowed client may request a run of a configured
538
+ * profile; it cannot enable triggers or widen sources.
539
+ */
540
+ export async function handleSessionsAutomationRun(
541
+ ctxHolder: ContextHolder,
542
+ store: SqliteAdapter,
543
+ req: Request
544
+ ): Promise<Response> {
545
+ const parsed = await readObjectBody(req);
546
+ if (!parsed.ok) return parsed.res;
547
+ const { body } = parsed;
548
+ const extra = unknownKeys(body, new Set(["profileId"]));
549
+ if (extra) return extra;
550
+ if (typeof body.profileId !== "string" || !body.profileId.trim()) {
551
+ return invalid("profileId must be a non-empty string");
552
+ }
553
+ try {
554
+ const result = await runAutomationProfile(
555
+ {
556
+ ...(await automationContext(ctxHolder)),
557
+ store,
558
+ // A child process keeps this server answering during the run.
559
+ inChildProcess: true,
560
+ },
561
+ body.profileId.trim(),
562
+ { trigger: "manual" }
563
+ );
564
+ const collections = [
565
+ ...new Set(
566
+ result.receipts.flatMap((receipt) => receipt.lexical.collections)
567
+ ),
568
+ ];
569
+ if (collections.length > 0) {
570
+ ctxHolder.markContentMutation?.();
571
+ ctxHolder.markIndexMutation?.();
572
+ ctxHolder.scheduler?.notifySyncComplete(collections);
573
+ }
574
+ return Response.json(result);
575
+ } catch (error) {
576
+ return sessionsErrorResponse(error);
577
+ }
578
+ }
579
+
580
+ /** GET /api/sessions/automation/:id/preview (same-host only: host paths). */
581
+ export async function handleSessionsAutomationPreview(
582
+ ctxHolder: ContextHolder,
583
+ id: string,
584
+ req: Request,
585
+ deps: SessionsRouteDeps = {}
586
+ ): Promise<Response> {
587
+ const refused = localOnly(req, deps, "Automation preview");
588
+ if (refused) return refused;
589
+ try {
590
+ return Response.json(
591
+ await previewAutomationProfile(await automationContext(ctxHolder), id)
592
+ );
593
+ } catch (error) {
594
+ return sessionsErrorResponse(error);
595
+ }
596
+ }
597
+
598
+ /** PUT /api/sessions/automation/:id (same-host only). Enables nothing. */
599
+ export async function handleSessionsAutomationSet(
600
+ ctxHolder: ContextHolder,
601
+ store: SqliteAdapter,
602
+ id: string,
603
+ req: Request,
604
+ deps: SessionsRouteDeps = {}
605
+ ): Promise<Response> {
606
+ const refused = localOnly(req, deps, "Changing an automation profile");
607
+ if (refused) return refused;
608
+ const parsed = await readObjectBody(req);
609
+ if (!parsed.ok) return parsed.res;
610
+ const { body } = parsed;
611
+ const extra = unknownKeys(body, PROFILE_KEYS);
612
+ if (extra) return extra;
613
+ if (
614
+ !Array.isArray(body.sources) ||
615
+ !body.sources.every((source) => typeof source === "string")
616
+ ) {
617
+ return invalid("sources must be an array of registered source IDs");
618
+ }
619
+ const optionalInt = (value: unknown): boolean =>
620
+ value === undefined ||
621
+ (typeof value === "number" && Number.isSafeInteger(value));
622
+ if (
623
+ (body.cadence !== undefined && typeof body.cadence !== "string") ||
624
+ !optionalInt(body.limit) ||
625
+ !optionalInt(body.retries)
626
+ ) {
627
+ return invalid("cadence must be a string; limit and retries integers");
628
+ }
629
+ try {
630
+ const preview = await setAutomationProfile(
631
+ await automationContext(ctxHolder),
632
+ {
633
+ id,
634
+ sources: body.sources as string[],
635
+ cadence: body.cadence as string | undefined,
636
+ limit: body.limit as number | undefined,
637
+ retries: body.retries as number | undefined,
638
+ }
639
+ );
640
+ await adoptSavedConfig(ctxHolder, store);
641
+ return Response.json(preview);
642
+ } catch (error) {
643
+ return sessionsErrorResponse(error);
644
+ }
645
+ }
646
+
647
+ /**
648
+ * POST /api/sessions/automation/:id/enable (same-host only)
649
+ * Body: { hook?: { harness, settings? }, schedule?: { cadence? } }.
650
+ */
651
+ export async function handleSessionsAutomationEnable(
652
+ ctxHolder: ContextHolder,
653
+ store: SqliteAdapter,
654
+ id: string,
655
+ req: Request,
656
+ deps: SessionsRouteDeps = {}
657
+ ): Promise<Response> {
658
+ const refused = localOnly(req, deps, "Enabling automation");
659
+ if (refused) return refused;
660
+ const parsed = await readObjectBody(req);
661
+ if (!parsed.ok) return parsed.res;
662
+ const { body } = parsed;
663
+ const extra = unknownKeys(body, ENABLE_KEYS);
664
+ if (extra) return extra;
665
+ const hook = body.hook as { harness?: unknown } | undefined;
666
+ const schedule = body.schedule as { cadence?: unknown } | undefined;
667
+ if (hook && typeof hook === "object" && "settings" in hook) {
668
+ // The browser installs only into the default or already recorded
669
+ // settings file; another file is named locally with the CLI.
670
+ return invalid(
671
+ "hook.settings is not accepted here; choose another settings file with gno sessions automation enable --settings"
672
+ );
673
+ }
674
+ if (
675
+ (hook !== undefined &&
676
+ (typeof hook !== "object" ||
677
+ hook === null ||
678
+ typeof hook.harness !== "string")) ||
679
+ (schedule !== undefined &&
680
+ (typeof schedule !== "object" ||
681
+ schedule === null ||
682
+ (schedule.cadence !== undefined &&
683
+ typeof schedule.cadence !== "string")))
684
+ ) {
685
+ return invalid(
686
+ "Body must be { hook?: { harness, settings? }, schedule?: { cadence? } }"
687
+ );
688
+ }
689
+ try {
690
+ const preview = await enableAutomation(
691
+ await automationContext(ctxHolder),
692
+ id,
693
+ {
694
+ ...(hook ? { hook: { harness: hook.harness as string } } : {}),
695
+ ...(schedule
696
+ ? { schedule: { cadence: schedule.cadence as string | undefined } }
697
+ : {}),
698
+ }
699
+ );
700
+ await adoptSavedConfig(ctxHolder, store);
701
+ return Response.json(preview);
702
+ } catch (error) {
703
+ return sessionsErrorResponse(error);
704
+ }
705
+ }
706
+
707
+ /**
708
+ * POST /api/sessions/automation/:id/disable (same-host only)
709
+ * Body: { hook?: boolean, schedule?: boolean }; empty pauses both.
710
+ */
711
+ export async function handleSessionsAutomationDisable(
712
+ ctxHolder: ContextHolder,
713
+ store: SqliteAdapter,
714
+ id: string,
715
+ req: Request,
716
+ deps: SessionsRouteDeps = {}
717
+ ): Promise<Response> {
718
+ const refused = localOnly(req, deps, "Disabling automation");
719
+ if (refused) return refused;
720
+ const parsed = await readObjectBody(req);
721
+ if (!parsed.ok) return parsed.res;
722
+ const { body } = parsed;
723
+ const extra = unknownKeys(body, DISABLE_KEYS);
724
+ if (extra) return extra;
725
+ for (const key of DISABLE_KEYS) {
726
+ if (body[key] !== undefined && typeof body[key] !== "boolean") {
727
+ return invalid(`${key} must be a boolean`);
728
+ }
729
+ }
730
+ try {
731
+ const change = await disableAutomation(
732
+ await automationContext(ctxHolder),
733
+ id,
734
+ {
735
+ ...(body.hook === true ? { hook: true } : {}),
736
+ ...(body.schedule === true ? { schedule: true } : {}),
737
+ }
738
+ );
739
+ await adoptSavedConfig(ctxHolder, store);
740
+ return Response.json(change);
741
+ } catch (error) {
742
+ return sessionsErrorResponse(error);
743
+ }
744
+ }
745
+
746
+ /** DELETE /api/sessions/automation/:id (same-host only; archive retained). */
747
+ export async function handleSessionsAutomationRemove(
748
+ ctxHolder: ContextHolder,
749
+ store: SqliteAdapter,
750
+ id: string,
751
+ req: Request,
752
+ deps: SessionsRouteDeps = {}
753
+ ): Promise<Response> {
754
+ const refused = localOnly(req, deps, "Removing an automation profile");
755
+ if (refused) return refused;
756
+ try {
757
+ const change = await removeAutomationProfile(
758
+ await automationContext(ctxHolder),
759
+ id
760
+ );
761
+ await adoptSavedConfig(ctxHolder, store);
762
+ return Response.json(change);
763
+ } catch (error) {
764
+ return sessionsErrorResponse(error);
765
+ }
766
+ }