forge-memory 0.2.108 → 0.2.110

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 (2) hide show
  1. package/bin/forge-memory.mjs +435 -59
  2. package/package.json +1 -1
@@ -179,6 +179,14 @@ function webUrl(config) {
179
179
  return `${baseUrl(config)}/forge/`;
180
180
  }
181
181
 
182
+ function localHostHeader(config) {
183
+ return `127.0.0.1:${config.port || DEFAULT_PORT}`;
184
+ }
185
+
186
+ function forgeApiUrl(config, pathname) {
187
+ return new URL(pathname, baseUrl(config));
188
+ }
189
+
182
190
  async function readJson(filePath, fallback = null) {
183
191
  try {
184
192
  return JSON.parse(await fsp.readFile(filePath, "utf8"));
@@ -734,13 +742,8 @@ async function patchCodexConfig(config, options) {
734
742
  `FORGE_DATA_ROOT = "${config.dataRoot.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`,
735
743
  ""
736
744
  ].join("\n");
737
- const pattern =
738
- /(?:^|\n)\[mcp_servers\.forge\][\s\S]*?(?=\n\[[^\]]+\]|\s*$)/m;
739
- if (pattern.test(source)) {
740
- source = source.replace(pattern, `\n${block}`.trimEnd());
741
- } else {
742
- source = `${source.trimEnd()}\n\n${block}`.trimStart();
743
- }
745
+ const cleaned = stripCodexForgeMcpConfig(source).trimEnd();
746
+ source = `${cleaned ? `${cleaned}\n\n` : ""}${block}`;
744
747
  if (!options.dryRun) {
745
748
  await fsp.mkdir(path.dirname(filePath), { recursive: true });
746
749
  await backupIfExists(filePath);
@@ -753,6 +756,44 @@ async function patchCodexConfig(config, options) {
753
756
  return { filePath };
754
757
  }
755
758
 
759
+ function stripCodexForgeMcpConfig(source) {
760
+ const orphanForgeLines = new Set([
761
+ 'command = "npx"',
762
+ 'args = ["forge-memory", "mcp"]',
763
+ "FORGE_ORIGIN",
764
+ "FORGE_PORT",
765
+ "FORGE_ACTOR_LABEL",
766
+ "FORGE_TIMEOUT_MS",
767
+ "FORGE_DATA_ROOT"
768
+ ]);
769
+ const lines = source.split(/\r?\n/);
770
+ const kept = [];
771
+ let skippingForgeTable = false;
772
+ let currentTable = null;
773
+ for (const line of lines) {
774
+ const trimmed = line.trim();
775
+ const tableMatch = trimmed.match(/^\[([^\]]+)\]$/);
776
+ if (tableMatch) {
777
+ currentTable = tableMatch[1];
778
+ skippingForgeTable = currentTable === "mcp_servers.forge" ||
779
+ currentTable.startsWith("mcp_servers.forge.");
780
+ if (skippingForgeTable) continue;
781
+ }
782
+ if (skippingForgeTable) continue;
783
+ const isGlobalOrphanForgeLine =
784
+ currentTable === null &&
785
+ (orphanForgeLines.has(trimmed) ||
786
+ Array.from(orphanForgeLines).some((prefix) =>
787
+ trimmed.startsWith(`${prefix} =`)
788
+ ));
789
+ if (isGlobalOrphanForgeLine) {
790
+ continue;
791
+ }
792
+ kept.push(line);
793
+ }
794
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
795
+ }
796
+
756
797
  async function runCommand(
757
798
  command,
758
799
  args,
@@ -895,12 +936,28 @@ async function health(config, timeoutMs = 1_500) {
895
936
  const controller = new AbortController();
896
937
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
897
938
  try {
898
- const response = await fetch(new URL("/api/v1/health", baseUrl(config)), {
899
- headers: { accept: "application/json" },
939
+ const response = await fetch(forgeApiUrl(config, "/api/v1/health"), {
940
+ headers: { accept: "application/json", "x-forge-runtime-probe": "1" },
900
941
  signal: controller.signal
901
942
  });
902
943
  if (!response.ok) return { ok: false, status: response.status };
903
- return { ok: true, payload: await response.json() };
944
+ const text = await response.text();
945
+ let payload;
946
+ try {
947
+ payload = JSON.parse(text);
948
+ } catch {
949
+ return {
950
+ ok: true,
951
+ status: response.status,
952
+ error: "HTTP health endpoint returned non-JSON content",
953
+ forge: false
954
+ };
955
+ }
956
+ return {
957
+ ok: true,
958
+ payload,
959
+ forge: isForgeHealthPayload(payload)
960
+ };
904
961
  } catch (error) {
905
962
  return {
906
963
  ok: false,
@@ -911,6 +968,11 @@ async function health(config, timeoutMs = 1_500) {
911
968
  }
912
969
  }
913
970
 
971
+ function isForgeHealthPayload(payload) {
972
+ if (!payload || typeof payload !== "object") return false;
973
+ return payload.app === "forge" && payload.backend === "forge-node-runtime";
974
+ }
975
+
914
976
  function describeNetworkError(error) {
915
977
  if (error instanceof Error) {
916
978
  if (error.name === "AbortError") return "request timed out";
@@ -922,12 +984,17 @@ function describeNetworkError(error) {
922
984
  }
923
985
 
924
986
  function describeHealthResult(result) {
987
+ if (result.ok && result.forge === false) return "HTTP 200 from a non-Forge service";
925
988
  if (result.ok) return "healthy";
926
989
  if (result.status) return `HTTP ${result.status}`;
927
990
  if (result.error) return result.error;
928
991
  return "not reachable";
929
992
  }
930
993
 
994
+ function isHealthyForgeRuntime(result) {
995
+ return Boolean(result?.ok && result.forge !== false);
996
+ }
997
+
931
998
  async function readRuntimeState() {
932
999
  return readJson(runtimeStatePath(), null);
933
1000
  }
@@ -951,8 +1018,8 @@ async function waitForHealth(config, timeoutMs = 30_000) {
951
1018
  return health(config);
952
1019
  }
953
1020
 
954
- function resolveOpenClawPluginRoot() {
955
- const candidates = [require];
1021
+ function resolveOpenClawPluginRoot(options = {}) {
1022
+ const candidates = [];
956
1023
  const installedRuntimePackageJson = path.join(
957
1024
  runtimeInstallRoot(),
958
1025
  "package.json"
@@ -960,6 +1027,9 @@ function resolveOpenClawPluginRoot() {
960
1027
  if (fs.existsSync(installedRuntimePackageJson)) {
961
1028
  candidates.push(createRequire(installedRuntimePackageJson));
962
1029
  }
1030
+ if (!options.installedOnly) {
1031
+ candidates.push(require);
1032
+ }
963
1033
 
964
1034
  for (const candidateRequire of candidates) {
965
1035
  try {
@@ -975,8 +1045,10 @@ function resolveOpenClawPluginRoot() {
975
1045
  return null;
976
1046
  }
977
1047
 
978
- async function ensurePackagedRuntimeInstalled() {
979
- const existing = resolveOpenClawPluginRoot();
1048
+ async function ensurePackagedRuntimeInstalled(options = {}) {
1049
+ const existing = options.forceInstall
1050
+ ? null
1051
+ : resolveOpenClawPluginRoot();
980
1052
  if (existing) return existing;
981
1053
  const installRoot = runtimeInstallRoot();
982
1054
  await fsp.mkdir(installRoot, { recursive: true });
@@ -1012,19 +1084,108 @@ async function ensurePackagedRuntimeInstalled() {
1012
1084
  ].join(" ")
1013
1085
  );
1014
1086
  }
1015
- const installed = resolveOpenClawPluginRoot();
1087
+ const installed = resolveOpenClawPluginRoot({ installedOnly: true });
1016
1088
  if (!installed)
1017
1089
  throw new Error(
1018
1090
  `${RUNTIME_PACKAGE} installed but its runtime entry could not be resolved. Log: ${logPath()}`
1019
1091
  );
1092
+ const entry = path.join(installed, "server", "index.js");
1093
+ if (!fs.existsSync(entry)) {
1094
+ throw new Error(
1095
+ `${RUNTIME_PACKAGE} installed but ${entry} is missing. Log: ${logPath()}`
1096
+ );
1097
+ }
1020
1098
  return installed;
1021
1099
  }
1022
1100
 
1101
+ async function rotateRuntimeLog(reason) {
1102
+ if (!fs.existsSync(logPath())) return null;
1103
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1104
+ const backupPath = `${logPath()}.${reason}-${stamp}.log`;
1105
+ await fsp.mkdir(path.dirname(backupPath), { recursive: true });
1106
+ await fsp.copyFile(logPath(), backupPath);
1107
+ return backupPath;
1108
+ }
1109
+
1110
+ async function repairPackagedRuntimeCache(config) {
1111
+ if (config.mode === "dev") {
1112
+ const result = await startRuntime(config);
1113
+ return {
1114
+ ok: result.ok,
1115
+ mode: "dev",
1116
+ dataRoot: config.dataRoot,
1117
+ health: result.health ?? { ok: result.ok }
1118
+ };
1119
+ }
1120
+
1121
+ await stopRuntime();
1122
+ const rotatedLogPath = await rotateRuntimeLog("repair");
1123
+ await fsp.rm(runtimeStatePath(), { force: true });
1124
+ await fsp.rm(runtimeInstallRoot(), { recursive: true, force: true });
1125
+ const pluginRoot = await ensurePackagedRuntimeInstalled({ forceInstall: true });
1126
+ const result = await startRuntime(config);
1127
+ const record = {
1128
+ repairedAt: new Date().toISOString(),
1129
+ ok: result.ok,
1130
+ mode: config.mode,
1131
+ runtimePackage: RUNTIME_PACKAGE,
1132
+ runtimePackageVersion: RUNTIME_PACKAGE_VERSION,
1133
+ pluginRoot,
1134
+ dataRoot: config.dataRoot,
1135
+ dataPreserved: true,
1136
+ rotatedLogPath,
1137
+ health: result.health ?? { ok: result.ok }
1138
+ };
1139
+ const stamp = record.repairedAt.replace(/[:.]/g, "-");
1140
+ const repairRecordPath = path.join(forgeHome(), "run", `runtime-repair-${stamp}.json`);
1141
+ await fsp.mkdir(path.dirname(repairRecordPath), { recursive: true });
1142
+ await fsp.writeFile(repairRecordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
1143
+ return { ...record, repairRecordPath };
1144
+ }
1145
+
1023
1146
  async function startRuntime(config) {
1147
+ const current = await health(config);
1148
+ if (isHealthyForgeRuntime(current)) {
1149
+ const existing = await readRuntimeState();
1150
+ return {
1151
+ ok: true,
1152
+ started: false,
1153
+ adopted: true,
1154
+ state: existing,
1155
+ health: current
1156
+ };
1157
+ }
1158
+ if (current.ok && current.forge === false) {
1159
+ return {
1160
+ ok: false,
1161
+ started: false,
1162
+ state: await readRuntimeState(),
1163
+ health: current,
1164
+ portConflict: true,
1165
+ message: `Port ${config.port || DEFAULT_PORT} is already serving a non-Forge HTTP service.`
1166
+ };
1167
+ }
1168
+ if (!(await isPortAvailable(config.port || DEFAULT_PORT))) {
1169
+ return {
1170
+ ok: false,
1171
+ started: false,
1172
+ state: await readRuntimeState(),
1173
+ health: current,
1174
+ portConflict: true,
1175
+ message: `Port ${config.port || DEFAULT_PORT} is already in use.`
1176
+ };
1177
+ }
1024
1178
  const existing = await readRuntimeState();
1025
1179
  if (existing?.pid && processExists(existing.pid)) {
1026
- const current = await health(config);
1027
- if (current.ok) return { ok: true, started: false, state: existing };
1180
+ const existingHealth = await health(config);
1181
+ if (isHealthyForgeRuntime(existingHealth))
1182
+ return {
1183
+ ok: true,
1184
+ started: false,
1185
+ adopted: true,
1186
+ state: existing,
1187
+ health: existingHealth
1188
+ };
1028
1189
  }
1029
1190
 
1030
1191
  await fsp.mkdir(path.dirname(logPath()), { recursive: true });
@@ -1122,6 +1283,18 @@ async function startRuntime(config) {
1122
1283
 
1123
1284
  function assertRuntimeStartedForPairing(result, config) {
1124
1285
  if (result?.ok) return;
1286
+ if (result?.portConflict) {
1287
+ throw new Error(
1288
+ [
1289
+ `Forge runtime did not start because ${baseUrl(config)} is already in use by another service.`,
1290
+ `Health check: ${describeHealthResult(result?.health ?? { ok: false })}.`,
1291
+ "Run npx forge-memory status to inspect the configured runtime.",
1292
+ "If Forge Memory owns the running process, run npx forge-memory stop; otherwise stop the conflicting process or choose another --port.",
1293
+ `Runtime log: ${logPath()}.`,
1294
+ "Your data folder is unchanged."
1295
+ ].join(" ")
1296
+ );
1297
+ }
1125
1298
  throw new Error(
1126
1299
  [
1127
1300
  `Forge runtime did not become healthy at ${baseUrl(config)}, so iOS pairing was not started.`,
@@ -1132,22 +1305,38 @@ function assertRuntimeStartedForPairing(result, config) {
1132
1305
  );
1133
1306
  }
1134
1307
 
1135
- async function stopRuntime() {
1308
+ async function stopRuntime(config = null) {
1309
+ const effectiveConfig = config ?? (await readConfig());
1136
1310
  const state = await readRuntimeState();
1137
- if (!state?.children?.length)
1138
- return {
1139
- ok: true,
1140
- stopped: false,
1141
- message: "No forge-memory runtime state found."
1142
- };
1143
1311
  const stopped = [];
1144
- for (const child of state.children) {
1312
+ for (const child of state?.children ?? []) {
1145
1313
  if (!child?.pid || !processExists(child.pid)) continue;
1146
1314
  process.kill(child.pid, "SIGTERM");
1147
1315
  stopped.push(child.pid);
1148
1316
  }
1317
+ const current = await health(effectiveConfig);
1318
+ const runtimePid = Number(current?.payload?.runtime?.pid);
1319
+ if (
1320
+ isHealthyForgeRuntime(current) &&
1321
+ Number.isInteger(runtimePid) &&
1322
+ runtimePid > 0 &&
1323
+ !stopped.includes(runtimePid) &&
1324
+ processExists(runtimePid)
1325
+ ) {
1326
+ process.kill(runtimePid, "SIGTERM");
1327
+ stopped.push(runtimePid);
1328
+ }
1149
1329
  await fsp.rm(runtimeStatePath(), { force: true });
1150
- return { ok: true, stopped: stopped.length > 0, pids: stopped };
1330
+ if (stopped.length === 0) {
1331
+ return {
1332
+ ok: true,
1333
+ stopped: false,
1334
+ message: state?.children?.length
1335
+ ? "No recorded Forge Memory runtime processes were alive."
1336
+ : "No Forge Memory runtime state or live Forge runtime was found."
1337
+ };
1338
+ }
1339
+ return { ok: true, stopped: true, pids: stopped };
1151
1340
  }
1152
1341
 
1153
1342
  async function exportForgeData(parsed) {
@@ -1274,14 +1463,9 @@ async function removeCodexAdapterConfig() {
1274
1463
  const filePath = path.join(homeDir(), ".codex", "config.toml");
1275
1464
  if (!fs.existsSync(filePath)) return { filePath, changed: false };
1276
1465
  const source = await fsp.readFile(filePath, "utf8");
1277
- const pattern =
1278
- /(?:^|\n)\[mcp_servers\.forge\][\s\S]*?(?=\n\[[^\]]+\]|\s*$)/m;
1279
- if (!pattern.test(source)) return { filePath, changed: false };
1466
+ const next = stripCodexForgeMcpConfig(source);
1467
+ if (next === source.trimEnd()) return { filePath, changed: false };
1280
1468
  await backupIfExists(filePath);
1281
- const next = source
1282
- .replace(pattern, "\n")
1283
- .replace(/\n{3,}/g, "\n\n")
1284
- .trimEnd();
1285
1469
  await fsp.writeFile(filePath, next ? `${next}\n` : "", "utf8");
1286
1470
  return { filePath, changed: true };
1287
1471
  }
@@ -1363,10 +1547,100 @@ function normalizePublicPairingUrl(value) {
1363
1547
  }
1364
1548
  }
1365
1549
 
1550
+ function readSetCookieHeader(headers) {
1551
+ if (typeof headers.getSetCookie === "function") {
1552
+ const values = headers.getSetCookie();
1553
+ if (values.length > 0) return values[0];
1554
+ }
1555
+ return headers.get("set-cookie");
1556
+ }
1557
+
1558
+ function cookiePairFromSetCookie(value) {
1559
+ if (!value) return null;
1560
+ return value.split(";")[0]?.trim() || null;
1561
+ }
1562
+
1563
+ class PairingAuthError extends Error {
1564
+ constructor(message, detail = {}) {
1565
+ super(message);
1566
+ this.name = "PairingAuthError";
1567
+ this.code = "pairing_auth_failed";
1568
+ this.detail = detail;
1569
+ this.guidance = [
1570
+ "Forge is reachable, but Forge Memory could not create a local operator session for iOS pairing.",
1571
+ "Run npx forge-memory doctor to confirm the runtime is healthy.",
1572
+ "Then rerun npx forge-memory pair-ios."
1573
+ ];
1574
+ }
1575
+ }
1576
+
1577
+ class PairingRequestError extends Error {
1578
+ constructor(message, detail = {}) {
1579
+ super(message);
1580
+ this.name = "PairingRequestError";
1581
+ this.code = "pairing_request_failed";
1582
+ this.detail = detail;
1583
+ this.guidance =
1584
+ detail.status === 401
1585
+ ? [
1586
+ "Forge is reachable, but the pairing request was not authenticated.",
1587
+ "Forge Memory should bootstrap a local operator session before pairing; rerun npx forge-memory pair-ios after updating.",
1588
+ "Run npx forge-memory doctor only if runtime health also fails."
1589
+ ]
1590
+ : [
1591
+ "Forge is reachable, but it rejected the iOS pairing request.",
1592
+ "Run npx forge-memory doctor to confirm runtime health, then inspect the response above."
1593
+ ];
1594
+ }
1595
+ }
1596
+
1597
+ async function bootstrapLocalOperatorSession(config) {
1598
+ const sessionUrl = forgeApiUrl(config, "/api/v1/auth/operator-session");
1599
+ let response;
1600
+ try {
1601
+ response = await fetch(sessionUrl, {
1602
+ method: "GET",
1603
+ headers: {
1604
+ accept: "application/json",
1605
+ host: localHostHeader(config)
1606
+ }
1607
+ });
1608
+ } catch (error) {
1609
+ throw new PairingAuthError(
1610
+ [
1611
+ `Could not create a local operator session at ${sessionUrl}.`,
1612
+ `Network: ${describeNetworkError(error)}.`
1613
+ ].join(" "),
1614
+ { url: sessionUrl.toString() }
1615
+ );
1616
+ }
1617
+ if (!response.ok) {
1618
+ const body = await response.text().catch(() => "");
1619
+ throw new PairingAuthError(
1620
+ [
1621
+ `Could not create a local operator session at ${sessionUrl}: Forge returned HTTP ${response.status}.`,
1622
+ body ? `Response: ${body.slice(0, 500)}` : ""
1623
+ ]
1624
+ .filter(Boolean)
1625
+ .join(" "),
1626
+ { url: sessionUrl.toString(), status: response.status }
1627
+ );
1628
+ }
1629
+ const cookie = cookiePairFromSetCookie(readSetCookieHeader(response.headers));
1630
+ if (!cookie) {
1631
+ throw new PairingAuthError(
1632
+ `Could not create a local operator session at ${sessionUrl}: Forge did not return a session cookie.`,
1633
+ { url: sessionUrl.toString(), status: response.status }
1634
+ );
1635
+ }
1636
+ return cookie;
1637
+ }
1638
+
1366
1639
  async function createPairing(config, options = {}) {
1367
1640
  const transportMode = options.transportMode ?? "iroh";
1368
- const publicUrl = normalizePublicPairingUrl(options.publicUrl);
1369
- const pairingUrl = new URL("/api/v1/health/pairing-sessions", baseUrl(config));
1641
+ const publicUrl = validatePairingOptions({ transportMode, publicUrl: options.publicUrl });
1642
+ const pairingUrl = forgeApiUrl(config, "/api/v1/health/pairing-sessions");
1643
+ const operatorCookie = await bootstrapLocalOperatorSession(config);
1370
1644
  let response;
1371
1645
  try {
1372
1646
  response = await fetch(pairingUrl, {
@@ -1374,6 +1648,7 @@ async function createPairing(config, options = {}) {
1374
1648
  headers: {
1375
1649
  "content-type": "application/json",
1376
1650
  accept: "application/json",
1651
+ cookie: operatorCookie,
1377
1652
  ...(publicUrl ? { referer: publicUrl } : {})
1378
1653
  },
1379
1654
  body: JSON.stringify({ userId: null, transportMode })
@@ -1396,19 +1671,49 @@ async function createPairing(config, options = {}) {
1396
1671
  }
1397
1672
  if (!response.ok) {
1398
1673
  const body = await response.text().catch(() => "");
1399
- throw new Error(
1674
+ throw new PairingRequestError(
1400
1675
  [
1401
1676
  `Could not create iOS pairing at ${pairingUrl}: Forge returned HTTP ${response.status}.`,
1402
1677
  body ? `Response: ${body.slice(0, 500)}` : "",
1403
- `Run npx forge-memory doctor --repair and inspect ${logPath()}.`
1678
+ response.status === 401
1679
+ ? "Forge Memory did not obtain or pass a valid local operator session cookie."
1680
+ : "Inspect the response and rerun npx forge-memory doctor if runtime health is uncertain."
1404
1681
  ]
1405
1682
  .filter(Boolean)
1406
- .join(" ")
1683
+ .join(" "),
1684
+ { url: pairingUrl.toString(), status: response.status }
1407
1685
  );
1408
1686
  }
1409
1687
  return response.json();
1410
1688
  }
1411
1689
 
1690
+ function validatePairingOptions({ transportMode, publicUrl }) {
1691
+ const normalizedPublicUrl = normalizePublicPairingUrl(publicUrl);
1692
+ if (transportMode !== "manual-http") {
1693
+ return normalizedPublicUrl;
1694
+ }
1695
+ if (!normalizedPublicUrl) {
1696
+ throw new Error(
1697
+ [
1698
+ "Manual HTTP pairing for a physical iPhone requires --public-url.",
1699
+ "Use a phone-reachable Tailscale or LAN Forge URL, for example:",
1700
+ "npx forge-memory pair-ios --manual-http --public-url https://your-mac.tailnet.ts.net/forge/",
1701
+ "For normal pairing, omit --manual-http and use the default Iroh transport."
1702
+ ].join(" ")
1703
+ );
1704
+ }
1705
+ if (isLoopbackPairingUrl(normalizedPublicUrl)) {
1706
+ throw new Error(
1707
+ [
1708
+ `Manual HTTP --public-url points at ${normalizedPublicUrl}, which is loopback-only.`,
1709
+ "A physical iPhone cannot reach localhost on this Mac.",
1710
+ "Use a Tailscale or LAN URL, or omit --manual-http and use Iroh pairing."
1711
+ ].join(" ")
1712
+ );
1713
+ }
1714
+ return normalizedPublicUrl;
1715
+ }
1716
+
1412
1717
  function compactPairingPayload(payload) {
1413
1718
  const transport = payload.transport
1414
1719
  ? {
@@ -1426,7 +1731,7 @@ function compactPairingPayload(payload) {
1426
1731
  notes: []
1427
1732
  }
1428
1733
  : undefined;
1429
- return {
1734
+ return compactObject({
1430
1735
  kind: payload.kind,
1431
1736
  apiBaseUrl: payload.apiBaseUrl,
1432
1737
  uiBaseUrl: payload.uiBaseUrl,
@@ -1436,7 +1741,25 @@ function compactPairingPayload(payload) {
1436
1741
  pairingToken: payload.pairingToken,
1437
1742
  expiresAt: payload.expiresAt,
1438
1743
  capabilities: payload.capabilities
1439
- };
1744
+ });
1745
+ }
1746
+
1747
+ function compactObject(value) {
1748
+ if (Array.isArray(value)) {
1749
+ const compacted = value.map((entry) => compactObject(entry)).filter((entry) => entry !== undefined);
1750
+ return compacted.length ? compacted : undefined;
1751
+ }
1752
+ if (!value || typeof value !== "object") {
1753
+ return value ?? undefined;
1754
+ }
1755
+ const output = {};
1756
+ for (const [key, entry] of Object.entries(value)) {
1757
+ const compacted = compactObject(entry);
1758
+ if (compacted !== undefined && !(Array.isArray(compacted) && compacted.length === 0)) {
1759
+ output[key] = compacted;
1760
+ }
1761
+ }
1762
+ return Object.keys(output).length ? output : undefined;
1440
1763
  }
1441
1764
 
1442
1765
  async function writePairingPayloadFile(payload) {
@@ -1462,8 +1785,15 @@ function isLoopbackPairingUrl(value) {
1462
1785
  async function printPairing(pairing) {
1463
1786
  const payload = compactPairingPayload(pairing.qrPayload);
1464
1787
  const payloadText = JSON.stringify(payload);
1465
- console.log("\nScan this compact QR in Forge Companion:\n");
1466
- qrcode.generate(payloadText, { small: true });
1788
+ const terminalColumns = process.stdout.columns ?? 120;
1789
+ if (terminalColumns >= 72 && payloadText.length <= 2_950) {
1790
+ console.log("\nScan this compact QR in Forge Companion:\n");
1791
+ qrcode.generate(payloadText, { small: true });
1792
+ } else {
1793
+ console.log("");
1794
+ console.log(color.yellow("QR skipped because the terminal is too narrow or the payload is too large to scan reliably."));
1795
+ console.log("Use Manual connection in the iPhone app and paste the saved payload below.");
1796
+ }
1467
1797
  const transport = payload.transport;
1468
1798
  if (transport?.provider) {
1469
1799
  const label =
@@ -1665,25 +1995,34 @@ async function runStatus(parsed) {
1665
1995
  const config = await readConfig();
1666
1996
  const state = await readRuntimeState();
1667
1997
  const currentHealth = await health(config);
1998
+ const stateExists = fs.existsSync(runtimeStatePath());
1999
+ const running = isHealthyForgeRuntime(currentHealth);
1668
2000
  const payload = {
1669
- ok: currentHealth.ok,
1670
- running: currentHealth.ok,
2001
+ ok: running,
2002
+ running,
1671
2003
  mode: config.mode,
1672
2004
  baseUrl: baseUrl(config),
1673
2005
  webUrl: webUrl(config),
1674
2006
  dataRoot: config.dataRoot,
1675
2007
  adapters: config.adapters,
2008
+ health: currentHealth,
2009
+ runtimeStatePath: runtimeStatePath(),
2010
+ runtimeStateExists: stateExists,
2011
+ adoptedRuntime: running && !stateExists,
1676
2012
  state
1677
2013
  };
1678
2014
  if (parsed.flags.json) console.log(JSON.stringify(payload, null, 2));
1679
2015
  else {
1680
2016
  console.log(`${color.bold("Forge Memory Status")}`);
1681
2017
  console.log(
1682
- `Runtime: ${currentHealth.ok ? color.green("healthy") : color.yellow("not reachable")}`
2018
+ `Runtime: ${running ? color.green("healthy") : color.yellow(describeHealthResult(currentHealth))}`
1683
2019
  );
1684
2020
  console.log(`Mode: ${config.mode}`);
1685
2021
  console.log(`UI: ${webUrl(config)}`);
1686
2022
  console.log(`Data: ${config.dataRoot}`);
2023
+ console.log(
2024
+ `Runtime state: ${stateExists ? runtimeStatePath() : color.yellow("missing; healthy runtimes will be adopted")}`
2025
+ );
1687
2026
  console.log(
1688
2027
  `Adapters: ${config.adapters.length ? config.adapters.join(", ") : "none configured"}`
1689
2028
  );
@@ -1694,19 +2033,37 @@ async function runStatus(parsed) {
1694
2033
  async function doctorCheckRuntime(config, options) {
1695
2034
  let result = await health(config);
1696
2035
  let repaired = false;
1697
- if (!result.ok && options.repair && !options.noStart && !options.dryRun) {
1698
- await startRuntime(config);
2036
+ let repairRecordPath = null;
2037
+ if (
2038
+ !isHealthyForgeRuntime(result) &&
2039
+ !(result.ok && result.forge === false) &&
2040
+ options.repair &&
2041
+ !options.noStart &&
2042
+ !options.dryRun
2043
+ ) {
2044
+ const repair = await repairPackagedRuntimeCache(config);
2045
+ repairRecordPath = repair.repairRecordPath ?? null;
1699
2046
  result = await health(config, 3_000);
1700
- repaired = result.ok;
2047
+ repaired = isHealthyForgeRuntime(result);
1701
2048
  }
2049
+ const ok = isHealthyForgeRuntime(result);
1702
2050
  return {
1703
2051
  id: "runtime",
1704
- ok: result.ok,
1705
- detail: baseUrl(config),
2052
+ ok,
2053
+ detail:
2054
+ result.ok && result.forge === false
2055
+ ? `${baseUrl(config)} (non-Forge service responded)`
2056
+ : baseUrl(config),
1706
2057
  repaired,
1707
- guidance: result.ok
2058
+ repairRecordPath,
2059
+ health: result,
2060
+ statePath: runtimeStatePath(),
2061
+ stateExists: fs.existsSync(runtimeStatePath()),
2062
+ guidance: ok
1708
2063
  ? "Forge API is reachable."
1709
- : `Run npx forge-memory doctor --repair, then inspect ${logPath()} if the runtime still does not start.`
2064
+ : result.ok && result.forge === false
2065
+ ? `Port ${config.port || DEFAULT_PORT} responded, but not with Forge runtime health. Stop the conflicting process or choose another --port.`
2066
+ : `Run npx forge-memory doctor --repair, then inspect ${logPath()} if the runtime still does not start. Repair reinstalls only the owned runtime cache and preserves the data folder.`
1710
2067
  };
1711
2068
  }
1712
2069
 
@@ -1816,6 +2173,11 @@ async function runUi(parsed) {
1816
2173
 
1817
2174
  async function runPairIos(parsed) {
1818
2175
  const config = await readConfig();
2176
+ const transportMode = parsed.flags.manualHttp ? "manual-http" : "iroh";
2177
+ const publicUrl = validatePairingOptions({
2178
+ transportMode,
2179
+ publicUrl: parsed.values.publicUrl
2180
+ });
1819
2181
  if (!parsed.flags.noStart) {
1820
2182
  const runtimeResult = await withProgress(
1821
2183
  "Starting Forge runtime for iOS pairing",
@@ -1824,10 +2186,16 @@ async function runPairIos(parsed) {
1824
2186
  () => startRuntime(config)
1825
2187
  );
1826
2188
  assertRuntimeStartedForPairing(runtimeResult, config);
2189
+ } else {
2190
+ const currentHealth = await health(config, 3_000);
2191
+ assertRuntimeStartedForPairing(
2192
+ { ok: currentHealth.ok, started: false, health: currentHealth },
2193
+ config
2194
+ );
1827
2195
  }
1828
2196
  const pairing = await createPairing(config, {
1829
- transportMode: parsed.flags.manualHttp ? "manual-http" : "iroh",
1830
- publicUrl: parsed.values.publicUrl
2197
+ transportMode,
2198
+ publicUrl
1831
2199
  });
1832
2200
  if (parsed.flags.json) {
1833
2201
  console.log(JSON.stringify(pairing, null, 2));
@@ -2256,14 +2624,22 @@ async function main() {
2256
2624
 
2257
2625
  function printFatalError(error, { json = false } = {}) {
2258
2626
  const message = error instanceof Error ? error.message : String(error);
2627
+ const guidance =
2628
+ error && typeof error === "object" && Array.isArray(error.guidance)
2629
+ ? error.guidance
2630
+ : [
2631
+ "Run npx forge-memory doctor --repair to check and repair the local install.",
2632
+ "Run npx forge-memory logs to inspect the runtime log.",
2633
+ "Forge Memory repair never deletes your data folder."
2634
+ ];
2259
2635
  const payload = {
2260
2636
  ok: false,
2637
+ code:
2638
+ error && typeof error === "object" && typeof error.code === "string"
2639
+ ? error.code
2640
+ : "forge_memory_failed",
2261
2641
  error: message,
2262
- guidance: [
2263
- "Run npx forge-memory doctor --repair to check and repair the local install.",
2264
- "Run npx forge-memory logs to inspect the runtime log.",
2265
- "Forge Memory repair never deletes your data folder."
2266
- ],
2642
+ guidance,
2267
2643
  logPath: logPath()
2268
2644
  };
2269
2645
  if (json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-memory",
3
- "version": "0.2.108",
3
+ "version": "0.2.110",
4
4
  "description": "Guided Forge installer and local runtime manager for the Forge UI, OpenClaw, Hermes, Codex, and iOS pairing.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",