@gmickel/gno 2.5.1 → 2.7.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 (160) hide show
  1. package/README.md +60 -5
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +80 -4
  4. package/assets/skill/cli-reference.md +132 -2
  5. package/assets/skill/examples.md +30 -0
  6. package/assets/skill/mcp-reference.md +54 -1
  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.5.1.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.7.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 +449 -35
  17. package/spec/mcp.md +234 -4
  18. package/spec/output-schemas/ask.schema.json +1 -1
  19. package/spec/output-schemas/capture-receipt.schema.json +4 -1
  20. package/spec/output-schemas/doctor.schema.json +88 -0
  21. package/spec/output-schemas/error.schema.json +11 -2
  22. package/spec/output-schemas/get.schema.json +1 -1
  23. package/spec/output-schemas/mcp-capture-result.schema.json +4 -2
  24. package/spec/output-schemas/memory-remember.schema.json +10 -4
  25. package/spec/output-schemas/multi-get.schema.json +4 -1
  26. package/spec/output-schemas/peek.schema.json +2 -9
  27. package/spec/output-schemas/request-status.schema.json +113 -0
  28. package/spec/output-schemas/resident-status.schema.json +22 -0
  29. package/spec/output-schemas/search-result.schema.json +1 -1
  30. package/spec/output-schemas/search-results.schema.json +1 -1
  31. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  32. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  33. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  34. package/spec/output-schemas/sessions-status.schema.json +432 -0
  35. package/spec/output-schemas/status.schema.json +98 -0
  36. package/src/cli/commands/ask.ts +14 -2
  37. package/src/cli/commands/capture.ts +55 -96
  38. package/src/cli/commands/daemon.ts +41 -0
  39. package/src/cli/commands/doctor.ts +54 -20
  40. package/src/cli/commands/embed.ts +41 -3
  41. package/src/cli/commands/ls.ts +3 -0
  42. package/src/cli/commands/memory.ts +12 -3
  43. package/src/cli/commands/query.ts +5 -0
  44. package/src/cli/commands/request-status.ts +59 -0
  45. package/src/cli/commands/reset.ts +39 -5
  46. package/src/cli/commands/sessions.ts +713 -0
  47. package/src/cli/commands/shared.ts +14 -1
  48. package/src/cli/commands/status.ts +63 -5
  49. package/src/cli/commands/vec.ts +54 -0
  50. package/src/cli/detach.ts +29 -1
  51. package/src/cli/errors.ts +13 -9
  52. package/src/cli/program.ts +441 -2
  53. package/src/cli/session-binding.ts +49 -0
  54. package/src/config/types.ts +8 -0
  55. package/src/core/capture-publish.ts +239 -0
  56. package/src/core/capture-sync.ts +12 -2
  57. package/src/core/host-paths.ts +31 -0
  58. package/src/core/memory-remember.ts +234 -122
  59. package/src/core/memory-types.ts +11 -0
  60. package/src/core/network-boundary-inventory.ts +8 -0
  61. package/src/core/request-receipts.ts +671 -0
  62. package/src/core/shutdown-budget.ts +6 -0
  63. package/src/core/vector-partition-status.ts +52 -0
  64. package/src/embed/backlog.ts +124 -18
  65. package/src/embed/fingerprint.ts +6 -3
  66. package/src/embed/retry.ts +66 -27
  67. package/src/embed/variant-backlog.ts +15 -10
  68. package/src/embed/variant-retry.ts +31 -22
  69. package/src/index.ts +30 -2
  70. package/src/llm/native-worker/dispatcher.ts +2 -0
  71. package/src/llm/native-worker/embedding-identity.ts +42 -0
  72. package/src/llm/native-worker/protocol.ts +1 -0
  73. package/src/llm/types.ts +3 -0
  74. package/src/mcp/context.ts +17 -0
  75. package/src/mcp/http-egress.ts +4 -0
  76. package/src/mcp/http-transport.ts +2 -0
  77. package/src/mcp/resources/index.ts +6 -5
  78. package/src/mcp/tool-descriptions-core.ts +1 -1
  79. package/src/mcp/tools/capture.ts +87 -85
  80. package/src/mcp/tools/index.ts +77 -4
  81. package/src/mcp/tools/memory-remember.ts +8 -1
  82. package/src/mcp/tools/memory-shared.ts +7 -1
  83. package/src/mcp/tools/request-status.ts +73 -0
  84. package/src/mcp/tools/sessions.ts +208 -0
  85. package/src/mcp/tools/status.ts +4 -0
  86. package/src/pipeline/hybrid.ts +37 -7
  87. package/src/pipeline/vsearch.ts +14 -2
  88. package/src/sdk/client.ts +180 -84
  89. package/src/sdk/index.ts +6 -0
  90. package/src/sdk/types.ts +54 -2
  91. package/src/serve/capture-service.ts +98 -32
  92. package/src/serve/config-sync.ts +3 -2
  93. package/src/serve/embed-scheduler.ts +133 -19
  94. package/src/serve/host-path-redaction.ts +79 -0
  95. package/src/serve/public/app.tsx +4 -1
  96. package/src/serve/public/components/CaptureModal.tsx +26 -8
  97. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  98. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  99. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  100. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  101. package/src/serve/public/components/sessions/api.ts +40 -0
  102. package/src/serve/public/globals.built.css +1 -1
  103. package/src/serve/public/hooks/use-api.ts +26 -3
  104. package/src/serve/public/lib/request-intent.ts +77 -0
  105. package/src/serve/public/lib/snippet.tsx +52 -0
  106. package/src/serve/public/lib/workspace-actions.ts +12 -1
  107. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  108. package/src/serve/public/pages/Dashboard.tsx +22 -9
  109. package/src/serve/public/pages/DocView.tsx +25 -6
  110. package/src/serve/public/pages/DocumentEditor.tsx +224 -104
  111. package/src/serve/public/pages/Search.tsx +1 -41
  112. package/src/serve/public/pages/Sessions.tsx +350 -0
  113. package/src/serve/resident-runtime.ts +69 -4
  114. package/src/serve/resident-status.ts +13 -1
  115. package/src/serve/routes/api.ts +476 -147
  116. package/src/serve/routes/sessions.ts +766 -0
  117. package/src/serve/security.ts +9 -0
  118. package/src/serve/server.ts +215 -10
  119. package/src/serve/session-automation.ts +146 -0
  120. package/src/serve/status-model.ts +16 -0
  121. package/src/serve/status.ts +2 -0
  122. package/src/serve/watch-reconciliation-shared.ts +3 -0
  123. package/src/serve/watch-service-events.ts +3 -2
  124. package/src/serve/watch-service-run-flush.ts +35 -2
  125. package/src/serve/watch-service.ts +5 -0
  126. package/src/sessions/archive.ts +348 -0
  127. package/src/sessions/automation-state.ts +444 -0
  128. package/src/sessions/automation-status.ts +239 -0
  129. package/src/sessions/automation.ts +1169 -0
  130. package/src/sessions/binding.ts +105 -0
  131. package/src/sessions/claude-hook.ts +240 -0
  132. package/src/sessions/config.ts +176 -0
  133. package/src/sessions/format.ts +191 -0
  134. package/src/sessions/import-child-env.ts +8 -0
  135. package/src/sessions/import-child.ts +152 -0
  136. package/src/sessions/parsers/claude-code.ts +259 -0
  137. package/src/sessions/parsers/codex.ts +303 -0
  138. package/src/sessions/parsers/hermes.ts +248 -0
  139. package/src/sessions/parsers/openclaw.ts +496 -0
  140. package/src/sessions/parsers/shared.ts +184 -0
  141. package/src/sessions/sanitize.ts +222 -0
  142. package/src/sessions/service.ts +1533 -0
  143. package/src/sessions/setup.ts +477 -0
  144. package/src/sessions/sources.ts +518 -0
  145. package/src/sessions/state.ts +118 -0
  146. package/src/sessions/types.ts +457 -0
  147. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  148. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  149. package/src/store/migrations/index.ts +4 -0
  150. package/src/store/sqlite/adapter.ts +76 -16
  151. package/src/store/sqlite/scoped-index.ts +9 -0
  152. package/src/store/types.ts +11 -1
  153. package/src/store/vector/lazy.ts +46 -43
  154. package/src/store/vector/runtime-compat.ts +651 -0
  155. package/src/store/vector/sqlite-vec.ts +20 -2
  156. package/src/store/vector/status.ts +276 -35
  157. package/src/store/vector/types.ts +2 -0
  158. package/src/store/vector/variant-search.ts +71 -23
  159. package/src/store/vector/variants.ts +49 -14
  160. package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Session-archive setup: the dedicated archive config/index pair and the
