@gajae-gateway/cli 0.1.4 → 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.
package/src/main.ts CHANGED
@@ -5,6 +5,7 @@ import { isAbsolute, join } from "node:path";
5
5
  import type {
6
6
  MonitorEventRecord,
7
7
  MonitorRecord,
8
+ MonitorScheduleProjection,
8
9
  MonitorSpec,
9
10
  OpsCycleResult,
10
11
  WorkJobsResult,
@@ -21,11 +22,13 @@ import {
21
22
  columnNames,
22
23
  type ListOptions,
23
24
  MONITOR_COLUMNS,
25
+ type MonitorListRow,
24
26
  parseListOptions,
25
27
  renderList,
26
28
  SESSION_COLUMNS,
27
29
  type SessionListRow,
28
30
  } from "./list";
31
+ import { migrate, parseMigrateArgs } from "./migrate";
29
32
  import {
30
33
  effectiveRestartState,
31
34
  type LaunchRestartOptions,
@@ -36,6 +39,9 @@ import {
36
39
  runRestartStack,
37
40
  } from "./restart-stack";
38
41
  import { type InstallServicesOptions, installServices, type ServicePlatform, serviceUsage } from "./services";
42
+ import { type RunSetupOptions, runSetup } from "./setup";
43
+ import { parseUpdateArgs, renderUpdate, runUpdate, type UpdateDeps } from "./update";
44
+ import { performUpgrade } from "./upgrade";
39
45
 
40
46
  export function socketPath(home = process.env.GAJAEWAY_HOME): string {
41
47
  return `${home ?? `${process.env.HOME ?? "~"}/.gajaeway`}/gateway.sock`;
@@ -58,10 +64,13 @@ export const COMMANDS = [
58
64
  "monitors",
59
65
  "work",
60
66
  "services",
67
+ "migrate",
68
+ "update",
69
+ "setup",
61
70
  ] as const;
62
71
 
63
72
  export const CLI_USAGE =
64
- "usage: gajaeway [--socket PATH] status|shutdown|chat|daemon run|sessions list [--json] [--fields a,b,c] [--limit N] [--offset N]|sessions inspect <originKey-or-index>|memory audit|memory search <query>|monitors ... (test <id> [--type T] [--payload J] [--wait[=SECONDS]])|work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] <text>|work status <name>|work steer <name> <text>|work retire [--force] <name>|work retire --all-dead|work jobs|ops backup <path>|ops redeliver <deliveryId>|ops redeliver --since <iso>|ops cycle [--json]|ops integrity|ops restore <backupPath>|ops restart-stack [--status]|services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux] (work run waits for a response; caller timeout does not end the attempt)";
73
+ "usage: gajaeway [--socket PATH] status|shutdown|chat|daemon run|sessions list [--json] [--fields a,b,c] [--limit N] [--offset N]|sessions inspect <originKey-or-index>|memory audit|memory search <query>|monitors ... (test <id> [--type T] [--payload J] [--wait[=SECONDS]])|work run|start <name> [--cwd DIR] [--resume] [--model ID|--preset NAME] [--notify originKey (start only)] <text>|work status <name>|work steer <name> <text>|work retire [--force] <name>|work retire --all-dead|work jobs|ops backup <path>|ops redeliver <deliveryId>|ops redeliver --since <iso>|ops cycle [--json]|ops integrity|ops restore <backupPath>|ops restart-stack [--status]|services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux]|migrate [--source PATH] [--target PATH] [--dry-run]|update [--check] [--force] [--bin-dir DIR] [--no-restart]|setup [--from-env] [--adapters discord,slack,telegram] [--owner ID] [--discord-app-id ID]|setup --status (work run waits for a response; caller timeout does not end the attempt); cron timezone is an IANA zone and defaults to the gateway host's local timezone";
65
74
 
66
75
  /** Usage errors exit 2, as `gajaeway-gateway` does; 1 stays a runtime failure. */
67
76
  export const USAGE_EXIT_CODE = 2;
@@ -87,11 +96,15 @@ function gatewayHome(): string {
87
96
 
88
97
  export interface MainOptions {
89
98
  readonly services?: Pick<InstallServicesOptions, "loginPathRunner" | "writeFile">;
99
+ /** Test seams for `setup`: the platform fetch and the prompter. */
100
+ readonly setup?: Pick<RunSetupOptions, "fetch" | "prompter" | "env">;
90
101
  /** Test seams for `ops restart-stack`; the real path spawns the service manager. */
91
102
  readonly restartStack?: {
92
103
  readonly launch?: Omit<LaunchRestartOptions, "home">;
93
104
  readonly run?: Omit<RunRestartOptions, "home" | "id">;
94
105
  };
106
+ /** Test seams for `update`; the real path hits GitHub and the service manager. */
107
+ readonly update?: UpdateDeps;
95
108
  }
96
109
 
97
110
  export type ServicesAction = "install" | "repair";
@@ -589,7 +602,28 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
589
602
  break;
590
603
  }
591
604
  case "ops": {
592
- const [command, path] = parsed.rest;
605
+ const [command, path, ...extraArgs] = parsed.rest;
606
+ if (command === "upgrade") {
607
+ // gajaeway ops upgrade [--gjc X.Y.Z]
608
+ const home = gatewayHome();
609
+ let gjcVersion: string | undefined;
610
+ if (path === "--gjc" && extraArgs[0]) {
611
+ gjcVersion = extraArgs[0];
612
+ } else if (path && path !== "--gjc") {
613
+ throw new Error("usage: gajaeway ops upgrade [--gjc X.Y.Z]");
614
+ }
615
+ const result = await performUpgrade({ home, gjcVersion });
616
+ console.log(result.detail);
617
+ if (result.status !== "ok") {
618
+ process.exitCode = 1;
619
+ break;
620
+ }
621
+ // After upgrade, trigger restart-stack
622
+ const { receipt, supervisorPid } = await launchRestartStack({ home });
623
+ console.log(`restart-stack ${receipt.id}: queued (supervisor pid ${supervisorPid})`);
624
+ console.log("read the outcome with: gajaeway ops restart-stack --status");
625
+ break;
626
+ }
593
627
  if (command === "restore" && path) {
594
628
  await restoreDatabase(parsed.socket, path);
595
629
  break;
@@ -650,7 +684,7 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
650
684
  process.exitCode = cycleExitCode(cycle);
651
685
  } else
652
686
  throw new Error(
653
- "usage: gajaeway ops backup <path>|redeliver <deliveryId>|redeliver --since <iso>|cycle [--json]|integrity|restore <backupPath>|restart-stack [--status]",
687
+ "usage: gajaeway ops backup <path>|redeliver <deliveryId>|redeliver --since <iso>|cycle [--json]|integrity|restore <backupPath>|upgrade [--gjc X.Y.Z]|restart-stack [--status]",
654
688
  );
655
689
  } finally {
656
690
  await client.close();
@@ -695,10 +729,18 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
695
729
  console.log(JSON.stringify(await client.request<{ monitorId: string }>("monitor.update", update.params)));
696
730
  else if (command === "list") {
697
731
  const options = listOptions as ListOptions;
698
- const result = await client.request<{ monitors: MonitorRecord[] }>("monitor.list");
699
- for (const line of renderList(MONITOR_COLUMNS, result.monitors, options, {
732
+ const result = await client.request<{
733
+ monitors: MonitorRecord[];
734
+ schedules: Record<string, MonitorScheduleProjection>;
735
+ }>("monitor.list");
736
+ const rows: MonitorListRow[] = result.monitors.map((monitor) => ({
737
+ monitor,
738
+ schedule: result.schedules[monitor.monitorId] ?? null,
739
+ }));
740
+ for (const line of renderList(MONITOR_COLUMNS, rows, options, {
700
741
  key: "monitors",
701
742
  result,
743
+ serializeRow: (row) => row.monitor,
702
744
  }))
703
745
  console.log(line);
704
746
  } else if (command === "inspect" && args[0])
@@ -900,6 +942,24 @@ export async function main(args = process.argv.slice(2), options: MainOptions =
900
942
  for (const definition of written) console.log(definition);
901
943
  break;
902
944
  }
945
+ case "migrate":
946
+ await migrate(parseMigrateArgs(parsed.rest));
947
+ break;
948
+ case "update": {
949
+ const update = parseUpdateArgs(parsed.rest);
950
+ const result = await runUpdate({
951
+ args: update,
952
+ home: gatewayHome(),
953
+ ...(options.update === undefined ? {} : { deps: options.update }),
954
+ });
955
+ for (const line of renderUpdate(result)) console.log(line);
956
+ break;
957
+ }
958
+ case "setup": {
959
+ // Socket-free: setup runs before any gateway exists.
960
+ await runSetup(parsed.rest, { home: gatewayHome(), ...options.setup });
961
+ break;
962
+ }
903
963
  default:
904
964
  throw new Error(CLI_USAGE);
905
965
  }
@@ -0,0 +1,436 @@
1
+ import { join } from "node:path";
2
+ import type { OriginRef } from "@gajae-gateway/protocol";
3
+ import {
4
+ type ChannelPlan,
5
+ cronMonitor,
6
+ deliveryOrigin,
7
+ emitChannels,
8
+ emitImportedAxis,
9
+ emitMonitors,
10
+ emptyChannelPlan,
11
+ emptyExtract,
12
+ gatewayCron,
13
+ intervalCron,
14
+ type SourceExtract,
15
+ } from "./plan";
16
+ import {
17
+ entries,
18
+ isDirectory,
19
+ isRecord,
20
+ parseDotenv,
21
+ regularFile,
22
+ type SourceTree,
23
+ stringList,
24
+ walkFiles,
25
+ } from "./source-fs";
26
+
27
+ /**
28
+ * Hermes Agent (NousResearch/hermes-agent) keeps everything under one home,
29
+ * `~/.hermes` (HERMES_HOME):
30
+ *
31
+ * SOUL.md, AGENTS.md persona / instructions
32
+ * memories/MEMORY.md, USER.md bounded entry stores, entries joined by "\n§\n"
33
+ * .env credentials (DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, ...)
34
+ * config.yaml settings (discord.free_response_channels, ...)
35
+ * cron/jobs.json {"jobs": [...], "updated_at": ...}
36
+ * skills/<category>/<name>/SKILL.md, skills/.bundled_manifest
37
+ *
38
+ * See docs/migration.md for the upstream references.
39
+ */
40
+ export const HERMES_ENTRY_DELIMITER = "\n§\n";
41
+
42
+ export async function detectHermes(root: string): Promise<boolean> {
43
+ return (
44
+ (await regularFile(join(root, "config.yaml"))) !== undefined ||
45
+ (await isDirectory(join(root, "memories"))) ||
46
+ (await regularFile(join(root, "cron", "jobs.json"))) !== undefined
47
+ );
48
+ }
49
+
50
+ /** Channel tokens Hermes reads from `.env`, keyed to the gajae-way credential they become. */
51
+ const TOKEN_KEYS = {
52
+ DISCORD_BOT_TOKEN: "discord",
53
+ TELEGRAM_BOT_TOKEN: "telegram",
54
+ SLACK_BOT_TOKEN: "slack-bot",
55
+ SLACK_APP_TOKEN: "slack-app",
56
+ } as const;
57
+
58
+ const ALLOWLIST_KEYS = ["DISCORD_ALLOWED_USERS", "TELEGRAM_ALLOWED_USERS", "SLACK_ALLOWED_USERS"] as const;
59
+ const HOME_CHANNEL_KEYS: Record<string, string> = {
60
+ discord: "DISCORD_HOME_CHANNEL",
61
+ telegram: "TELEGRAM_HOME_CHANNEL",
62
+ slack: "SLACK_HOME_CHANNEL",
63
+ };
64
+
65
+ export async function readHermes(tree: SourceTree, home: string): Promise<SourceExtract> {
66
+ const root = tree.root;
67
+ const extract = emptyExtract("hermes", root);
68
+
69
+ for (const name of ["SOUL.md", "AGENTS.md"] as const) {
70
+ const path = join(root, name);
71
+ const text = await tree.text(path);
72
+ if (text === undefined) continue;
73
+ const info = await regularFile(path);
74
+ extract.inventory.persona.push(tree.rel(path));
75
+ extract.files.push({
76
+ category: "persona",
77
+ sources: [tree.rel(path)],
78
+ target: `workspace/${name}`,
79
+ content: text,
80
+ mtime: info?.mtime,
81
+ });
82
+ }
83
+
84
+ const memoryDir = join(root, "memories");
85
+ const userPath = join(memoryDir, "USER.md");
86
+ const userText = await tree.text(userPath);
87
+ if (userText !== undefined) {
88
+ const info = await regularFile(userPath);
89
+ extract.inventory.persona.push(tree.rel(userPath));
90
+ extract.files.push({
91
+ category: "persona",
92
+ sources: [tree.rel(userPath)],
93
+ target: "workspace/USER.md",
94
+ content: entriesToMarkdown("User profile (imported from Hermes)", userText),
95
+ mtime: info?.mtime,
96
+ });
97
+ }
98
+ const memoryPath = join(memoryDir, "MEMORY.md");
99
+ const memoryText = await tree.text(memoryPath);
100
+ if (memoryText !== undefined) {
101
+ const info = await regularFile(memoryPath);
102
+ extract.inventory.memory.push(tree.rel(memoryPath));
103
+ extract.files.push({
104
+ category: "memory",
105
+ sources: [tree.rel(memoryPath)],
106
+ target: "memory/imported/hermes/MEMORY.md",
107
+ content: entriesToMarkdown("Long-term memory (imported from Hermes)", memoryText),
108
+ mtime: info?.mtime,
109
+ });
110
+ emitImportedAxis(extract);
111
+ }
112
+
113
+ const envPath = join(root, ".env");
114
+ const envText = await tree.text(envPath);
115
+ const env = envText === undefined ? new Map<string, string>() : parseDotenv(envText);
116
+ const consumedEnv = new Set<string>();
117
+ const configPath = join(root, "config.yaml");
118
+ const configText = await tree.text(configPath);
119
+ let config: Record<string, unknown> = {};
120
+ if (configText !== undefined) {
121
+ try {
122
+ const parsed = Bun.YAML.parse(configText);
123
+ if (isRecord(parsed)) config = parsed;
124
+ } catch (error) {
125
+ extract.warnings.push(
126
+ `config.yaml could not be parsed (${error instanceof Error ? error.message : String(error)}); channel policies were not read`,
127
+ );
128
+ }
129
+ }
130
+ const consumedConfig = new Set<string>();
131
+
132
+ const channels = emptyChannelPlan();
133
+ for (const [key, credential] of Object.entries(TOKEN_KEYS)) {
134
+ const value = env.get(key);
135
+ if (!value) continue;
136
+ consumedEnv.add(key);
137
+ extract.inventory.channels.push(`.env ${key}`);
138
+ if (value.includes(",")) {
139
+ extract.unmapped.push({
140
+ category: "credentials",
141
+ source: `.env ${key}`,
142
+ reason:
143
+ "holds several comma-separated tokens (multi-workspace); gajae-way runs one bot per adapter, so none was imported",
144
+ });
145
+ continue;
146
+ }
147
+ channels.credentials[credential] = { value, source: `.env ${key}` };
148
+ }
149
+ for (const key of ALLOWLIST_KEYS) {
150
+ const value = env.get(key);
151
+ if (value === undefined) continue;
152
+ consumedEnv.add(key);
153
+ for (const id of stringList(value)) channels.allowlist.add(id);
154
+ }
155
+ readHermesChannelPolicy(config, env, channels, consumedConfig, consumedEnv, extract);
156
+ if (Object.keys(channels.credentials).length > 0 || Object.keys(channels.channels).length > 0)
157
+ emitChannels(extract, channels, home, ".env / config.yaml");
158
+
159
+ await readHermesCron(tree, env, consumedEnv, extract);
160
+ emitMonitors(extract);
161
+ await readHermesSkills(tree, extract);
162
+
163
+ for (const key of env.keys())
164
+ if (!consumedEnv.has(key))
165
+ extract.unmapped.push({
166
+ category: "credentials",
167
+ source: `.env ${key}`,
168
+ reason: /(_KEY|_TOKEN|_SECRET|PASSWORD)$/.test(key)
169
+ ? "provider/tool credential; gajae-way sessions use the shared gjc agent profile, so set it there if still needed"
170
+ : "setting has no gajae-way equivalent",
171
+ });
172
+ for (const key of Object.keys(config))
173
+ if (!consumedConfig.has(key))
174
+ extract.unmapped.push({
175
+ category: "other",
176
+ source: `config.yaml ${key}`,
177
+ reason: "setting has no gajae-way equivalent",
178
+ });
179
+ return extract;
180
+ }
181
+
182
+ /** Hermes stores memory as delimited entries; gajae-way memory is Markdown, one bullet block per entry. */
183
+ export function entriesToMarkdown(title: string, raw: string): string {
184
+ const items = raw
185
+ .split(HERMES_ENTRY_DELIMITER)
186
+ .map((entry) => entry.trim())
187
+ .filter(Boolean);
188
+ return `# ${title}\n\n${items.map((entry) => `- ${entry.replace(/\n/g, "\n ")}`).join("\n")}\n`;
189
+ }
190
+
191
+ function readHermesChannelPolicy(
192
+ config: Record<string, unknown>,
193
+ env: Map<string, string>,
194
+ channels: ChannelPlan,
195
+ consumedConfig: Set<string>,
196
+ consumedEnv: Set<string>,
197
+ extract: SourceExtract,
198
+ ): void {
199
+ const discord = isRecord(config.discord) ? config.discord : undefined;
200
+ if (discord) {
201
+ consumedConfig.add("discord");
202
+ const free = stringList(discord.free_response_channels);
203
+ for (const id of free) {
204
+ if (id === "*") {
205
+ extract.unmapped.push({
206
+ category: "channels",
207
+ source: "config.yaml discord.free_response_channels",
208
+ reason: '"*" (every channel) has no per-channel equivalent; list channels explicitly',
209
+ });
210
+ continue;
211
+ }
212
+ channels.channels[`discord:${id}`] = { engagement: "open" };
213
+ extract.inventory.channels.push(`config.yaml discord.free_response_channels ${id}`);
214
+ }
215
+ for (const key of Object.keys(discord))
216
+ if (key !== "free_response_channels" && key !== "require_mention")
217
+ extract.unmapped.push({
218
+ category: "channels",
219
+ source: `config.yaml discord.${key}`,
220
+ reason: "Discord adapter setting has no gajae-way equivalent",
221
+ });
222
+ }
223
+ const slackChannels = env.get("SLACK_ALLOWED_CHANNELS");
224
+ if (slackChannels !== undefined) {
225
+ consumedEnv.add("SLACK_ALLOWED_CHANNELS");
226
+ for (const id of stringList(slackChannels)) channels.channels[`slack:${id}`] = { engagement: "mention-open" };
227
+ }
228
+ const telegram = isRecord(config.telegram) ? config.telegram : undefined;
229
+ if (telegram) consumedConfig.add("telegram");
230
+ const telegramGroups = env.get("TELEGRAM_GROUP_ALLOWED_CHATS");
231
+ if (telegramGroups !== undefined) {
232
+ consumedEnv.add("TELEGRAM_GROUP_ALLOWED_CHATS");
233
+ const engagement = telegram?.require_mention === true ? "mention-open" : "open";
234
+ for (const id of stringList(telegramGroups)) channels.channels[`telegram:${id}`] = { engagement };
235
+ }
236
+ for (const key of [
237
+ "DISCORD_ALLOW_ALL_USERS",
238
+ "TELEGRAM_ALLOW_ALL_USERS",
239
+ "SLACK_ALLOW_ALL_USERS",
240
+ "GATEWAY_ALLOW_ALL_USERS",
241
+ ]) {
242
+ if (!env.has(key)) continue;
243
+ consumedEnv.add(key);
244
+ extract.unmapped.push({
245
+ category: "channels",
246
+ source: `.env ${key}`,
247
+ reason:
248
+ 'allow-everyone is not carried over; gajae-way DMs default to the allowlist. Set dmPolicy "open" in config.json deliberately if wanted',
249
+ });
250
+ }
251
+ }
252
+
253
+ async function readHermesCron(
254
+ tree: SourceTree,
255
+ env: Map<string, string>,
256
+ consumedEnv: Set<string>,
257
+ extract: SourceExtract,
258
+ ): Promise<void> {
259
+ const path = join(tree.root, "cron", "jobs.json");
260
+ const text = await tree.text(path);
261
+ if (text === undefined) return;
262
+ let jobs: unknown[] = [];
263
+ try {
264
+ const parsed: unknown = JSON.parse(text);
265
+ jobs = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : [];
266
+ } catch {
267
+ extract.unmapped.push({ category: "monitors", source: tree.rel(path), reason: "is not valid JSON" });
268
+ return;
269
+ }
270
+ const taken = new Set<string>();
271
+ for (const job of jobs) {
272
+ if (!isRecord(job)) continue;
273
+ const label = String(job.name ?? job.id ?? "job");
274
+ const source = `${tree.rel(path)} ${JSON.stringify(label)}`;
275
+ extract.inventory.schedules.push(source);
276
+ const skip = (reason: string) => extract.unmapped.push({ category: "monitors", source, reason });
277
+ if (job.no_agent === true || (typeof job.script === "string" && job.script)) {
278
+ skip("script job: gajae-way script monitors run a command under scriptRoot, recreate it with a script trigger");
279
+ continue;
280
+ }
281
+ if (typeof job.monitor_script === "string" || typeof job.monitor_url === "string") {
282
+ skip("change-detection pre-check (monitor_script/monitor_url) has no equivalent");
283
+ continue;
284
+ }
285
+ const schedule = isRecord(job.schedule) ? job.schedule : {};
286
+ let cron: string | undefined;
287
+ if (schedule.kind === "cron" && typeof schedule.expr === "string") {
288
+ cron = gatewayCron(schedule.expr);
289
+ if (!cron) {
290
+ skip(`cron expression ${JSON.stringify(schedule.expr)} uses syntax the gateway cron trigger does not evaluate`);
291
+ continue;
292
+ }
293
+ } else if (schedule.kind === "interval" && typeof schedule.minutes === "number") {
294
+ cron = intervalCron(schedule.minutes);
295
+ if (!cron) {
296
+ skip(`every ${schedule.minutes}m does not divide an hour or a day, so it has no cron form`);
297
+ continue;
298
+ }
299
+ } else {
300
+ skip(`schedule kind ${JSON.stringify(schedule.kind)} (one-shot) has no recurring monitor equivalent`);
301
+ continue;
302
+ }
303
+ const skills = stringList(job.skills);
304
+ const prompt = typeof job.prompt === "string" ? job.prompt : "";
305
+ const instruction = skills.length ? `Use the skill(s): ${skills.join(", ")}.\n\n${prompt}` : prompt;
306
+ const target = hermesTarget(job, env, consumedEnv, extract, source);
307
+ const repeat = isRecord(job.repeat) ? job.repeat.times : null;
308
+ if (typeof repeat === "number")
309
+ extract.warnings.push(`${source}: repeat limit ${repeat} dropped; the monitor fires until removed`);
310
+ const spec = cronMonitor({
311
+ source: "hermes",
312
+ name: label,
313
+ schedule: cron,
314
+ instruction,
315
+ enabled: job.enabled !== false && job.state !== "paused",
316
+ target,
317
+ taken,
318
+ });
319
+ if (typeof spec === "string") skip(spec);
320
+ else extract.monitors.push({ source, spec });
321
+ }
322
+ }
323
+
324
+ function hermesTarget(
325
+ job: Record<string, unknown>,
326
+ env: Map<string, string>,
327
+ consumedEnv: Set<string>,
328
+ extract: SourceExtract,
329
+ source: string,
330
+ ): OriginRef | undefined {
331
+ const targets = stringList(job.deliver ?? "local").filter((target) => target !== "local");
332
+ if (targets.length === 0) return undefined;
333
+ const resolved: OriginRef[] = [];
334
+ for (const target of targets) {
335
+ let result: OriginRef | string;
336
+ if (target === "origin" || target === "origin_fallback") {
337
+ const origin = isRecord(job.origin) ? job.origin : undefined;
338
+ result =
339
+ origin && typeof origin.platform === "string" && origin.chat_id !== undefined
340
+ ? deliveryOrigin(origin.platform, String(origin.chat_id))
341
+ : "origin delivery recorded no origin chat";
342
+ } else {
343
+ const [platform = "", ...rest] = target.split(":");
344
+ let chat = rest.join(":");
345
+ if (!chat && HOME_CHANNEL_KEYS[platform]) {
346
+ chat = env.get(HOME_CHANNEL_KEYS[platform] as string) ?? "";
347
+ consumedEnv.add(HOME_CHANNEL_KEYS[platform] as string);
348
+ consumedEnv.add(`${HOME_CHANNEL_KEYS[platform]}_NAME`);
349
+ }
350
+ result = chat
351
+ ? deliveryOrigin(platform, chat)
352
+ : `delivery target ${JSON.stringify(target)} has no chat id or home channel`;
353
+ }
354
+ if (typeof result === "string")
355
+ extract.warnings.push(`${source}: ${result}; delivered to the owner target instead`);
356
+ else resolved.push(result);
357
+ }
358
+ if (resolved.length > 1)
359
+ extract.warnings.push(`${source}: a monitor has one channel target; kept the first of ${resolved.length}`);
360
+ return resolved[0];
361
+ }
362
+
363
+ async function readHermesSkills(tree: SourceTree, extract: SourceExtract): Promise<void> {
364
+ const skillsDir = join(tree.root, "skills");
365
+ if (!(await isDirectory(skillsDir))) return;
366
+ tree.consume(skillsDir);
367
+ const bundledText = await tree.text(join(skillsDir, ".bundled_manifest"));
368
+ const bundled = new Set(
369
+ (bundledText ?? "")
370
+ .split(/\r?\n/)
371
+ .map((line) => line.split(":")[0]?.trim() ?? "")
372
+ .filter(Boolean),
373
+ );
374
+ const files = (await walkFiles(skillsDir)).filter((file) => !file.split("/").some((part) => part.startsWith(".")));
375
+ const skillRoots = files
376
+ .filter((file) => file.endsWith("/SKILL.md") || file === "SKILL.md")
377
+ .map((file) => file.slice(0, -"/SKILL.md".length));
378
+ const seen = new Set<string>();
379
+ for (const skillRoot of skillRoots) {
380
+ const name = skillRoot.split("/").at(-1) ?? skillRoot;
381
+ if (bundled.has(name)) continue;
382
+ await copySkill(tree, join(skillsDir, skillRoot), name, extract, seen);
383
+ }
384
+ if (bundled.size)
385
+ extract.warnings.push(`${bundled.size} Hermes-bundled skill(s) skipped; they ship with Hermes, not with your data`);
386
+ }
387
+
388
+ export async function copySkill(
389
+ tree: SourceTree,
390
+ dir: string,
391
+ name: string,
392
+ extract: SourceExtract,
393
+ seen: Set<string>,
394
+ ): Promise<void> {
395
+ const source = tree.rel(dir);
396
+ extract.inventory.skills.push(source);
397
+ if (name === "self-ops" || seen.has(name)) {
398
+ extract.unmapped.push({
399
+ category: "skills",
400
+ source,
401
+ reason: `a skill named ${name} is already imported or built in`,
402
+ });
403
+ return;
404
+ }
405
+ seen.add(name);
406
+ for (const file of await walkFiles(dir)) {
407
+ const path = join(dir, file);
408
+ const content = await tree.bytes(path);
409
+ if (content === undefined) continue;
410
+ extract.files.push({
411
+ category: "skills",
412
+ sources: [tree.rel(path)],
413
+ target: `workspace/.gjc/skills/${name}/${file}`,
414
+ content,
415
+ mtime: (await regularFile(path))?.mtime,
416
+ });
417
+ }
418
+ }
419
+
420
+ /** Top-level entries no reader consumed, reported so nothing is dropped silently. */
421
+ export async function reportLeftovers(
422
+ tree: SourceTree,
423
+ dir: string,
424
+ extract: SourceExtract,
425
+ reason: (name: string) => string,
426
+ ): Promise<void> {
427
+ for (const name of await entries(dir)) {
428
+ const path = join(dir, name);
429
+ if (tree.isFullyConsumed(path)) continue;
430
+ if (tree.isConsumed(path)) {
431
+ if (await isDirectory(path)) await reportLeftovers(tree, path, extract, reason);
432
+ continue;
433
+ }
434
+ extract.unmapped.push({ category: "other", source: tree.rel(path), reason: reason(name) });
435
+ }
436
+ }