@gajae-gateway/cli 0.1.3 → 0.1.5

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.
@@ -0,0 +1,284 @@
1
+ import { join } from "node:path";
2
+ import type { MonitorSpec, OriginRef } from "@gajae-gateway/protocol";
3
+ import {
4
+ type ChannelPlan,
5
+ cronMonitor,
6
+ deliveryOrigin,
7
+ emptyChannelPlan,
8
+ emptyExtract,
9
+ intervalCron,
10
+ MONITOR_INSTRUCTION_MAX,
11
+ type SourceExtract,
12
+ slug,
13
+ } from "./plan";
14
+ import { isDirectory, isRecord, parseDotenv, regularFile, type SourceTree, stringList, walkFiles } from "./source-fs";
15
+
16
+ /**
17
+ * OpenClaw is a gajae-way-compatible agent framework. Migration reads its
18
+ * config.json, .env, and skill/memory structures from an OpenClaw home.
19
+ */
20
+ export const OPENCLAW_CONFIG = "config.json";
21
+
22
+ export async function detectOpenClaw(root: string): Promise<boolean> {
23
+ const configPath = join(root, OPENCLAW_CONFIG);
24
+ const stat = await regularFile(configPath);
25
+ return stat !== undefined;
26
+ }
27
+
28
+ interface OpenClawConfig {
29
+ persona?: { name?: string };
30
+ skills?: Record<string, { enabled?: boolean }>;
31
+ memory?: Record<string, unknown>;
32
+ channels?: Record<string, unknown>;
33
+ monitors?: Record<string, unknown>;
34
+ }
35
+
36
+ export async function readOpenClaw(tree: SourceTree, _home: string): Promise<SourceExtract> {
37
+ const extract = emptyExtract("openclaw", tree.root);
38
+ const configPath = join(tree.root, OPENCLAW_CONFIG);
39
+ const envPath = join(tree.root, ".env");
40
+
41
+ // Read configuration
42
+ let config: OpenClawConfig = {};
43
+ const configText = await tree.text(configPath);
44
+ if (configText) {
45
+ try {
46
+ config = JSON.parse(configText) as OpenClawConfig;
47
+ } catch (e) {
48
+ extract.warnings.push(`Invalid config.json: ${String(e)}`);
49
+ }
50
+ }
51
+
52
+ // Read environment variables
53
+ const env = new Map<string, string>();
54
+ const envText = await tree.text(envPath);
55
+ if (envText) {
56
+ const parsed = parseDotenv(envText);
57
+ for (const [key, val] of parsed.entries()) {
58
+ env.set(key, val);
59
+ }
60
+ }
61
+
62
+ // Establish consumed sets for tracking unmapped entries
63
+ const consumedConfig = new Set<string>();
64
+ const consumedEnv = new Set<string>();
65
+
66
+ // Extract persona
67
+ if (config.persona?.name) {
68
+ extract.files.push({
69
+ category: "persona",
70
+ sources: ["config.persona.name"],
71
+ target: "persona.json",
72
+ content: JSON.stringify({ name: config.persona.name }),
73
+ });
74
+ consumedConfig.add("persona");
75
+ }
76
+
77
+ // Extract channel policies and credentials
78
+ const channels: ChannelPlan = emptyChannelPlan();
79
+ if (isRecord(config.channels)) {
80
+ readOpenClawChannels(config.channels, env, channels, consumedConfig, consumedEnv);
81
+ }
82
+
83
+ // Extract monitors
84
+ if (isRecord(config.monitors)) {
85
+ for (const [name, spec] of Object.entries(config.monitors)) {
86
+ if (isRecord(spec)) {
87
+ const monitorResult = buildOpenClawMonitor(name, spec);
88
+ if (typeof monitorResult === "object") {
89
+ extract.monitors.push({ source: `config.monitors.${name}`, spec: monitorResult });
90
+ } else {
91
+ extract.unmapped.push({
92
+ source: `config.monitors.${name}`,
93
+ reason: monitorResult,
94
+ category: "monitors",
95
+ });
96
+ }
97
+ }
98
+ }
99
+ consumedConfig.add("monitors");
100
+ }
101
+
102
+ // Extract skills
103
+ if (isRecord(config.skills)) {
104
+ await readOpenClawSkills(tree, config.skills, extract);
105
+ consumedConfig.add("skills");
106
+ }
107
+
108
+ // Extract memory
109
+ if (isRecord(config.memory)) {
110
+ await readOpenClawMemory(tree, config.memory, extract);
111
+ consumedConfig.add("memory");
112
+ }
113
+
114
+ // Report unmapped
115
+ for (const [key] of Object.entries(config)) {
116
+ if (!consumedConfig.has(key)) {
117
+ extract.unmapped.push({
118
+ source: `config.${key}`,
119
+ reason: "not mapped by migration",
120
+ category: "other",
121
+ });
122
+ }
123
+ }
124
+
125
+ for (const [key] of env.entries()) {
126
+ if (!consumedEnv.has(key)) {
127
+ extract.unmapped.push({
128
+ source: `.env.${key}`,
129
+ reason: "not mapped by migration",
130
+ category: "credentials",
131
+ });
132
+ }
133
+ }
134
+
135
+ return extract;
136
+ }
137
+
138
+ function readOpenClawChannels(
139
+ config: Record<string, unknown>,
140
+ env: Map<string, string>,
141
+ channels: ChannelPlan,
142
+ consumedConfig: Set<string>,
143
+ consumedEnv: Set<string>,
144
+ ): void {
145
+ for (const [platform, platformConfig] of Object.entries(config)) {
146
+ if (!isRecord(platformConfig)) continue;
147
+
148
+ if (platform === "discord" || platform === "telegram" || platform === "slack") {
149
+ // Handle credentials
150
+ const credKey = `${platform.toUpperCase()}_TOKEN`;
151
+ const token = env.get(credKey);
152
+ if (token) {
153
+ consumedEnv.add(credKey);
154
+ channels.credentials[platform === "slack" ? "slack-bot" : platform] = {
155
+ value: token,
156
+ source: `.env ${credKey}`,
157
+ };
158
+ }
159
+
160
+ // Handle channel policies
161
+ if (isRecord(platformConfig.channels)) {
162
+ for (const [channelId, channelPolicy] of Object.entries(platformConfig.channels)) {
163
+ if (isRecord(channelPolicy)) {
164
+ const engagement = typeof channelPolicy.engagement === "string" ? channelPolicy.engagement : "open";
165
+
166
+ channels.channels[`${platform}:${channelId}`] = {
167
+ engagement: engagement as "open" | "mention-open",
168
+ };
169
+ }
170
+ }
171
+ }
172
+
173
+ // Handle allowlist
174
+ const allowlistKey = `${platform.toUpperCase()}_ALLOWED_USERS`;
175
+ const allowlist = env.get(allowlistKey);
176
+ if (allowlist) {
177
+ consumedEnv.add(allowlistKey);
178
+ for (const user of stringList(allowlist)) {
179
+ channels.allowlist.add(`${platform}:${user}`);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ consumedConfig.add("channels");
185
+ }
186
+
187
+ function buildOpenClawMonitor(name: string, spec: Record<string, unknown>): MonitorSpec | string {
188
+ const schedule = typeof spec.schedule === "string" ? spec.schedule : undefined;
189
+ const enabled = typeof spec.enabled === "boolean" ? spec.enabled : true;
190
+ const instruction = typeof spec.instruction === "string" ? spec.instruction : "";
191
+ const target = typeof spec.target === "string" ? spec.target : undefined;
192
+
193
+ if (!schedule || instruction.length === 0) {
194
+ return "Missing schedule or instruction";
195
+ }
196
+
197
+ if (instruction.length > MONITOR_INSTRUCTION_MAX) {
198
+ return `Instruction exceeds ${MONITOR_INSTRUCTION_MAX} characters`;
199
+ }
200
+
201
+ // Validate and convert schedule
202
+ const cronExpr = schedule.includes("/") ? schedule : intervalCron(parseInt(schedule, 10));
203
+ if (!cronExpr) {
204
+ return "Invalid schedule";
205
+ }
206
+
207
+ // Parse target
208
+ let deliveryTarget: OriginRef | undefined;
209
+ if (target) {
210
+ const [platform, ...rest] = target.split(":");
211
+ const rawTarget = rest.join(":");
212
+ const origin = deliveryOrigin(platform, rawTarget);
213
+ if (typeof origin === "object") {
214
+ deliveryTarget = origin;
215
+ }
216
+ }
217
+
218
+ const taken = new Set<string>();
219
+ return cronMonitor({
220
+ source: "openclaw",
221
+ name: slug(name),
222
+ instruction,
223
+ schedule: cronExpr,
224
+ enabled,
225
+ target: deliveryTarget,
226
+ taken,
227
+ });
228
+ }
229
+
230
+ async function readOpenClawSkills(
231
+ tree: SourceTree,
232
+ skillsConfig: Record<string, unknown>,
233
+ extract: SourceExtract,
234
+ ): Promise<void> {
235
+ const skillsDir = join(tree.root, "skills");
236
+ const hasSkillsDir = await isDirectory(skillsDir);
237
+
238
+ for (const [skillName] of Object.entries(skillsConfig)) {
239
+ if (!hasSkillsDir) continue;
240
+
241
+ const skillPath = join(skillsDir, `${skillName}.md`);
242
+ const skillContent = await tree.text(skillPath);
243
+
244
+ if (skillContent) {
245
+ extract.files.push({
246
+ category: "skills",
247
+ sources: [skillPath],
248
+ target: join(".gjc", "skills", `${slug(skillName)}.md`),
249
+ content: skillContent,
250
+ });
251
+ }
252
+ }
253
+ }
254
+
255
+ async function readOpenClawMemory(
256
+ tree: SourceTree,
257
+ _memoryConfig: Record<string, unknown>,
258
+ extract: SourceExtract,
259
+ ): Promise<void> {
260
+ const memoryDir = join(tree.root, "memory");
261
+ const hasMemoryDir = await isDirectory(memoryDir);
262
+
263
+ if (!hasMemoryDir) {
264
+ return;
265
+ }
266
+
267
+ const files = await walkFiles(memoryDir);
268
+
269
+ for (const file of files) {
270
+ if (file.endsWith(".md")) {
271
+ const fullPath = join(memoryDir, file);
272
+ const content = await tree.text(fullPath);
273
+
274
+ if (content) {
275
+ extract.files.push({
276
+ category: "memory",
277
+ sources: [fullPath],
278
+ target: join("memory", "imported.md"),
279
+ content,
280
+ });
281
+ }
282
+ }
283
+ }
284
+ }
@@ -0,0 +1,343 @@
1
+ import type { MonitorSpec, OriginRef } from "@gajae-gateway/protocol";
2
+ import { eventTypeOrigin, validateOriginRef } from "@gajae-gateway/protocol";
3
+
4
+ export const MIGRATION_SOURCES = ["hermes", "openclaw"] as const;
5
+ export type MigrationSource = (typeof MIGRATION_SOURCES)[number];
6
+
7
+ export type MigrationCategory = "persona" | "memory" | "channels" | "credentials" | "monitors" | "skills" | "other";
8
+
9
+ /** One file the migration produces, relative to the gajae-way home. */
10
+ export interface SourceFile {
11
+ readonly category: MigrationCategory;
12
+ /** Source path(s) this output derives from, for the report. */
13
+ readonly sources: readonly string[];
14
+ /** Target path relative to the gajae-way home. */
15
+ readonly target: string;
16
+ readonly content: string | Uint8Array;
17
+ /** Credential material: written 0600 under secrets/, never printed. */
18
+ readonly secret?: boolean;
19
+ /** Modification time to carry over from the source (timestamps are preserved). */
20
+ readonly mtime?: Date;
21
+ }
22
+
23
+ /**
24
+ * A JSON document merged into whatever the target home already holds, so a
25
+ * re-run over its own output converges instead of stacking duplicates.
26
+ */
27
+ export interface MergedJson {
28
+ readonly category: MigrationCategory;
29
+ readonly sources: readonly string[];
30
+ readonly target: string;
31
+ merge(existing: Record<string, unknown> | undefined): Record<string, unknown>;
32
+ }
33
+
34
+ export interface Unmapped {
35
+ readonly category: MigrationCategory;
36
+ readonly source: string;
37
+ readonly reason: string;
38
+ }
39
+
40
+ export interface Inventory {
41
+ readonly persona: string[];
42
+ readonly memory: string[];
43
+ readonly channels: string[];
44
+ readonly schedules: string[];
45
+ readonly skills: string[];
46
+ }
47
+
48
+ /** Everything a source reader extracts; the planner turns it into concrete writes. */
49
+ export interface SourceExtract {
50
+ readonly source: MigrationSource;
51
+ readonly root: string;
52
+ readonly inventory: Inventory;
53
+ readonly files: SourceFile[];
54
+ readonly merges: MergedJson[];
55
+ readonly monitors: { readonly source: string; readonly spec: MonitorSpec }[];
56
+ readonly unmapped: Unmapped[];
57
+ readonly warnings: string[];
58
+ }
59
+
60
+ export function emptyExtract(source: MigrationSource, root: string): SourceExtract {
61
+ return {
62
+ source,
63
+ root,
64
+ inventory: { persona: [], memory: [], channels: [], schedules: [], skills: [] },
65
+ files: [],
66
+ merges: [],
67
+ monitors: [],
68
+ unmapped: [],
69
+ warnings: [],
70
+ };
71
+ }
72
+
73
+ /** The subset of five-field cron syntax the gateway's cron trigger evaluates (no names, no `?`/`L`/`#`). */
74
+ const CRON_FIELD = /^(\*|\d+(-\d+)?)(\/\d+)?(,(\*|\d+(-\d+)?)(\/\d+)?)*$/;
75
+ const CRON_BOUNDS: readonly (readonly [number, number])[] = [
76
+ [0, 59],
77
+ [0, 23],
78
+ [1, 31],
79
+ [1, 12],
80
+ [0, 7],
81
+ ];
82
+
83
+ export function gatewayCron(expr: string): string | undefined {
84
+ const fields = expr.trim().split(/\s+/);
85
+ if (fields.length !== 5) return undefined;
86
+ for (const [index, field] of fields.entries()) {
87
+ if (!CRON_FIELD.test(field)) return undefined;
88
+ const [min, max] = CRON_BOUNDS[index] as readonly [number, number];
89
+ for (const number of field.match(/\d+/g) ?? []) {
90
+ const value = Number(number);
91
+ // Step values are divisors, not field values; only bound the field values.
92
+ if (field.includes(`/${number}`)) continue;
93
+ if (value < min || value > max) return undefined;
94
+ }
95
+ }
96
+ return fields.join(" ");
97
+ }
98
+
99
+ /** A fixed interval as a cron schedule, when it divides the hour or the day evenly. */
100
+ export function intervalCron(minutes: number): string | undefined {
101
+ if (!Number.isInteger(minutes) || minutes < 1) return undefined;
102
+ if (minutes === 1) return "* * * * *";
103
+ if (minutes < 60 && 60 % minutes === 0) return `*/${minutes} * * * *`;
104
+ if (minutes === 60) return "0 * * * *";
105
+ if (minutes % 60 === 0 && minutes < 1440 && 24 % (minutes / 60) === 0) return `0 */${minutes / 60} * * *`;
106
+ if (minutes === 1440) return "0 0 * * *";
107
+ return undefined;
108
+ }
109
+
110
+ export function slug(value: string): string {
111
+ return (
112
+ value
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9]+/g, "-")
115
+ .replace(/^-+|-+$/g, "")
116
+ .slice(0, 48)
117
+ .replace(/-+$/, "") || "job"
118
+ );
119
+ }
120
+
121
+ export const MONITOR_INSTRUCTION_MAX = 4000;
122
+
123
+ /**
124
+ * Builds a validated cron MonitorSpec, or the reason it cannot be one. The
125
+ * checks mirror the gateway registry's own validation so a spec accepted here
126
+ * is never rejected at import.
127
+ */
128
+ export function cronMonitor(input: {
129
+ readonly source: MigrationSource;
130
+ readonly name: string;
131
+ readonly schedule: string;
132
+ readonly instruction: string;
133
+ readonly enabled: boolean;
134
+ readonly target?: OriginRef;
135
+ readonly taken: Set<string>;
136
+ }): MonitorSpec | string {
137
+ const instruction = input.instruction.trim();
138
+ if (!instruction) return "job has no prompt text to use as the monitor instruction";
139
+ if (instruction.length > MONITOR_INSTRUCTION_MAX)
140
+ return `prompt is ${instruction.length} characters; monitor instructions are limited to ${MONITOR_INSTRUCTION_MAX}`;
141
+ const base = `${input.source}-${slug(input.name)}`;
142
+ let name = base;
143
+ for (let n = 2; input.taken.has(name); n++) name = `${base}-${n}`;
144
+ const eventType = `migrated.${name}`;
145
+ validateOriginRef(eventTypeOrigin(eventType));
146
+ if (input.target) validateOriginRef(input.target);
147
+ input.taken.add(name);
148
+ return {
149
+ name,
150
+ trigger: { kind: "cron", schedule: input.schedule },
151
+ eventTypes: [eventType],
152
+ burstPolicy: "dedupe",
153
+ instruction,
154
+ ...(input.target ? { channelTarget: { origin: input.target } } : {}),
155
+ enabled: input.enabled,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * A delivery target as an origin. Telegram chat ids are negative for groups and
161
+ * positive for the private chat with that user, where the chat id is the peer.
162
+ * Discord and Slack DMs need a conversation id the source does not record.
163
+ */
164
+ export function deliveryOrigin(platform: string, rawTarget: string): OriginRef | string {
165
+ const target = rawTarget.trim();
166
+ if (platform === "telegram") {
167
+ const chat = target.replace(/^(channel|group|chat):/, "").split(":")[0] ?? "";
168
+ if (!/^-?\d+$/.test(chat)) return `telegram target ${JSON.stringify(target)} is not a numeric chat id`;
169
+ return chat.startsWith("-")
170
+ ? { platform: "telegram", kind: "channel", conversationId: chat }
171
+ : { platform: "telegram", kind: "dm", conversationId: chat, peerId: chat };
172
+ }
173
+ if (platform === "discord" || platform === "slack") {
174
+ if (target.startsWith("user:"))
175
+ return `${platform} user target ${JSON.stringify(target)} needs a DM conversation id the source does not record`;
176
+ const id = target.replace(/^channel:/, "");
177
+ const valid = platform === "discord" ? /^\d+$/.test(id) : /^[CG][A-Z0-9]+$/.test(id);
178
+ if (!valid) return `${platform} target ${JSON.stringify(target)} is not a channel id`;
179
+ return { platform, kind: "channel", conversationId: id };
180
+ }
181
+ return `delivery platform ${JSON.stringify(platform)} has no gajae-way adapter`;
182
+ }
183
+
184
+ /** Channel credential names this migration can carry. */
185
+ export type ChannelCredential = "discord" | "telegram" | "slack-bot" | "slack-app";
186
+
187
+ export const SECRET_FILES: Record<ChannelCredential, string> = {
188
+ discord: "secrets/discord-token",
189
+ telegram: "secrets/telegram-token",
190
+ "slack-bot": "secrets/slack-bot-token",
191
+ "slack-app": "secrets/slack-app-token",
192
+ };
193
+
194
+ export interface ChannelPlan {
195
+ readonly credentials: Partial<Record<ChannelCredential, { readonly value: string; readonly source: string }>>;
196
+ /** Gateway `config.json` channel policies keyed `<platform>:<id>`. */
197
+ readonly channels: Record<string, { engagement: "open" | "mention-open" }>;
198
+ readonly allowlist: Set<string>;
199
+ dmPolicy?: "open";
200
+ }
201
+
202
+ export function emptyChannelPlan(): ChannelPlan {
203
+ return { credentials: {}, channels: {}, allowlist: new Set() };
204
+ }
205
+
206
+ /**
207
+ * Turns a channel plan into secret files plus merged gateway/adapter config.
208
+ * Merges only add: an existing policy, allowlist entry or owner target the
209
+ * operator already set is never replaced by an imported one.
210
+ */
211
+ export function emitChannels(extract: SourceExtract, plan: ChannelPlan, home: string, configSource: string): void {
212
+ const credentialSources = Object.values(plan.credentials).map((entry) => entry.source);
213
+ for (const [name, entry] of Object.entries(plan.credentials) as [
214
+ ChannelCredential,
215
+ { value: string; source: string },
216
+ ][]) {
217
+ extract.files.push({
218
+ category: "credentials",
219
+ sources: [entry.source],
220
+ target: SECRET_FILES[name],
221
+ content: `${entry.value}\n`,
222
+ secret: true,
223
+ });
224
+ }
225
+ const has = (name: ChannelCredential) => plan.credentials[name] !== undefined;
226
+ const byPlatform = (platform: string) =>
227
+ Object.fromEntries(
228
+ Object.entries(plan.channels)
229
+ .filter(([key]) => key.startsWith(`${platform}:`))
230
+ .map(([key, policy]) => [key.slice(platform.length + 1), policy]),
231
+ );
232
+ const credentialRefs: Record<string, { credentialFile: string }> = {};
233
+ if (has("discord")) credentialRefs.discord = { credentialFile: `${home}/${SECRET_FILES.discord}` };
234
+ if (has("telegram")) credentialRefs.telegram = { credentialFile: `${home}/${SECRET_FILES.telegram}` };
235
+ if (has("slack-bot")) credentialRefs.slackBot = { credentialFile: `${home}/${SECRET_FILES["slack-bot"]}` };
236
+ if (has("slack-app")) credentialRefs.slackApp = { credentialFile: `${home}/${SECRET_FILES["slack-app"]}` };
237
+ const allowlist = [...plan.allowlist].sort();
238
+ extract.merges.push({
239
+ category: "channels",
240
+ sources: [configSource, ...credentialSources],
241
+ target: "config.json",
242
+ merge(existing) {
243
+ const base = existing ?? { schemaVersion: 1 };
244
+ const credentials = { ...(base.credentials as Record<string, unknown> | undefined) };
245
+ for (const [name, reference] of Object.entries(credentialRefs)) credentials[name] ??= reference;
246
+ const channels = { ...(base.channels as Record<string, unknown> | undefined) };
247
+ for (const [key, policy] of Object.entries(plan.channels)) channels[key] ??= policy;
248
+ const existingAllow = Array.isArray(base.mentionAllowlist) ? (base.mentionAllowlist as string[]) : [];
249
+ const mergedAllow = [...new Set([...existingAllow, ...allowlist])];
250
+ return {
251
+ ...base,
252
+ ...(Object.keys(credentials).length ? { credentials } : {}),
253
+ ...(Object.keys(channels).length ? { channels } : {}),
254
+ ...(mergedAllow.length ? { mentionAllowlist: mergedAllow } : {}),
255
+ ...(base.dmPolicy === undefined && plan.dmPolicy ? { dmPolicy: plan.dmPolicy } : {}),
256
+ };
257
+ },
258
+ });
259
+ const adapter = (target: string, required: Record<string, string>, channelKey: string, platform: string) => {
260
+ const channels = byPlatform(platform);
261
+ // The Telegram adapter's chat map only accepts `open`; mention gating stays in config.json.
262
+ const adapterChannels =
263
+ platform === "telegram"
264
+ ? Object.fromEntries(Object.entries(channels).filter(([, policy]) => policy.engagement === "open"))
265
+ : channels;
266
+ extract.merges.push({
267
+ category: "channels",
268
+ sources: [configSource],
269
+ target,
270
+ merge(existing) {
271
+ const base = { ...existing };
272
+ for (const [field, value] of Object.entries(required)) base[field] ??= value;
273
+ const merged = { ...(base[channelKey] as Record<string, unknown> | undefined) };
274
+ for (const [id, policy] of Object.entries(adapterChannels)) merged[id] ??= policy;
275
+ return { ...base, ...(Object.keys(merged).length ? { [channelKey]: merged } : {}) };
276
+ },
277
+ });
278
+ };
279
+ if (has("discord")) adapter("adapter-discord.json", { tokenFile: SECRET_FILES.discord }, "channels", "discord");
280
+ if (has("telegram")) adapter("adapter-telegram.json", { tokenFile: SECRET_FILES.telegram }, "chats", "telegram");
281
+ if (has("slack-bot") && has("slack-app"))
282
+ adapter(
283
+ "adapter-slack.json",
284
+ { botTokenFile: SECRET_FILES["slack-bot"], appTokenFile: SECRET_FILES["slack-app"] },
285
+ "channels",
286
+ "slack",
287
+ );
288
+ else if (has("slack-bot") !== has("slack-app"))
289
+ extract.warnings.push(
290
+ "Slack needs both a bot token (xoxb-) and a Socket Mode app token (xapp-); only one was found, so adapter-slack.json was not written",
291
+ );
292
+ for (const [name, prefix] of [
293
+ ["slack-bot", "xoxb-"],
294
+ ["slack-app", "xapp-"],
295
+ ] as const) {
296
+ const value = plan.credentials[name]?.value;
297
+ if (value !== undefined && !value.startsWith(prefix))
298
+ extract.warnings.push(`${SECRET_FILES[name]} does not start with ${prefix}; the Slack adapter will refuse it`);
299
+ }
300
+ }
301
+
302
+ /** Adds imported monitor specs to `migration/monitors.json`, which the gateway imports once at boot. */
303
+ export function emitMonitors(extract: SourceExtract): void {
304
+ if (extract.monitors.length === 0) return;
305
+ const specs = extract.monitors.map((entry) => entry.spec);
306
+ extract.merges.push({
307
+ category: "monitors",
308
+ sources: [...new Set(extract.monitors.map((entry) => entry.source))],
309
+ target: MIGRATED_MONITORS_FILE,
310
+ merge(existing) {
311
+ const current = Array.isArray(existing?.monitors) ? (existing.monitors as MonitorSpec[]) : [];
312
+ const names = new Set(current.map((spec) => spec.name));
313
+ return { version: 1, monitors: [...current, ...specs.filter((spec) => !names.has(spec.name))] };
314
+ },
315
+ });
316
+ }
317
+
318
+ /** Relative to the gajae-way home; the gateway reads the same path (packages/gateway/src/monitors/migrated.ts). */
319
+ export const MIGRATED_MONITORS_FILE = "migration/monitors.json";
320
+
321
+ /** Custom memory axis that holds imported curated memory, so the audit does not report it as orphaned. */
322
+ export const IMPORTED_AXIS = {
323
+ id: "imported",
324
+ displayName: "Imported memory",
325
+ root: "imported",
326
+ nesting: "nested",
327
+ index: "tree",
328
+ layout: "free",
329
+ retrievalPriority: 50,
330
+ } as const;
331
+
332
+ export function emitImportedAxis(extract: SourceExtract): void {
333
+ extract.merges.push({
334
+ category: "memory",
335
+ sources: [],
336
+ target: "memory/axes.json",
337
+ merge(existing) {
338
+ const axes = Array.isArray(existing?.axes) ? (existing.axes as { id?: unknown }[]) : [];
339
+ if (axes.some((axis) => axis.id === IMPORTED_AXIS.id)) return { version: 1, ...existing, axes };
340
+ return { version: 1, ...existing, axes: [...axes, IMPORTED_AXIS] };
341
+ },
342
+ });
343
+ }