3
+ * owner-registered sources. Owner-local surfaces only (CLI, SDK, same-host
4
+ * REST); nothing here imports.
5
+ *
6
+ * @module src/sessions/setup
7
+ */
8
+
9
+ // node:fs/promises mkdir: directory creation has no Bun equivalent.
10
+ import { mkdir } from "node:fs/promises";
11
+ // node:path: no Bun path utilities.
12
+ import { dirname, isAbsolute, join, resolve } from "node:path";
13
+
14
+ import type { Collection, Config } from "../config/types";
15
+ import type { SessionSourceConfig, SessionsConfig } from "./config";
16
+
17
+ import { getIndexDbPath, resolveDirs } from "../app/constants";
18
+ import { canonicalizeIndexName, isValidIndexName } from "../app/index-name";
19
+ import { createDefaultConfig, getConfigPaths, loadConfig } from "../config";
20
+ import { applyConfigFileChange } from "../core/config-mutation";
21
+ import { SqliteAdapter } from "../store/sqlite/adapter";
22
+ import {
23
+ SESSION_ARCHIVE_FIELD_MAPPING,
24
+ SESSION_STATE_DIRNAME,
25
+ } from "./archive";
26
+ import {
27
+ canonicalConfigPath,
28
+ readIndexBinding,
29
+ writeIndexBinding,
30
+ } from "./binding";
31
+ import {
32
+ assertNotFilesystemRoot,
33
+ assertNotFilesystemRootAnyForm,
34
+ assertSafeSourceRoot,
35
+ canonicalPath,
36
+ isWithin,
37
+ } from "./sources";
38
+ import { SESSION_HARNESSES, type SessionHarness, SessionsError } from "./types";
39
+
40
+ /** GNO-owned directories plus the archive: never readable as a source. */
41
+ export function protectedRoots(sessions: SessionsConfig | undefined): string[] {
42
+ const dirs = resolveDirs();
43
+ const roots = [dirs.config, dirs.data, dirs.cache];
44
+ if (sessions) roots.push(sessions.archiveRoot);
45
+ return roots;
46
+ }
47
+
48
+ export function requireSessionsConfig(config: Config): SessionsConfig {
49
+ if (!config.sessions) {
50
+ throw new SessionsError(
51
+ "SESSIONS_NOT_CONFIGURED",
52
+ "No session archive is configured for this config. Create one with: gno --config <archive.yml> --index <name> sessions init --archive <dir> --collection <name>"
53
+ );
54
+ }
55
+ return config.sessions;
56
+ }
57
+
58
+ export function archiveCollection(
59
+ config: Config,
60
+ sessions: SessionsConfig,
61
+ name: string
62
+ ): Collection {
63
+ const collection = config.collections.find((item) => item.name === name);
64
+ if (
65
+ !collection ||
66
+ resolve(collection.path) !== resolve(join(sessions.archiveRoot, name))
67
+ ) {
68
+ throw new SessionsError(
69
+ "SESSIONS_UNKNOWN_COLLECTION",
70
+ `Collection "${name}" is not an archive collection of this session archive. Add it with: gno sessions init --archive <dir> --collection ${name}`
71
+ );
72
+ }
73
+ return collection;
74
+ }
75
+
76
+ /**
77
+ * Refuse an archive inside a folder the default (curated) config already
78
+ * indexes: plain default-config sync would otherwise ingest the archive.
79
+ */
80
+ async function assertOutsideCuratedCollections(
81
+ archiveRoot: string
82
+ ): Promise<void> {
83
+ const path = getConfigPaths().configFile;
84
+ if (!(await Bun.file(path).exists())) return;
85
+ const loaded = await loadConfig(path);
86
+ if (!loaded.ok) return;
87
+ for (const collection of loaded.value.collections) {
88
+ const root =
89
+ (await canonicalPath(collection.path)) ?? resolve(collection.path);
90
+ if (isWithin(root, archiveRoot)) {
91
+ throw new SessionsError(
92
+ "SESSIONS_UNSAFE_PATH",
93
+ `The archive would sit inside collection "${collection.name}" of the default config, whose sync would ingest it; choose a directory outside it.`
94
+ );
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Check the index can be bound to this archive config, run `commit` (the
101
+ * config change), and record the binding only once it succeeds, so a failed
102
+ * init never leaves an index bound to a config that is not an archive. The
103
+ * check runs first so an index in use refuses before the config is written.
104
+ */
105
+ async function bindArchiveIndex<T extends { ok: boolean }>(
106
+ configPath: string,
107
+ indexName: string,
108
+ archiveRoot: string,
109
+ commit: () => Promise<T>
110
+ ): Promise<T> {
111
+ const existing = (await Bun.file(configPath).exists())
112
+ ? await loadConfig(configPath)
113
+ : null;
114
+ if (existing && !existing.ok) {
115
+ throw new SessionsError("SESSIONS_INVALID_INPUT", existing.error.message);
116
+ }
117
+ const config = existing?.ok ? existing.value : createDefaultConfig();
118
+ const dbPath = getIndexDbPath(indexName);
119
+ await mkdir(dirname(dbPath), { recursive: true });
120
+ const store = new SqliteAdapter();
121
+ store.setConfigPath(configPath);
122
+ const opened = await store.open(
123
+ dbPath,
124
+ config.ftsTokenizer,
125
+ config.busyTimeoutMs
126
+ );
127
+ if (!opened.ok) {
128
+ throw new SessionsError("SESSIONS_INVALID_INPUT", opened.error.message);
129
+ }
130
+ try {
131
+ const db = store.getRawDb();
132
+ const foreign = db
133
+ .query<{ path: string }, []>("SELECT path FROM collections")
134
+ .all()
135
+ .filter((row) => !isWithin(archiveRoot, resolve(row.path)));
136
+ const canonical = await canonicalConfigPath(configPath);
137
+ const marker = readIndexBinding(dbPath);
138
+ if (foreign.length > 0 || (marker !== null && marker !== canonical)) {
139
+ throw new SessionsError(
140
+ "SESSIONS_BINDING_MISMATCH",
141
+ `Index "${indexName}" already holds other collections or belongs to another archive; choose a new index name for the session archive.`
142
+ );
143
+ }
144
+ const result = await commit();
145
+ if (result.ok) writeIndexBinding(db, canonical);
146
+ return result;
147
+ } finally {
148
+ await store.close();
149
+ }
150
+ }
151
+
152
+ export interface InitArchiveInput {
153
+ configPath: string;
154
+ indexName: string;
155
+ archiveRoot: string;
156
+ collection: string;
157
+ }
158
+
159
+ function assertDedicatedPair(configPath: string, indexName: string): void {
160
+ if (resolve(configPath) === resolve(getConfigPaths().configFile)) {
161
+ throw new SessionsError(
162
+ "SESSIONS_INVALID_INPUT",
163
+ "Session archives use a dedicated config file; pass --config <archive.yml> instead of the default config."
164
+ );
165
+ }
166
+ if (
167
+ !isValidIndexName(indexName) ||
168
+ canonicalizeIndexName(indexName) === "default"
169
+ ) {
170
+ throw new SessionsError(
171
+ "SESSIONS_INVALID_INPUT",
172
+ "Session archives use a dedicated named index; pass --index <name> (not default)."
173
+ );
174
+ }
175
+ }
176
+
177
+ function archiveCollectionDefinition(
178
+ archiveRoot: string,
179
+ name: string
180
+ ): Collection {
181
+ return {
182
+ name,
183
+ path: join(archiveRoot, name),
184
+ pattern: "**/*.jsonl",
185
+ include: [],
186
+ exclude: [SESSION_STATE_DIRNAME],
187
+ recordAdapters: {
188
+ jsonl: { fieldMapping: SESSION_ARCHIVE_FIELD_MAPPING },
189
+ },
190
+ } as Collection;
191
+ }
192
+
193
+ /** Create or extend the dedicated archive config. Idempotent. */
194
+ export async function initSessionArchive(input: InitArchiveInput): Promise<{
195
+ config: Config;
196
+ archiveRoot: string;
197
+ created: boolean;
198
+ }> {
199
+ assertDedicatedPair(input.configPath, input.indexName);
200
+ if (!isAbsolute(input.archiveRoot)) {
201
+ throw new SessionsError(
202
+ "SESSIONS_INVALID_INPUT",
203
+ "--archive must be an absolute directory path."
204
+ );
205
+ }
206
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(input.collection)) {
207
+ throw new SessionsError(
208
+ "SESSIONS_INVALID_INPUT",
209
+ "Collection names are lowercase alphanumeric with hyphens/underscores, 1-64 chars."
210
+ );
211
+ }
212
+ // Before creating anything (named, linked or canonical), and again once
213
+ // the created directory resolves.
214
+ await assertNotFilesystemRootAnyForm(
215
+ input.archiveRoot,
216
+ "The session archive"
217
+ );
218
+ const insideGnoDirs = (path: string): boolean =>
219
+ protectedRoots(undefined).some((root) => isWithin(resolve(root), path));
220
+ if (!insideGnoDirs(resolve(input.archiveRoot))) {
221
+ await mkdir(input.archiveRoot, { recursive: true });
222
+ }
223
+ const archiveRoot =
224
+ (await canonicalPath(input.archiveRoot)) ?? resolve(input.archiveRoot);
225
+ assertNotFilesystemRoot(archiveRoot, "The session archive");
226
+ if (insideGnoDirs(archiveRoot)) {
227
+ throw new SessionsError(
228
+ "SESSIONS_UNSAFE_PATH",
229
+ "The archive must live outside GNO's config/data/cache directories, which reset and cleanup may remove."
230
+ );
231
+ }
232
+ await assertOutsideCuratedCollections(archiveRoot);
233
+ let created = false;
234
+ const result = await bindArchiveIndex(
235
+ input.configPath,
236
+ input.indexName,
237
+ archiveRoot,
238
+ () =>
239
+ applyConfigFileChange(
240
+ {
241
+ configPath: input.configPath,
242
+ createConfigIfMissing: () => {
243
+ created = true;
244
+ return createDefaultConfig();
245
+ },
246
+ },
247
+ (config) => {
248
+ const existing = config.sessions;
249
+ if (
250
+ existing &&
251
+ (existing.index !== input.indexName ||
252
+ resolve(existing.archiveRoot) !== archiveRoot)
253
+ ) {
254
+ return {
255
+ ok: false,
256
+ code: "SESSIONS_BINDING_MISMATCH",
257
+ error:
258
+ "This config is already bound to a different archive index or root; it is never retargeted silently.",
259
+ };
260
+ }
261
+ if (!existing) {
262
+ const unrelated = config.collections.filter(
263
+ (collection) => !isWithin(archiveRoot, resolve(collection.path))
264
+ );
265
+ if (unrelated.length > 0) {
266
+ return {
267
+ ok: false,
268
+ code: "SESSIONS_INVALID_INPUT",
269
+ error:
270
+ "This config already holds collections outside the archive; use a new dedicated config file for the session archive.",
271
+ };
272
+ }
273
+ }
274
+ const sessions: SessionsConfig = existing ?? {
275
+ index: input.indexName,
276
+ archiveRoot,
277
+ sources: [],
278
+ };
279
+ const collections = [...config.collections];
280
+ const current = collections.find(
281
+ (item) => item.name === input.collection
282
+ );
283
+ if (current) {
284
+ if (
285
+ resolve(current.path) !==
286
+ resolve(join(archiveRoot, input.collection))
287
+ ) {
288
+ return {
289
+ ok: false,
290
+ code: "SESSIONS_INVALID_INPUT",
291
+ error: `Collection "${input.collection}" already exists with a different path.`,
292
+ };
293
+ }
294
+ } else {
295
+ collections.push(
296
+ archiveCollectionDefinition(archiveRoot, input.collection)
297
+ );
298
+ }
299
+ return { ok: true, config: { ...config, sessions, collections } };
300
+ }
301
+ )
302
+ );
303
+ if (!result.ok) {
304
+ throw new SessionsError(
305
+ result.code === "SESSIONS_BINDING_MISMATCH"
306
+ ? "SESSIONS_BINDING_MISMATCH"
307
+ : "SESSIONS_INVALID_INPUT",
308
+ result.error
309
+ );
310
+ }
311
+ await mkdir(join(archiveRoot, input.collection), { recursive: true });
312
+ return { config: result.config, archiveRoot, created };
313
+ }
314
+
315
+ export interface AddSourceInput {
316
+ configPath: string;
317
+ id: string;
318
+ harness: SessionHarness;
319
+ path: string;
320
+ collection: string;
321
+ projects?: Array<{ prefix: string; collection: string }>;
322
+ }
323
+
324
+ /** Register (or confirm) an owner source. Creates missing archive collections. */
325
+ export async function addSessionSource(input: AddSourceInput): Promise<Config> {
326
+ if (!SESSION_HARNESSES.includes(input.harness)) {
327
+ throw new SessionsError(
328
+ "SESSIONS_UNSUPPORTED_FORMAT",
329
+ `Unsupported harness "${String(input.harness)}". Supported: ${SESSION_HARNESSES.join(", ")}.`
330
+ );
331
+ }
332
+ if (!isAbsolute(input.path)) {
333
+ throw new SessionsError(
334
+ "SESSIONS_INVALID_INPUT",
335
+ "Source path must be absolute."
336
+ );
337
+ }
338
+ await assertNotFilesystemRootAnyForm(input.path, "A session source");
339
+ const canonical = await canonicalPath(input.path);
340
+ if (!canonical) {
341
+ throw new SessionsError(
342
+ "SESSIONS_SOURCE_UNAVAILABLE",
343
+ "The source path does not exist or is not readable."
344
+ );
345
+ }
346
+ assertNotFilesystemRoot(canonical, "A session source");
347
+ const result = await applyConfigFileChange(
348
+ { configPath: input.configPath },
349
+ (config) => {
350
+ const sessions = config.sessions;
351
+ if (!sessions) {
352
+ return {
353
+ ok: false,
354
+ code: "SESSIONS_NOT_CONFIGURED",
355
+ error: "Run gno sessions init before registering sources.",
356
+ };
357
+ }
358
+ try {
359
+ assertSafeSourceRoot(canonical, protectedRoots(sessions));
360
+ } catch (error) {
361
+ return {
362
+ ok: false,
363
+ code: "SESSIONS_UNSAFE_PATH",
364
+ error: (error as Error).message,
365
+ };
366
+ }
367
+ const source: SessionSourceConfig = {
368
+ id: input.id,
369
+ harness: input.harness,
370
+ path: canonical,
371
+ collection: input.collection,
372
+ ...(input.projects && input.projects.length > 0
373
+ ? { projects: input.projects }
374
+ : {}),
375
+ };
376
+ const existing = sessions.sources.find((item) => item.id === input.id);
377
+ if (existing && JSON.stringify(existing) !== JSON.stringify(source)) {
378
+ return {
379
+ ok: false,
380
+ code: "SESSIONS_INVALID_INPUT",
381
+ error: `Source "${input.id}" is already registered with different settings; remove it first.`,
382
+ };
383
+ }
384
+ const collections = [...config.collections];
385
+ const needed = new Set([
386
+ input.collection,
387
+ ...(input.projects ?? []).map((mapping) => mapping.collection),
388
+ ]);
389
+ for (const name of needed) {
390
+ const current = collections.find((item) => item.name === name);
391
+ if (!current) {
392
+ collections.push(
393
+ archiveCollectionDefinition(sessions.archiveRoot, name)
394
+ );
395
+ } else if (
396
+ resolve(current.path) !== resolve(join(sessions.archiveRoot, name))
397
+ ) {
398
+ return {
399
+ ok: false,
400
+ code: "SESSIONS_UNKNOWN_COLLECTION",
401
+ error: `Collection "${name}" is not an archive collection of this archive.`,
402
+ };
403
+ }
404
+ }
405
+ return {
406
+ ok: true,
407
+ config: {
408
+ ...config,
409
+ collections,
410
+ sessions: {
411
+ ...sessions,
412
+ sources: existing
413
+ ? sessions.sources
414
+ : [...sessions.sources, source],
415
+ },
416
+ },
417
+ };
418
+ }
419
+ );
420
+ if (!result.ok) {
421
+ const code = [
422
+ "SESSIONS_NOT_CONFIGURED",
423
+ "SESSIONS_UNSAFE_PATH",
424
+ "SESSIONS_UNKNOWN_COLLECTION",
425
+ ].includes(result.code)
426
+ ? (result.code as "SESSIONS_NOT_CONFIGURED")
427
+ : "SESSIONS_INVALID_INPUT";
428
+ throw new SessionsError(code, result.error);
429
+ }
430
+ for (const collection of result.config.collections) {
431
+ if (
432
+ isWithin(result.config.sessions!.archiveRoot, resolve(collection.path))
433
+ ) {
434
+ await mkdir(collection.path, { recursive: true });
435
+ }
436
+ }
437
+ return result.config;
438
+ }
439
+
440
+ /** Unregister a source. Its archive files are retained. */
441
+ export async function removeSessionSource(input: {
442
+ configPath: string;
443
+ id: string;
444
+ }): Promise<Config> {
445
+ const result = await applyConfigFileChange(
446
+ { configPath: input.configPath },
447
+ (config) => {
448
+ const sessions = config.sessions;
449
+ if (!sessions?.sources.some((item) => item.id === input.id)) {
450
+ return {
451
+ ok: false,
452
+ code: "SESSIONS_UNKNOWN_SOURCE",
453
+ error: `Unknown session source "${input.id}".`,
454
+ };
455
+ }
456
+ return {
457
+ ok: true,
458
+ config: {
459
+ ...config,
460
+ sessions: {
461
+ ...sessions,
462
+ sources: sessions.sources.filter((item) => item.id !== input.id),
463
+ },
464
+ },
465
+ };
466
+ }
467
+ );
468
+ if (!result.ok) {
469
+ throw new SessionsError(
470
+ result.code === "SESSIONS_UNKNOWN_SOURCE"
471
+ ? "SESSIONS_UNKNOWN_SOURCE"
472
+ : "SESSIONS_INVALID_INPUT",
473
+ result.error
474
+ );
475
+ }
476
+ return result.config;
477
+ }