@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,105 @@
1
+ /**
2
+ * Archive config/index binding.
3
+ *
4
+ * A session archive is one dedicated config file paired with one named
5
+ * index. The config side records the pair in `sessions.index`; the index side
6
+ * records the canonical config path in `schema_meta`. Opening either half
7
+ * with the wrong partner fails instead of silently pulling archive
8
+ * collections into a curated index, or curated collections into the archive
9
+ * index.
10
+ *
11
+ * @module src/sessions/binding
12
+ */
13
+
14
+ import { Database } from "bun:sqlite";
15
+
16
+ // Configures the platform SQLite before any Database opens (macOS).
17
+ import "../store/sqlite/setup";
18
+ import type { Config } from "../config/types";
19
+
20
+ import { canonicalizeIndexName, isValidIndexName } from "../app/index-name";
21
+ import { canonicalOperationalPath } from "../core/config-write-lock";
22
+ import { SessionsError } from "./types";
23
+
24
+ /** `schema_meta` key naming the archive config an index is bound to. */
25
+ const SESSION_BINDING_META_KEY = "session_archive_config";
26
+
27
+ /**
28
+ * Canonical identity of a config path: the same resolution the config writer
29
+ * uses for its write target and lock. An existing file realpaths; a missing
30
+ * one (init checks the binding before writing it) resolves through its
31
+ * nearest existing ancestor, and a dangling symlink through its target, so
32
+ * the marker matches the path the file later realpaths to (e.g. macOS `/var`
33
+ * -> `/private/var`, Windows short or case variants).
34
+ */
35
+ export function canonicalConfigPath(configPath: string): Promise<string> {
36
+ return canonicalOperationalPath(configPath);
37
+ }
38
+
39
+ /** Read the binding marker of an index database without creating it. */
40
+ export function readIndexBinding(dbPath: string): string | null {
41
+ let db: Database | undefined;
42
+ try {
43
+ db = new Database(dbPath, { readonly: true });
44
+ const table = db
45
+ .query<{ name: string }, []>(
46
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_meta'"
47
+ )
48
+ .get();
49
+ if (!table) return null;
50
+ const row = db
51
+ .query<{ value: string }, [string]>(
52
+ "SELECT value FROM schema_meta WHERE key = ?"
53
+ )
54
+ .get(SESSION_BINDING_META_KEY);
55
+ return row?.value ?? null;
56
+ } catch {
57
+ return null;
58
+ } finally {
59
+ db?.close();
60
+ }
61
+ }
62
+
63
+ /** Record the archive binding on an index the caller already opened. */
64
+ export function writeIndexBinding(db: Database, configPath: string): void {
65
+ db.run(
66
+ `INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, datetime('now'))
67
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
68
+ [SESSION_BINDING_META_KEY, configPath]
69
+ );
70
+ }
71
+
72
+ function sameIndex(left: string, right: string): boolean {
73
+ if (!(isValidIndexName(left) && isValidIndexName(right)))
74
+ return left === right;
75
+ return canonicalizeIndexName(left) === canonicalizeIndexName(right);
76
+ }
77
+
78
+ /**
79
+ * Fail when the config/index pair contradicts a recorded archive binding.
80
+ * `dbPath` may point to a database that does not exist yet.
81
+ */
82
+ export async function assertSessionBinding(options: {
83
+ config: Config;
84
+ configPath: string;
85
+ indexName: string;
86
+ dbPath: string;
87
+ }): Promise<void> {
88
+ const bound = options.config.sessions?.index;
89
+ if (bound !== undefined && !sameIndex(bound, options.indexName)) {
90
+ throw new SessionsError(
91
+ "SESSIONS_BINDING_MISMATCH",
92
+ `This config is a session archive config bound to index "${bound}", but index "${options.indexName}" was selected. Pass --index ${bound} together with this --config.`
93
+ );
94
+ }
95
+ if (!(await Bun.file(options.dbPath).exists())) return;
96
+ const marker = readIndexBinding(options.dbPath);
97
+ if (marker === null) return;
98
+ const configPath = await canonicalConfigPath(options.configPath);
99
+ if (marker !== configPath) {
100
+ throw new SessionsError(
101
+ "SESSIONS_BINDING_MISMATCH",
102
+ `Index "${options.indexName}" is a session archive bound to a different config file. Pass the archive's --config with --index ${options.indexName}; curated configs cannot open it.`
103
+ );
104
+ }
105
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Claude Code SessionEnd hook: owned-entry install, removal and inspection.
3
+ *
4
+ * GNO edits only the one hook entry it owns in a Claude Code settings file.
5
+ * Ownership is the command itself: it runs `sessions hook claude-code` for
6
+ * one archive config and profile, so foreign hooks (including other GNO
7
+ * profiles) are never touched. A settings file that is not valid JSON, or
8
+ * whose `hooks` shape is unexpected, is left unchanged. Writes keep a `.bak`
9
+ * copy and replace the file atomically (the MCP installer's contract).
10
+ *
11
+ * @module src/sessions/claude-hook
12
+ */
13
+
14
+ // node:os homedir: no Bun equivalent.
15
+ import { homedir } from "node:os";
16
+ // node:path: no Bun path utilities.
17
+ import { join, resolve } from "node:path";
18
+
19
+ import { resolveDirs } from "../app/constants";
20
+ import {
21
+ readMcpConfigText,
22
+ writeMcpConfigText,
23
+ } from "../cli/commands/mcp/config";
24
+ import { findBunPath } from "../cli/commands/mcp/paths";
25
+ import { getCurrentGnoEntrypoint } from "../core/runtime-entrypoint";
26
+ import { canonicalConfigPath } from "./binding";
27
+ import { SessionsError } from "./types";
28
+
29
+ /**
30
+ * Ownership is keyed on the canonical archive config path, so a symlinked
31
+ * path (for example macOS `/var` -> `/private/var`) matches its entry.
32
+ */
33
+ async function canonicalIdentity(
34
+ identity: HookIdentity
35
+ ): Promise<HookIdentity> {
36
+ return {
37
+ ...identity,
38
+ configPath: await canonicalConfigPath(identity.configPath),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Claude Code's hook `timeout` (seconds). SessionEnd hooks otherwise share a
44
+ * 1.5 s budget; the hook itself returns after at most the admission deadline.
45
+ */
46
+ export const CLAUDE_HOOK_TIMEOUT_SECONDS = 5;
47
+
48
+ export interface HookIdentity {
49
+ configPath: string;
50
+ indexName: string;
51
+ profileId: string;
52
+ }
53
+
54
+ /** `$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`. */
55
+ export function defaultClaudeSettingsPath(
56
+ env: NodeJS.ProcessEnv = process.env
57
+ ): string {
58
+ const dir = env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
59
+ return join(resolve(dir), "settings.json");
60
+ }
61
+
62
+ const quote = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'`;
63
+
64
+ const ownedSuffix = (identity: HookIdentity): string =>
65
+ `sessions hook claude-code --profile ${quote(identity.profileId)}`;
66
+
67
+ /**
68
+ * The absolute command the hook runs: this GNO runtime, its data/cache
69
+ * directories, and the archive config/index pair, so neither PATH nor the
70
+ * session's working directory can redirect it.
71
+ */
72
+ export function buildClaudeHookCommand(identity: HookIdentity): string {
73
+ const dirs = resolveDirs();
74
+ return [
75
+ `GNO_DATA_DIR=${quote(resolve(dirs.data))}`,
76
+ `GNO_CACHE_DIR=${quote(resolve(dirs.cache))}`,
77
+ quote(findBunPath()),
78
+ "run",
79
+ quote(getCurrentGnoEntrypoint()),
80
+ "--config",
81
+ quote(resolve(identity.configPath)),
82
+ "--index",
83
+ quote(identity.indexName),
84
+ ownedSuffix(identity),
85
+ ].join(" ");
86
+ }
87
+
88
+ /** Owned by this config and profile, whatever runtime path it was built with. */
89
+ export function isOwnedHookCommand(
90
+ command: unknown,
91
+ identity: HookIdentity
92
+ ): boolean {
93
+ return (
94
+ typeof command === "string" &&
95
+ command.includes(`--config ${quote(resolve(identity.configPath))} `) &&
96
+ command.endsWith(ownedSuffix(identity))
97
+ );
98
+ }
99
+
100
+ type Json = Record<string, unknown>;
101
+
102
+ const isObject = (value: unknown): value is Json =>
103
+ typeof value === "object" && value !== null && !Array.isArray(value);
104
+
105
+ const unexpected = (path: string, detail: string): SessionsError =>
106
+ new SessionsError(
107
+ "SESSIONS_INVALID_INPUT",
108
+ `Claude Code settings ${path} ${detail}; nothing was changed. Fix the file, then retry.`
109
+ );
110
+
111
+ async function readSettings(path: string): Promise<Json | null> {
112
+ const text = await readMcpConfigText(path, { returnNullOnMissing: true });
113
+ if (text === null) return null;
114
+ if (!text.trim()) return {};
115
+ let parsed: unknown;
116
+ try {
117
+ parsed = JSON.parse(text);
118
+ } catch {
119
+ throw unexpected(path, "is not valid JSON");
120
+ }
121
+ if (!isObject(parsed)) throw unexpected(path, "is not a JSON object");
122
+ return parsed;
123
+ }
124
+
125
+ /** SessionEnd groups, validated; null when the file has none. */
126
+ function sessionEndGroups(settings: Json, path: string): Json[] | null {
127
+ const hooks = settings.hooks;
128
+ if (hooks === undefined) return null;
129
+ if (!isObject(hooks)) throw unexpected(path, "has a non-object hooks value");
130
+ const groups = hooks.SessionEnd;
131
+ if (groups === undefined) return null;
132
+ if (!Array.isArray(groups) || !groups.every(isObject)) {
133
+ throw unexpected(path, "has an unexpected hooks.SessionEnd shape");
134
+ }
135
+ return groups;
136
+ }
137
+
138
+ /** Drop owned hooks; groups left empty by that are dropped too. */
139
+ function withoutOwned(groups: Json[], identity: HookIdentity): Json[] {
140
+ const kept: Json[] = [];
141
+ for (const group of groups) {
142
+ const hooks = Array.isArray(group.hooks) ? group.hooks : null;
143
+ if (!hooks) {
144
+ kept.push(group);
145
+ continue;
146
+ }
147
+ const remaining = hooks.filter(
148
+ (hook) => !(isObject(hook) && isOwnedHookCommand(hook.command, identity))
149
+ );
150
+ if (remaining.length === hooks.length) kept.push(group);
151
+ else if (remaining.length > 0) kept.push({ ...group, hooks: remaining });
152
+ }
153
+ return kept;
154
+ }
155
+
156
+ const countOwned = (groups: Json[], identity: HookIdentity): number =>
157
+ groups
158
+ .flatMap((group) => (Array.isArray(group.hooks) ? group.hooks : []))
159
+ .filter(
160
+ (hook) => isObject(hook) && isOwnedHookCommand(hook.command, identity)
161
+ ).length;
162
+
163
+ /** Whether the owned entry is present; null when the file cannot be read. */
164
+ export async function inspectClaudeHook(
165
+ settingsPath: string,
166
+ given: HookIdentity
167
+ ): Promise<boolean | null> {
168
+ const identity = await canonicalIdentity(given);
169
+ try {
170
+ const settings = await readSettings(settingsPath);
171
+ if (!settings) return false;
172
+ return (
173
+ countOwned(sessionEndGroups(settings, settingsPath) ?? [], identity) > 0
174
+ );
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /** Install (or repair) exactly one owned SessionEnd entry. */
181
+ export async function installClaudeHook(
182
+ settingsPath: string,
183
+ given: HookIdentity
184
+ ): Promise<{ command: string; changed: boolean }> {
185
+ const identity = await canonicalIdentity(given);
186
+ const settings = (await readSettings(settingsPath)) ?? {};
187
+ const groups = sessionEndGroups(settings, settingsPath) ?? [];
188
+ const command = buildClaudeHookCommand(identity);
189
+ const current = groups.some(
190
+ (group) =>
191
+ Array.isArray(group.hooks) &&
192
+ group.hooks.some(
193
+ (hook) =>
194
+ isObject(hook) &&
195
+ hook.command === command &&
196
+ hook.timeout === CLAUDE_HOOK_TIMEOUT_SECONDS
197
+ )
198
+ );
199
+ if (current && countOwned(groups, identity) === 1) {
200
+ return { command, changed: false };
201
+ }
202
+ const next = [
203
+ ...withoutOwned(groups, identity),
204
+ {
205
+ hooks: [
206
+ { type: "command", command, timeout: CLAUDE_HOOK_TIMEOUT_SECONDS },
207
+ ],
208
+ },
209
+ ];
210
+ const hooks = isObject(settings.hooks) ? settings.hooks : {};
211
+ settings.hooks = { ...hooks, SessionEnd: next };
212
+ await writeMcpConfigText(
213
+ settingsPath,
214
+ `${JSON.stringify(settings, null, 2)}\n`
215
+ );
216
+ return { command, changed: true };
217
+ }
218
+
219
+ /** Remove owned entries only. A missing file or entry is not an error. */
220
+ export async function removeClaudeHook(
221
+ settingsPath: string,
222
+ given: HookIdentity
223
+ ): Promise<{ removed: number }> {
224
+ const identity = await canonicalIdentity(given);
225
+ const settings = await readSettings(settingsPath);
226
+ if (!settings) return { removed: 0 };
227
+ const groups = sessionEndGroups(settings, settingsPath);
228
+ if (!groups) return { removed: 0 };
229
+ const removed = countOwned(groups, identity);
230
+ if (removed === 0) return { removed: 0 };
231
+ const hooks = settings.hooks as Json;
232
+ const next = withoutOwned(groups, identity);
233
+ if (next.length > 0) hooks.SessionEnd = next;
234
+ else delete hooks.SessionEnd;
235
+ await writeMcpConfigText(
236
+ settingsPath,
237
+ `${JSON.stringify(settings, null, 2)}\n`
238
+ );
239
+ return { removed };
240
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `sessions` block of a dedicated session-archive configuration file.
3
+ *
4
+ * The block binds the config file to one named index and one archive root,
5
+ * and lists owner-registered sources. It only ever appears in the archive
6
+ * config; the curated default config never carries archive collections.
7
+ *
8
+ * @module src/sessions/config
9
+ */
10
+
11
+ // node:path isAbsolute: no Bun path utilities.
12
+ import { isAbsolute, relative, resolve } from "node:path";
13
+ import { z } from "zod";
14
+
15
+ import type { Collection, Config } from "../config/types";
16
+
17
+ import { MAX_IMPORT_LIMIT, SESSION_HARNESSES } from "./types";
18
+
19
+ const COLLECTION_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
20
+ const SOURCE_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
21
+
22
+ const AbsolutePathSchema = z
23
+ .string()
24
+ .min(1)
25
+ .refine((value) => isAbsolute(value), { message: "Path must be absolute" });
26
+
27
+ const SessionProjectMappingSchema = z
28
+ .object({
29
+ /** Working-directory prefix (absolute) this mapping applies to. */
30
+ prefix: AbsolutePathSchema,
31
+ /** Archive collection that receives threads recorded under the prefix. */
32
+ collection: z.string().regex(COLLECTION_NAME),
33
+ })
34
+ .strict();
35
+
36
+ export const SessionSourceSchema = z
37
+ .object({
38
+ id: z
39
+ .string()
40
+ .regex(
41
+ SOURCE_ID,
42
+ "Source ID must be lowercase alphanumeric with hyphens/underscores, 1-64 chars"
43
+ ),
44
+ harness: z.enum(SESSION_HARNESSES),
45
+ /** Absolute harness session root, file or database. */
46
+ path: AbsolutePathSchema,
47
+ /** Default archive collection for this source. */
48
+ collection: z.string().regex(COLLECTION_NAME),
49
+ /** Owner-approved working-directory mappings to other archive collections. */
50
+ projects: z.array(SessionProjectMappingSchema).max(64).optional(),
51
+ })
52
+ .strict();
53
+
54
+ /** Host hooks GNO can install. Only verified integrations are listed. */
55
+ export const SESSION_HOOK_HARNESSES = ["claude-code"] as const;
56
+ export type SessionHookHarness = (typeof SESSION_HOOK_HARNESSES)[number];
57
+
58
+ /**
59
+ * Owner-controlled automation profile. Every trigger is off until the owner
60
+ * enables it explicitly; nothing here is ever switched on by install,
61
+ * upgrade, repair or restart. Destinations and privacy routing stay on the
62
+ * referenced sources (collection + project mappings), never on the profile.
63
+ */
64
+ export const SessionAutomationProfileSchema = z
65
+ .object({
66
+ id: z
67
+ .string()
68
+ .regex(
69
+ SOURCE_ID,
70
+ "Profile ID must be lowercase alphanumeric with hyphens/underscores, 1-64 chars"
71
+ ),
72
+ /** Registered source IDs this profile imports. */
73
+ sources: z
74
+ .array(z.string().regex(SOURCE_ID))
75
+ .min(1)
76
+ .max(64)
77
+ .refine((ids) => new Set(ids).size === ids.length, {
78
+ message: "Profile sources must be unique",
79
+ }),
80
+ hook: z
81
+ .object({
82
+ harness: z.enum(SESSION_HOOK_HARNESSES),
83
+ enabled: z.boolean(),
84
+ /** Absolute host settings file that holds the owned hook entry. */
85
+ settings: AbsolutePathSchema,
86
+ })
87
+ .strict()
88
+ .optional(),
89
+ schedule: z
90
+ .object({
91
+ enabled: z.boolean(),
92
+ /** Elapsed cadence `<n>s|m|h|d`; validated by the automation module. */
93
+ cadence: z.string().min(2).max(8),
94
+ })
95
+ .strict()
96
+ .optional(),
97
+ /** Changed units imported per source per run (bounded work budget). */
98
+ limit: z.number().int().min(1).max(MAX_IMPORT_LIMIT).optional(),
99
+ /** Automatic retries after a failed run before waiting for a new trigger. */
100
+ retries: z.number().int().min(0).max(10).optional(),
101
+ })
102
+ .strict();
103
+
104
+ export type SessionAutomationProfile = z.infer<
105
+ typeof SessionAutomationProfileSchema
106
+ >;
107
+
108
+ export const SessionsConfigSchema = z
109
+ .object({
110
+ /** Index name this archive config is bound to. */
111
+ index: z.string().min(1).max(64),
112
+ /** Durable archive root (absolute). Collections live directly below it. */
113
+ archiveRoot: AbsolutePathSchema,
114
+ sources: z
115
+ .array(SessionSourceSchema)
116
+ .max(64)
117
+ .default([])
118
+ .superRefine((sources, ctx) => {
119
+ const ids = new Set<string>();
120
+ for (const [index, source] of sources.entries()) {
121
+ if (ids.has(source.id)) {
122
+ ctx.addIssue({
123
+ code: "custom",
124
+ message: "Session source IDs must be unique",
125
+ path: [index, "id"],
126
+ });
127
+ }
128
+ ids.add(source.id);
129
+ }
130
+ }),
131
+ redaction: z
132
+ .object({
133
+ /** Exact literals always redacted (e.g. internal hostnames, known keys). */
134
+ literals: z.array(z.string().min(4).max(512)).max(256).default([]),
135
+ })
136
+ .strict()
137
+ .optional(),
138
+ /** Opt-in automation profiles (hooks and daemon schedules). */
139
+ automation: z
140
+ .array(SessionAutomationProfileSchema)
141
+ .max(16)
142
+ .optional()
143
+ .superRefine((profiles, ctx) => {
144
+ const ids = new Set<string>();
145
+ for (const [index, profile] of (profiles ?? []).entries()) {
146
+ if (ids.has(profile.id)) {
147
+ ctx.addIssue({
148
+ code: "custom",
149
+ message: "Automation profile IDs must be unique",
150
+ path: [index, "id"],
151
+ });
152
+ }
153
+ ids.add(profile.id);
154
+ }
155
+ }),
156
+ })
157
+ .strict();
158
+
159
+ export type SessionsConfig = z.infer<typeof SessionsConfigSchema>;
160
+ export type SessionSourceConfig = z.infer<typeof SessionSourceSchema>;
161
+
162
+ /**
163
+ * Collections a filesystem watcher follows. Session archive collections are
164
+ * written and synced only by the importer; a watcher re-syncing them would
165
+ * duplicate that work on the server's event loop and contend for the index
166
+ * write lock with the import.
167
+ */
168
+ export function watchedCollections(config: Config): Collection[] {
169
+ const archiveRoot = config.sessions?.archiveRoot;
170
+ if (!archiveRoot) return config.collections;
171
+ const root = resolve(archiveRoot);
172
+ return config.collections.filter((collection) => {
173
+ const rel = relative(root, resolve(collection.path));
174
+ return rel.startsWith("..") || isAbsolute(rel);
175
+ });
176
+ }