forge-memory 0.2.109 → 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 +299 -45
  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
  }
@@ -1077,10 +1144,48 @@ async function repairPackagedRuntimeCache(config) {
1077
1144
  }
1078
1145
 
1079
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
+ }
1080
1178
  const existing = await readRuntimeState();
1081
1179
  if (existing?.pid && processExists(existing.pid)) {
1082
- const current = await health(config);
1083
- 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
+ };
1084
1189
  }
1085
1190
 
1086
1191
  await fsp.mkdir(path.dirname(logPath()), { recursive: true });
@@ -1178,6 +1283,18 @@ async function startRuntime(config) {
1178
1283
 
1179
1284
  function assertRuntimeStartedForPairing(result, config) {
1180
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
+ }
1181
1298
  throw new Error(
1182
1299
  [
1183
1300
  `Forge runtime did not become healthy at ${baseUrl(config)}, so iOS pairing was not started.`,
@@ -1188,22 +1305,38 @@ function assertRuntimeStartedForPairing(result, config) {
1188
1305
  );
1189
1306
  }
1190
1307
 
1191
- async function stopRuntime() {
1308
+ async function stopRuntime(config = null) {
1309
+ const effectiveConfig = config ?? (await readConfig());
1192
1310
  const state = await readRuntimeState();
1193
- if (!state?.children?.length)
1194
- return {
1195
- ok: true,
1196
- stopped: false,
1197
- message: "No forge-memory runtime state found."
1198
- };
1199
1311
  const stopped = [];
1200
- for (const child of state.children) {
1312
+ for (const child of state?.children ?? []) {
1201
1313
  if (!child?.pid || !processExists(child.pid)) continue;
1202
1314
  process.kill(child.pid, "SIGTERM");
1203
1315
  stopped.push(child.pid);
1204
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
+ }
1205
1329
  await fsp.rm(runtimeStatePath(), { force: true });
1206
- 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 };
1207
1340
  }
1208
1341
 
1209
1342
  async function exportForgeData(parsed) {
@@ -1330,14 +1463,9 @@ async function removeCodexAdapterConfig() {
1330
1463
  const filePath = path.join(homeDir(), ".codex", "config.toml");
1331
1464
  if (!fs.existsSync(filePath)) return { filePath, changed: false };
1332
1465
  const source = await fsp.readFile(filePath, "utf8");
1333
- const pattern =
1334
- /(?:^|\n)\[mcp_servers\.forge\][\s\S]*?(?=\n\[[^\]]+\]|\s*$)/m;
1335
- if (!pattern.test(source)) return { filePath, changed: false };
1466
+ const next = stripCodexForgeMcpConfig(source);
1467
+ if (next === source.trimEnd()) return { filePath, changed: false };
1336
1468
  await backupIfExists(filePath);
1337
- const next = source
1338
- .replace(pattern, "\n")
1339
- .replace(/\n{3,}/g, "\n\n")
1340
- .trimEnd();
1341
1469
  await fsp.writeFile(filePath, next ? `${next}\n` : "", "utf8");
1342
1470
  return { filePath, changed: true };
1343
1471
  }
@@ -1419,10 +1547,100 @@ function normalizePublicPairingUrl(value) {
1419
1547
  }
1420
1548
  }
1421
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
+
1422
1639
  async function createPairing(config, options = {}) {
1423
1640
  const transportMode = options.transportMode ?? "iroh";
1424
1641
  const publicUrl = validatePairingOptions({ transportMode, publicUrl: options.publicUrl });
1425
- const pairingUrl = new URL("/api/v1/health/pairing-sessions", baseUrl(config));
1642
+ const pairingUrl = forgeApiUrl(config, "/api/v1/health/pairing-sessions");
1643
+ const operatorCookie = await bootstrapLocalOperatorSession(config);
1426
1644
  let response;
1427
1645
  try {
1428
1646
  response = await fetch(pairingUrl, {
@@ -1430,6 +1648,7 @@ async function createPairing(config, options = {}) {
1430
1648
  headers: {
1431
1649
  "content-type": "application/json",
1432
1650
  accept: "application/json",
1651
+ cookie: operatorCookie,
1433
1652
  ...(publicUrl ? { referer: publicUrl } : {})
1434
1653
  },
1435
1654
  body: JSON.stringify({ userId: null, transportMode })
@@ -1452,14 +1671,17 @@ async function createPairing(config, options = {}) {
1452
1671
  }
1453
1672
  if (!response.ok) {
1454
1673
  const body = await response.text().catch(() => "");
1455
- throw new Error(
1674
+ throw new PairingRequestError(
1456
1675
  [
1457
1676
  `Could not create iOS pairing at ${pairingUrl}: Forge returned HTTP ${response.status}.`,
1458
1677
  body ? `Response: ${body.slice(0, 500)}` : "",
1459
- `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."
1460
1681
  ]
1461
1682
  .filter(Boolean)
1462
- .join(" ")
1683
+ .join(" "),
1684
+ { url: pairingUrl.toString(), status: response.status }
1463
1685
  );
1464
1686
  }
1465
1687
  return response.json();
@@ -1773,25 +1995,34 @@ async function runStatus(parsed) {
1773
1995
  const config = await readConfig();
1774
1996
  const state = await readRuntimeState();
1775
1997
  const currentHealth = await health(config);
1998
+ const stateExists = fs.existsSync(runtimeStatePath());
1999
+ const running = isHealthyForgeRuntime(currentHealth);
1776
2000
  const payload = {
1777
- ok: currentHealth.ok,
1778
- running: currentHealth.ok,
2001
+ ok: running,
2002
+ running,
1779
2003
  mode: config.mode,
1780
2004
  baseUrl: baseUrl(config),
1781
2005
  webUrl: webUrl(config),
1782
2006
  dataRoot: config.dataRoot,
1783
2007
  adapters: config.adapters,
2008
+ health: currentHealth,
2009
+ runtimeStatePath: runtimeStatePath(),
2010
+ runtimeStateExists: stateExists,
2011
+ adoptedRuntime: running && !stateExists,
1784
2012
  state
1785
2013
  };
1786
2014
  if (parsed.flags.json) console.log(JSON.stringify(payload, null, 2));
1787
2015
  else {
1788
2016
  console.log(`${color.bold("Forge Memory Status")}`);
1789
2017
  console.log(
1790
- `Runtime: ${currentHealth.ok ? color.green("healthy") : color.yellow("not reachable")}`
2018
+ `Runtime: ${running ? color.green("healthy") : color.yellow(describeHealthResult(currentHealth))}`
1791
2019
  );
1792
2020
  console.log(`Mode: ${config.mode}`);
1793
2021
  console.log(`UI: ${webUrl(config)}`);
1794
2022
  console.log(`Data: ${config.dataRoot}`);
2023
+ console.log(
2024
+ `Runtime state: ${stateExists ? runtimeStatePath() : color.yellow("missing; healthy runtimes will be adopted")}`
2025
+ );
1795
2026
  console.log(
1796
2027
  `Adapters: ${config.adapters.length ? config.adapters.join(", ") : "none configured"}`
1797
2028
  );
@@ -1803,20 +2034,35 @@ async function doctorCheckRuntime(config, options) {
1803
2034
  let result = await health(config);
1804
2035
  let repaired = false;
1805
2036
  let repairRecordPath = null;
1806
- if (!result.ok && options.repair && !options.noStart && !options.dryRun) {
2037
+ if (
2038
+ !isHealthyForgeRuntime(result) &&
2039
+ !(result.ok && result.forge === false) &&
2040
+ options.repair &&
2041
+ !options.noStart &&
2042
+ !options.dryRun
2043
+ ) {
1807
2044
  const repair = await repairPackagedRuntimeCache(config);
1808
2045
  repairRecordPath = repair.repairRecordPath ?? null;
1809
2046
  result = await health(config, 3_000);
1810
- repaired = result.ok;
2047
+ repaired = isHealthyForgeRuntime(result);
1811
2048
  }
2049
+ const ok = isHealthyForgeRuntime(result);
1812
2050
  return {
1813
2051
  id: "runtime",
1814
- ok: result.ok,
1815
- detail: baseUrl(config),
2052
+ ok,
2053
+ detail:
2054
+ result.ok && result.forge === false
2055
+ ? `${baseUrl(config)} (non-Forge service responded)`
2056
+ : baseUrl(config),
1816
2057
  repaired,
1817
2058
  repairRecordPath,
1818
- guidance: result.ok
2059
+ health: result,
2060
+ statePath: runtimeStatePath(),
2061
+ stateExists: fs.existsSync(runtimeStatePath()),
2062
+ guidance: ok
1819
2063
  ? "Forge API is reachable."
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.`
1820
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.`
1821
2067
  };
1822
2068
  }
@@ -2378,14 +2624,22 @@ async function main() {
2378
2624
 
2379
2625
  function printFatalError(error, { json = false } = {}) {
2380
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
+ ];
2381
2635
  const payload = {
2382
2636
  ok: false,
2637
+ code:
2638
+ error && typeof error === "object" && typeof error.code === "string"
2639
+ ? error.code
2640
+ : "forge_memory_failed",
2383
2641
  error: message,
2384
- guidance: [
2385
- "Run npx forge-memory doctor --repair to check and repair the local install.",
2386
- "Run npx forge-memory logs to inspect the runtime log.",
2387
- "Forge Memory repair never deletes your data folder."
2388
- ],
2642
+ guidance,
2389
2643
  logPath: logPath()
2390
2644
  };
2391
2645
  if (json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-memory",
3
- "version": "0.2.109",
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",