ed-mcp 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -103,7 +103,7 @@ var require_code = __commonJS({
103
103
  }
104
104
  exports._ = _;
105
105
  var plus = new _Code("+");
106
- function str5(strs, ...args) {
106
+ function str6(strs, ...args) {
107
107
  const expr = [safeStringify(strs[0])];
108
108
  let i = 0;
109
109
  while (i < args.length) {
@@ -114,7 +114,7 @@ var require_code = __commonJS({
114
114
  optimize(expr);
115
115
  return new _Code(expr);
116
116
  }
117
- exports.str = str5;
117
+ exports.str = str6;
118
118
  function addCodeArg(code, arg) {
119
119
  if (arg instanceof _Code)
120
120
  code.push(...arg._items);
@@ -157,7 +157,7 @@ var require_code = __commonJS({
157
157
  return;
158
158
  }
159
159
  function strConcat(c1, c2) {
160
- return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str5`${c1}${c2}`;
160
+ return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str6`${c1}${c2}`;
161
161
  }
162
162
  exports.strConcat = strConcat;
163
163
  function interpolate(x) {
@@ -1119,22 +1119,22 @@ var require_util = __commonJS({
1119
1119
  return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`;
1120
1120
  }
1121
1121
  exports.schemaRefOrVal = schemaRefOrVal;
1122
- function unescapeFragment(str5) {
1123
- return unescapeJsonPointer(decodeURIComponent(str5));
1122
+ function unescapeFragment(str6) {
1123
+ return unescapeJsonPointer(decodeURIComponent(str6));
1124
1124
  }
1125
1125
  exports.unescapeFragment = unescapeFragment;
1126
- function escapeFragment(str5) {
1127
- return encodeURIComponent(escapeJsonPointer(str5));
1126
+ function escapeFragment(str6) {
1127
+ return encodeURIComponent(escapeJsonPointer(str6));
1128
1128
  }
1129
1129
  exports.escapeFragment = escapeFragment;
1130
- function escapeJsonPointer(str5) {
1131
- if (typeof str5 == "number")
1132
- return `${str5}`;
1133
- return str5.replace(/~/g, "~0").replace(/\//g, "~1");
1130
+ function escapeJsonPointer(str6) {
1131
+ if (typeof str6 == "number")
1132
+ return `${str6}`;
1133
+ return str6.replace(/~/g, "~0").replace(/\//g, "~1");
1134
1134
  }
1135
1135
  exports.escapeJsonPointer = escapeJsonPointer;
1136
- function unescapeJsonPointer(str5) {
1137
- return str5.replace(/~1/g, "/").replace(/~0/g, "~");
1136
+ function unescapeJsonPointer(str6) {
1137
+ return str6.replace(/~1/g, "/").replace(/~0/g, "~");
1138
1138
  }
1139
1139
  exports.unescapeJsonPointer = unescapeJsonPointer;
1140
1140
  function eachItem(xs, f) {
@@ -2159,8 +2159,8 @@ var require_json_schema_traverse = __commonJS({
2159
2159
  post(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex);
2160
2160
  }
2161
2161
  }
2162
- function escapeJsonPtr(str5) {
2163
- return str5.replace(/~/g, "~0").replace(/\//g, "~1");
2162
+ function escapeJsonPtr(str6) {
2163
+ return str6.replace(/~/g, "~0").replace(/\//g, "~1");
2164
2164
  }
2165
2165
  }
2166
2166
  });
@@ -3257,10 +3257,10 @@ var require_utils = __commonJS({
3257
3257
  isIPV6: true
3258
3258
  };
3259
3259
  }
3260
- function findToken(str5, token) {
3260
+ function findToken(str6, token) {
3261
3261
  let ind = 0;
3262
- for (let i = 0; i < str5.length; i++) {
3263
- if (str5[i] === token) ind++;
3262
+ for (let i = 0; i < str6.length; i++) {
3263
+ if (str6[i] === token) ind++;
3264
3264
  }
3265
3265
  return ind;
3266
3266
  }
@@ -4330,7 +4330,7 @@ var require_core = __commonJS({
4330
4330
  var util_1 = require_util();
4331
4331
  var $dataRefSchema = require_data();
4332
4332
  var uri_1 = require_uri();
4333
- var defaultRegExp = (str5, flags) => new RegExp(str5, flags);
4333
+ var defaultRegExp = (str6, flags) => new RegExp(str6, flags);
4334
4334
  defaultRegExp.code = "new RegExp";
4335
4335
  var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"];
4336
4336
  var EXT_SCOPE_NAMES = /* @__PURE__ */ new Set([
@@ -5125,16 +5125,16 @@ var require_ucs2length = __commonJS({
5125
5125
  "node_modules/ajv/dist/runtime/ucs2length.js"(exports) {
5126
5126
  "use strict";
5127
5127
  Object.defineProperty(exports, "__esModule", { value: true });
5128
- function ucs2length(str5) {
5129
- const len = str5.length;
5128
+ function ucs2length(str6) {
5129
+ const len = str6.length;
5130
5130
  let length = 0;
5131
5131
  let pos = 0;
5132
5132
  let value;
5133
5133
  while (pos < len) {
5134
5134
  length++;
5135
- value = str5.charCodeAt(pos++);
5135
+ value = str6.charCodeAt(pos++);
5136
5136
  if (value >= 55296 && value <= 56319 && pos < len) {
5137
- value = str5.charCodeAt(pos);
5137
+ value = str6.charCodeAt(pos);
5138
5138
  if ((value & 64512) === 56320)
5139
5139
  pos++;
5140
5140
  }
@@ -7017,8 +7017,8 @@ var require_formats = __commonJS({
7017
7017
  }
7018
7018
  var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
7019
7019
  var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
7020
- function date5(str5) {
7021
- const matches2 = DATE.exec(str5);
7020
+ function date5(str6) {
7021
+ const matches2 = DATE.exec(str6);
7022
7022
  if (!matches2)
7023
7023
  return false;
7024
7024
  const year = +matches2[1];
@@ -7037,8 +7037,8 @@ var require_formats = __commonJS({
7037
7037
  }
7038
7038
  var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
7039
7039
  function getTime(strictTimeZone) {
7040
- return function time3(str5) {
7041
- const matches2 = TIME.exec(str5);
7040
+ return function time3(str6) {
7041
+ const matches2 = TIME.exec(str6);
7042
7042
  if (!matches2)
7043
7043
  return false;
7044
7044
  const hr = +matches2[1];
@@ -7084,8 +7084,8 @@ var require_formats = __commonJS({
7084
7084
  var DATE_TIME_SEPARATOR = /t|\s/i;
7085
7085
  function getDateTime(strictTimeZone) {
7086
7086
  const time3 = getTime(strictTimeZone);
7087
- return function date_time(str5) {
7088
- const dateTime = str5.split(DATE_TIME_SEPARATOR);
7087
+ return function date_time(str6) {
7088
+ const dateTime = str6.split(DATE_TIME_SEPARATOR);
7089
7089
  return dateTime.length === 2 && date5(dateTime[0]) && time3(dateTime[1]);
7090
7090
  };
7091
7091
  }
@@ -7110,13 +7110,13 @@ var require_formats = __commonJS({
7110
7110
  }
7111
7111
  var NOT_URI_FRAGMENT = /\/|:/;
7112
7112
  var URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
7113
- function uri(str5) {
7114
- return NOT_URI_FRAGMENT.test(str5) && URI.test(str5);
7113
+ function uri(str6) {
7114
+ return NOT_URI_FRAGMENT.test(str6) && URI.test(str6);
7115
7115
  }
7116
7116
  var BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm;
7117
- function byte(str5) {
7117
+ function byte(str6) {
7118
7118
  BYTE.lastIndex = 0;
7119
- return BYTE.test(str5);
7119
+ return BYTE.test(str6);
7120
7120
  }
7121
7121
  var MIN_INT32 = -(2 ** 31);
7122
7122
  var MAX_INT32 = 2 ** 31 - 1;
@@ -7130,11 +7130,11 @@ var require_formats = __commonJS({
7130
7130
  return true;
7131
7131
  }
7132
7132
  var Z_ANCHOR = /[^\\]\\Z/;
7133
- function regex(str5) {
7134
- if (Z_ANCHOR.test(str5))
7133
+ function regex(str6) {
7134
+ if (Z_ANCHOR.test(str6))
7135
7135
  return false;
7136
7136
  try {
7137
- new RegExp(str5);
7137
+ new RegExp(str6);
7138
7138
  return true;
7139
7139
  } catch (e) {
7140
7140
  return false;
@@ -8095,14 +8095,14 @@ function promiseAllObject(promisesObj) {
8095
8095
  }
8096
8096
  function randomString(length = 10) {
8097
8097
  const chars = "abcdefghijklmnopqrstuvwxyz";
8098
- let str5 = "";
8098
+ let str6 = "";
8099
8099
  for (let i = 0; i < length; i++) {
8100
- str5 += chars[Math.floor(Math.random() * chars.length)];
8100
+ str6 += chars[Math.floor(Math.random() * chars.length)];
8101
8101
  }
8102
- return str5;
8102
+ return str6;
8103
8103
  }
8104
- function esc(str5) {
8105
- return JSON.stringify(str5);
8104
+ function esc(str6) {
8105
+ return JSON.stringify(str6);
8106
8106
  }
8107
8107
  function slugify(input2) {
8108
8108
  return input2.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
@@ -8216,8 +8216,8 @@ var primitiveTypes = /* @__PURE__ */ new Set([
8216
8216
  "symbol",
8217
8217
  "undefined"
8218
8218
  ]);
8219
- function escapeRegex(str5) {
8220
- return str5.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8219
+ function escapeRegex(str6) {
8220
+ return str6.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8221
8221
  }
8222
8222
  function clone(inst, def, params) {
8223
8223
  const cl = new inst._zod.constr(def ?? inst._zod.def);
@@ -8483,13 +8483,13 @@ function getSizableOrigin(input2) {
8483
8483
  return "unknown";
8484
8484
  }
8485
8485
  var highSurrogate = /[\uD800-\uDBFF]/;
8486
- function codePointLength(str5) {
8487
- const units = str5.length;
8488
- if (!highSurrogate.test(str5))
8486
+ function codePointLength(str6) {
8487
+ const units = str6.length;
8488
+ if (!highSurrogate.test(str6))
8489
8489
  return units;
8490
8490
  let count = units;
8491
8491
  for (let i = 0; i < units - 1; i++) {
8492
- if ((str5.charCodeAt(i) & 64512) === 55296 && (str5.charCodeAt(i + 1) & 64512) === 56320) {
8492
+ if ((str6.charCodeAt(i) & 64512) === 55296 && (str6.charCodeAt(i + 1) & 64512) === 56320) {
8493
8493
  count--;
8494
8494
  i++;
8495
8495
  }
@@ -28752,7 +28752,7 @@ async function canonical(p) {
28752
28752
  }
28753
28753
 
28754
28754
  // src/version.ts
28755
- var VERSION = "0.5.1";
28755
+ var VERSION = "0.6.0";
28756
28756
 
28757
28757
  // src/config.ts
28758
28758
  var ConfigError = class extends Error {
@@ -28817,6 +28817,7 @@ function loadConfig(env2, dirs, platform = process.platform) {
28817
28817
  legacyPlansEnv: get(env2, "ED_MCP_PLANS"),
28818
28818
  plansMarkdownDir: path3.join(dataDir, "plans"),
28819
28819
  prefsFile: path3.join(dataDir, "preferences.json"),
28820
+ activityFile: path3.join(dataDir, "activity.json"),
28820
28821
  secretsFile: syncPassword ? path3.join(dataDir, "secrets.enc") : path3.join(dirs.config, "secrets.json"),
28821
28822
  locksDir: path3.join(dirs.config, "locks"),
28822
28823
  cacheDir: dirs.cache,
@@ -29754,8 +29755,8 @@ var Catalog = class {
29754
29755
  ).join(", ")}`
29755
29756
  );
29756
29757
  }
29757
- const same4 = m.trade_group ? this.d.materials.filter((x) => x.kind === m.kind && x.trade_group === m.trade_group).sort((a, b) => a.grade - b.grade) : [m];
29758
- return { ...m, same_group: same4, trade_rules: TRADE_RULES };
29758
+ const same6 = m.trade_group ? this.d.materials.filter((x) => x.kind === m.kind && x.trade_group === m.trade_group).sort((a, b) => a.grade - b.grade) : [m];
29759
+ return { ...m, same_group: same6, trade_rules: TRADE_RULES };
29759
29760
  }
29760
29761
  /** Coriolis feature ranges, [min, max], for a blueprint id at a grade on a module group, or null. */
29761
29762
  blueprintFeatures(blueprintId2, grade, grp) {
@@ -31701,6 +31702,333 @@ function buildState(events) {
31701
31702
  };
31702
31703
  }
31703
31704
 
31705
+ // src/journal/bodies.ts
31706
+ var BODY_EVENTS = [
31707
+ "Scan",
31708
+ "FSSDiscoveryScan",
31709
+ "FSSAllBodiesFound",
31710
+ "FSSBodySignals",
31711
+ "SAASignalsFound",
31712
+ "SAAScanComplete",
31713
+ "ScanOrganic",
31714
+ "SellOrganicData",
31715
+ "Died"
31716
+ ];
31717
+ var num3 = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
31718
+ var str3 = (v) => typeof v === "string" && v !== "" ? v : null;
31719
+ var objs2 = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "object" && x !== null) : [];
31720
+ var same = (a, b) => a !== null && a.toLowerCase() === b.toLowerCase();
31721
+ function systemAddress(events, system) {
31722
+ for (let i = events.length - 1; i >= 0; i--) {
31723
+ const e = events[i];
31724
+ if (same(str3(e.StarSystem) ?? str3(e.SystemName), system)) {
31725
+ const address = num3(e.SystemAddress);
31726
+ if (address !== null) return address;
31727
+ }
31728
+ }
31729
+ return null;
31730
+ }
31731
+ function signalsOf(e) {
31732
+ return objs2(e.Signals).map((s) => ({
31733
+ type: str3(s.Type_Localised) ?? str3(s.Type) ?? "unknown",
31734
+ count: num3(s.Count) ?? 0
31735
+ }));
31736
+ }
31737
+ function bioCount(e) {
31738
+ return objs2(e.Signals).filter((s) => typeof s.Type === "string" && s.Type.endsWith("SignalType_Biological;")).reduce((n, s) => n + (num3(s.Count) ?? 0), 0);
31739
+ }
31740
+ function bodyNames(events, address) {
31741
+ const names = /* @__PURE__ */ new Map();
31742
+ for (const e of events) {
31743
+ if (num3(e.SystemAddress) !== address) continue;
31744
+ const id = num3(e.BodyID);
31745
+ const name = str3(e.BodyName);
31746
+ if (id !== null && name) names.set(id, name);
31747
+ }
31748
+ return names;
31749
+ }
31750
+ function bodyMatches(name, system, wanted) {
31751
+ if (!name) return false;
31752
+ if (same(name, wanted)) return true;
31753
+ return system !== null && same(name.slice(system.length).trim(), wanted);
31754
+ }
31755
+ function exobiology(events, address, opts = {}) {
31756
+ const names = bodyNames(events, address);
31757
+ const bodies = /* @__PURE__ */ new Map();
31758
+ const body = (id) => {
31759
+ let b = bodies.get(id);
31760
+ if (!b) bodies.set(id, b = { bio: null, genera: /* @__PURE__ */ new Map(), species: /* @__PURE__ */ new Map() });
31761
+ return b;
31762
+ };
31763
+ let unsold = 0;
31764
+ let active2 = null;
31765
+ const drop = () => {
31766
+ if (active2?.species && !active2.species.analysed) {
31767
+ active2.species.samples = 0;
31768
+ active2.species.stage = "0/3";
31769
+ active2.species.abandoned = true;
31770
+ }
31771
+ active2 = null;
31772
+ };
31773
+ for (const e of events) {
31774
+ switch (e.event) {
31775
+ case "SellOrganicData":
31776
+ unsold = 0;
31777
+ break;
31778
+ case "Died":
31779
+ unsold = 0;
31780
+ drop();
31781
+ break;
31782
+ case "ScanOrganic": {
31783
+ if (e.ScanType === "Analyse") unsold++;
31784
+ const key = `${num3(e.SystemAddress)}|${num3(e.Body)}|${str3(e.Species)}`;
31785
+ if (e.ScanType === "Log" && active2 && active2.key !== key) drop();
31786
+ active2 = e.ScanType === "Analyse" ? null : { key, species: active2?.key === key ? active2.species : void 0 };
31787
+ break;
31788
+ }
31789
+ }
31790
+ if (num3(e.SystemAddress) !== address) continue;
31791
+ switch (e.event) {
31792
+ case "FSSBodySignals":
31793
+ case "SAASignalsFound": {
31794
+ const id = num3(e.BodyID);
31795
+ if (id === null) break;
31796
+ const count = bioCount(e);
31797
+ if (count > 0 || bodies.has(id)) body(id).bio = count;
31798
+ for (const g of objs2(e.Genuses)) {
31799
+ const key = str3(g.Genus);
31800
+ if (key) body(id).genera.set(key, str3(g.Genus_Localised) ?? key);
31801
+ }
31802
+ break;
31803
+ }
31804
+ case "ScanOrganic": {
31805
+ const id = num3(e.Body);
31806
+ const key = str3(e.Species);
31807
+ if (id === null || !key) break;
31808
+ const b = body(id);
31809
+ const genusKey = str3(e.Genus) ?? "";
31810
+ const s = b.species.get(key) ?? {
31811
+ genus: str3(e.Genus_Localised) ?? genusKey,
31812
+ genusKey,
31813
+ species: str3(e.Species_Localised) ?? key,
31814
+ variant: null,
31815
+ samples: 0,
31816
+ stage: "",
31817
+ analysed: false
31818
+ };
31819
+ s.variant = str3(e.Variant_Localised) ?? s.variant;
31820
+ if (e.ScanType === "Log") {
31821
+ if (!s.analysed) s.samples = 1;
31822
+ delete s.abandoned;
31823
+ } else if (e.ScanType === "Sample") s.samples = Math.min(3, Math.max(2, s.samples + 1));
31824
+ else if (e.ScanType === "Analyse") {
31825
+ s.samples = 3;
31826
+ s.analysed = true;
31827
+ }
31828
+ s.stage = `${s.samples}/3`;
31829
+ b.species.set(key, s);
31830
+ if (active2 && e.ScanType !== "Analyse") active2.species = s;
31831
+ if (genusKey && !b.genera.has(genusKey)) b.genera.set(genusKey, s.genus);
31832
+ break;
31833
+ }
31834
+ }
31835
+ }
31836
+ const out = [];
31837
+ for (const [id, b] of bodies) {
31838
+ const name = names.get(id) ?? `Body ${id}`;
31839
+ if (opts.body && !bodyMatches(name, opts.system ?? null, opts.body)) continue;
31840
+ const sampled = new Set(
31841
+ [...b.species.values()].filter((s) => !s.abandoned).map((s) => s.genusKey)
31842
+ );
31843
+ out.push({
31844
+ body: name,
31845
+ body_id: id,
31846
+ bio_signals: b.bio,
31847
+ genera: [...b.genera.values()],
31848
+ species: [...b.species.values()].map(({ genusKey: _, ...s }) => s),
31849
+ not_started: [...b.genera].filter(([k]) => !sampled.has(k)).map(([, v]) => v)
31850
+ });
31851
+ }
31852
+ out.sort((a, b) => a.body_id - b.body_id);
31853
+ return { bodies: out, unsold_analysed: unsold };
31854
+ }
31855
+ function exploration(events, address) {
31856
+ const discovery = {
31857
+ body_count: null,
31858
+ non_body_count: null,
31859
+ progress: null,
31860
+ all_bodies_found: false
31861
+ };
31862
+ const scans = /* @__PURE__ */ new Map();
31863
+ const mapped = /* @__PURE__ */ new Set();
31864
+ const signals = /* @__PURE__ */ new Map();
31865
+ for (const e of events) {
31866
+ if (num3(e.SystemAddress) !== address) continue;
31867
+ const id = num3(e.BodyID);
31868
+ switch (e.event) {
31869
+ case "FSSDiscoveryScan":
31870
+ discovery.body_count = num3(e.BodyCount) ?? discovery.body_count;
31871
+ discovery.non_body_count = num3(e.NonBodyCount) ?? discovery.non_body_count;
31872
+ discovery.progress = num3(e.Progress) ?? discovery.progress;
31873
+ break;
31874
+ case "FSSAllBodiesFound":
31875
+ discovery.all_bodies_found = true;
31876
+ discovery.body_count = num3(e.Count) ?? discovery.body_count;
31877
+ break;
31878
+ case "Scan":
31879
+ if (id !== null) scans.set(id, e);
31880
+ break;
31881
+ case "SAAScanComplete":
31882
+ if (id !== null) mapped.add(id);
31883
+ break;
31884
+ case "FSSBodySignals":
31885
+ case "SAASignalsFound":
31886
+ if (id !== null) signals.set(id, signalsOf(e));
31887
+ break;
31888
+ }
31889
+ }
31890
+ const bodies = [...scans].map(([id, s]) => {
31891
+ const distance2 = num3(s.DistanceFromArrivalLS);
31892
+ return {
31893
+ body: str3(s.BodyName) ?? `Body ${id}`,
31894
+ body_id: id,
31895
+ // Belt clusters have neither a planet class nor a star type.
31896
+ type: str3(s.PlanetClass) ?? (str3(s.StarType) ? `${s.StarType} star` : null),
31897
+ distance_ls: distance2 === null ? null : Math.round(distance2),
31898
+ landable: s.Landable === true,
31899
+ terraformable: s.TerraformState === "Terraformable",
31900
+ atmosphere: str3(s.Atmosphere),
31901
+ rings: objs2(s.Rings).map((r) => ({
31902
+ name: str3(r.Name) ?? "unknown",
31903
+ class: str3(r.RingClass)?.replace(/^eRingClass_/, "") ?? null
31904
+ })),
31905
+ was_discovered: typeof s.WasDiscovered === "boolean" ? s.WasDiscovered : null,
31906
+ was_mapped: typeof s.WasMapped === "boolean" ? s.WasMapped : null,
31907
+ mapped_by_you: mapped.has(id),
31908
+ signals: signals.get(id) ?? []
31909
+ };
31910
+ });
31911
+ bodies.sort((a, b) => (a.distance_ls ?? Infinity) - (b.distance_ls ?? Infinity));
31912
+ return { discovery, scanned: bodies.length, bodies };
31913
+ }
31914
+
31915
+ // src/journal/events.ts
31916
+ var MAX_ARRAY = 20;
31917
+ function truncate(value) {
31918
+ if (Array.isArray(value)) {
31919
+ const kept = value.slice(0, MAX_ARRAY).map(truncate);
31920
+ if (value.length > MAX_ARRAY) kept.push(`(${value.length - MAX_ARRAY} more not shown)`);
31921
+ return kept;
31922
+ }
31923
+ if (value && typeof value === "object") {
31924
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, truncate(v)]));
31925
+ }
31926
+ return value;
31927
+ }
31928
+ var LOCATION_EVENTS = /* @__PURE__ */ new Set([
31929
+ "Location",
31930
+ "FSDJump",
31931
+ "CarrierJump",
31932
+ "Docked"
31933
+ ]);
31934
+ function systemAt(locations, timestamp) {
31935
+ for (let i = locations.length - 1; i >= 0; i--) {
31936
+ const e = locations[i];
31937
+ if (e.timestamp <= timestamp && typeof e.StarSystem === "string") return e.StarSystem;
31938
+ }
31939
+ return null;
31940
+ }
31941
+ function matchFile(events, filter, start = null) {
31942
+ const names = filter.events?.length ? new Set(filter.events.map((n) => n.toLowerCase())) : void 0;
31943
+ const system = filter.system?.toLowerCase();
31944
+ let current = start?.toLowerCase() ?? null;
31945
+ const out = [];
31946
+ for (const e of events) {
31947
+ if (LOCATION_EVENTS.has(e.event) && typeof e.StarSystem === "string") {
31948
+ current = e.StarSystem.toLowerCase();
31949
+ }
31950
+ if (names && !names.has(e.event.toLowerCase())) continue;
31951
+ if (filter.since !== void 0 && !(Date.parse(e.timestamp) >= filter.since)) continue;
31952
+ if (system !== void 0) {
31953
+ const own2 = e.event === "StartJump" ? void 0 : e.StarSystem ?? e.SystemName;
31954
+ const at = typeof own2 === "string" ? own2.toLowerCase() : current;
31955
+ if (at !== system) continue;
31956
+ }
31957
+ out.push(e);
31958
+ }
31959
+ return out;
31960
+ }
31961
+
31962
+ // src/journal/massacre.ts
31963
+ var MASSACRE_EVENTS = [
31964
+ "Bounty",
31965
+ "FactionKillBond",
31966
+ "MissionRedirected"
31967
+ ];
31968
+ var same2 = (a, b) => a.toLowerCase() === b.toLowerCase();
31969
+ function massacre(missions, events) {
31970
+ const tracked = missions.filter((m) => (m.kill_count ?? 0) > 0 && m.target_faction && m.accepted).map((m) => ({
31971
+ mission: m,
31972
+ out: {
31973
+ id: m.id,
31974
+ name: m.name,
31975
+ accepted: m.accepted,
31976
+ expires: m.expires,
31977
+ reward: m.reward,
31978
+ kills_required: m.kill_count,
31979
+ kills_counted: 0,
31980
+ kills_left: m.kill_count,
31981
+ done: false
31982
+ }
31983
+ })).sort((a, b) => a.out.accepted.localeCompare(b.out.accepted) || a.out.id - b.out.id);
31984
+ const uncounted = missions.filter((m) => m.details_missing && /massacre/i.test(m.name));
31985
+ const byId = new Map(tracked.map((t) => [t.out.id, t]));
31986
+ for (const e of events) {
31987
+ if (e.event === "MissionRedirected") {
31988
+ const t = byId.get(typeof e.MissionID === "number" ? e.MissionID : -1);
31989
+ if (t?.mission.status === "complete") t.out.done = true;
31990
+ continue;
31991
+ }
31992
+ if (e.event !== "Bounty" && e.event !== "FactionKillBond") continue;
31993
+ const victim = typeof e.VictimFaction === "string" ? e.VictimFaction : null;
31994
+ if (!victim) continue;
31995
+ const credited = /* @__PURE__ */ new Set();
31996
+ for (const { mission, out } of tracked) {
31997
+ const giver = mission.faction ?? "";
31998
+ if (credited.has(giver) || out.done || out.accepted > e.timestamp) continue;
31999
+ if (!same2(mission.target_faction, victim)) continue;
32000
+ out.kills_counted++;
32001
+ credited.add(giver);
32002
+ if (out.kills_counted >= out.kills_required) out.done = true;
32003
+ }
32004
+ }
32005
+ const groups = /* @__PURE__ */ new Map();
32006
+ for (const { mission, out } of tracked) {
32007
+ if (mission.status === "complete") out.done = true;
32008
+ out.kills_left = out.done ? 0 : out.kills_required - out.kills_counted;
32009
+ const target = mission.target_faction;
32010
+ const giver = mission.faction ?? "unknown";
32011
+ const byGiver = groups.get(target) ?? /* @__PURE__ */ new Map();
32012
+ groups.set(target, byGiver);
32013
+ byGiver.set(giver, [...byGiver.get(giver) ?? [], out]);
32014
+ }
32015
+ return {
32016
+ groups: [...groups].map(([target, byGiver]) => {
32017
+ const factions = [...byGiver].map(([faction, list2]) => ({
32018
+ faction,
32019
+ kills_left: list2.reduce((n, m) => n + m.kills_left, 0),
32020
+ missions: list2
32021
+ }));
32022
+ return {
32023
+ target_faction: target,
32024
+ kills_left: Math.max(0, ...factions.map((f) => f.kills_left)),
32025
+ factions
32026
+ };
32027
+ }),
32028
+ uncounted
32029
+ };
32030
+ }
32031
+
31704
32032
  // src/journal/service.ts
31705
32033
  function errText(err) {
31706
32034
  return err.code ?? err.message ?? String(err);
@@ -31712,6 +32040,12 @@ function staleWarning(minutes2) {
31712
32040
  var STATE_WINDOW_MS = 14 * 864e5;
31713
32041
  var FRESH_MS = 864e5;
31714
32042
  var MEMO_MS = 1e3;
32043
+ var RAW_CACHE_FILES = 2;
32044
+ var KEPT_EVENTS = /* @__PURE__ */ new Set([
32045
+ ...STATE_EVENTS,
32046
+ ...BODY_EVENTS,
32047
+ ...MASSACRE_EVENTS
32048
+ ]);
31715
32049
  var STATION_FILES = {
31716
32050
  market: "Market.json",
31717
32051
  outfitting: "Outfitting.json",
@@ -31720,11 +32054,12 @@ var STATION_FILES = {
31720
32054
  var JournalService = class {
31721
32055
  constructor(dir, opts = {}) {
31722
32056
  this.dir = dir;
31723
- this.reader = opts.reader ?? new JournalReader({ keep: STATE_EVENTS });
32057
+ this.reader = opts.reader ?? new JournalReader({ keep: KEPT_EVENTS });
31724
32058
  this.now = opts.now ?? Date.now;
31725
32059
  }
31726
32060
  dir;
31727
32061
  reader;
32062
+ rawReader = new JournalReader();
31728
32063
  now;
31729
32064
  // Timed with performance.now so an injected test clock cannot freeze it.
31730
32065
  memo;
@@ -31850,7 +32185,37 @@ var JournalService = class {
31850
32185
  const ageMinutes = Number.isNaN(age) ? null : Math.max(0, Math.round(age / 6e4));
31851
32186
  if (ageMinutes !== null && age > STALE_MS) warnings.push(staleWarning(ageMinutes));
31852
32187
  const loadout = events.findLast((e) => e.event === "Loadout") ?? null;
31853
- return { ...state, age_minutes: ageMinutes, warnings, loadout };
32188
+ return { ...state, age_minutes: ageMinutes, warnings, loadout, events };
32189
+ }
32190
+ /**
32191
+ * Every recorded event that matches, newest first, from the state window. Files are read newest
32192
+ * first and reading stops once the limit is reached or the files are older than since.
32193
+ */
32194
+ async recentEvents(filter, limit) {
32195
+ const found = await this.files(STATE_WINDOW_MS);
32196
+ if (!("files" in found)) throw new UserError(`${found.missing}. Run journal_status for help.`);
32197
+ if (found.error) throw new UserError(`${found.error}. Run journal_status for help.`);
32198
+ const warnings = [];
32199
+ const out = [];
32200
+ const locations = filter.system ? (await this.state()).events.filter((e) => LOCATION_EVENTS.has(e.event)) : [];
32201
+ for (const file2 of [...found.files].reverse()) {
32202
+ if (out.length >= limit) break;
32203
+ let events;
32204
+ try {
32205
+ events = await this.rawReader.read(file2);
32206
+ } catch (err) {
32207
+ warnings.push(`Cannot read ${file2.name}: ${errText(err)}`);
32208
+ continue;
32209
+ }
32210
+ const first = events[0];
32211
+ const start = first && filter.system ? systemAt(locations, first.timestamp) : null;
32212
+ const matched = matchFile(events, filter, start);
32213
+ for (let i = matched.length - 1; i >= 0 && out.length < limit; i--) out.push(matched[i]);
32214
+ const last = events.at(-1);
32215
+ if (filter.since !== void 0 && last && Date.parse(last.timestamp) < filter.since) break;
32216
+ }
32217
+ this.rawReader.retain(found.files.slice(-RAW_CACHE_FILES).map((f) => f.path));
32218
+ return { events: out, warnings };
31854
32219
  }
31855
32220
  // Newer game builds keep the cargo list here instead of in the Cargo event. Syncthing can
31856
32221
  // deliver the logs before this file, so a file older than the newest Cargo event is ignored.
@@ -31894,16 +32259,16 @@ var JournalService = class {
31894
32259
  const f = await this.stationFile("market");
31895
32260
  if (!f) return null;
31896
32261
  const items = Array.isArray(f.data.Items) ? f.data.Items : [];
31897
- const num6 = (v) => typeof v === "number" ? v : 0;
32262
+ const num7 = (v) => typeof v === "number" ? v : 0;
31898
32263
  const rows = [];
31899
32264
  for (const i of items) {
31900
32265
  const name = i.Name_Localised ?? i.Name;
31901
32266
  if (typeof name !== "string") continue;
31902
32267
  rows.push({
31903
32268
  commodity: name,
31904
- buy_price: num6(i.BuyPrice),
31905
- sell_price: num6(i.SellPrice),
31906
- supply: num6(i.Stock),
32269
+ buy_price: num7(i.BuyPrice),
32270
+ sell_price: num7(i.SellPrice),
32271
+ supply: num7(i.Stock),
31907
32272
  demand: marketDemand(i)
31908
32273
  });
31909
32274
  }
@@ -32176,7 +32541,10 @@ function mapSystem(raw) {
32176
32541
  name: s.name,
32177
32542
  type: s.type ?? null,
32178
32543
  pad: stationPad(s),
32179
- arrival_ls: s.distance_to_arrival ?? null
32544
+ arrival_ls: s.distance_to_arrival ?? null,
32545
+ // System search gives service names; station search gives { name } objects.
32546
+ services: (s.services ?? []).map((x2) => typeof x2 === "string" ? x2 : x2.name),
32547
+ owner: s.controlling_minor_faction ?? null
32180
32548
  })),
32181
32549
  stars: (raw.bodies ?? []).filter((b) => b.type === "Star").map((b) => ({ name: b.name, arrival_ls: b.distance_to_arrival ?? 0 }))
32182
32550
  };
@@ -32326,6 +32694,7 @@ var SpanshClient = class {
32326
32694
  this.systemCache = opts.systemCache ?? cache;
32327
32695
  this.marketCache = opts.marketCache ?? cache;
32328
32696
  this.sweepCache = opts.sweepCache ?? new SweepCache();
32697
+ this.warCache = opts.warCache ?? cache;
32329
32698
  }
32330
32699
  http;
32331
32700
  cache;
@@ -32337,11 +32706,13 @@ var SpanshClient = class {
32337
32706
  systemCache;
32338
32707
  marketCache;
32339
32708
  sweepCache;
32709
+ warCache;
32340
32710
  // Job ids by route form, so a call after a timeout collects the job instead of resubmitting.
32341
32711
  routeJobs = /* @__PURE__ */ new Map();
32342
32712
  tradeJobs = /* @__PURE__ */ new Map();
32343
32713
  tradeSubmits = /* @__PURE__ */ new Map();
32344
32714
  sweeps = /* @__PURE__ */ new Map();
32715
+ warSearches = /* @__PURE__ */ new Map();
32345
32716
  gate = Promise.resolve();
32346
32717
  lastStart = 0;
32347
32718
  async resolveShip(name) {
@@ -32522,6 +32893,85 @@ var SpanshClient = class {
32522
32893
  this.sweepCache.savePartial({ key, centre, coveredLy, systems, total });
32523
32894
  return { centre, systems, total, partial: partial2, coveredLy };
32524
32895
  }
32896
+ /** Systems within radiusLy of near with a faction at war and a Frontline Solutions station. */
32897
+ async frontlineWarSystems(near, radiusLy, opts = {}) {
32898
+ const centre = (await this.system(near, 0)).coords;
32899
+ const key = `wars:${near.toLowerCase()}:${radiusLy}`;
32900
+ const hit = this.warCache.get(key);
32901
+ if (hit) return { centre, systems: hit, partial: false };
32902
+ let running = this.warSearches.get(key);
32903
+ if (!running) {
32904
+ running = this.warPages(near, radiusLy, centre, key, opts).finally(
32905
+ () => this.warSearches.delete(key)
32906
+ );
32907
+ this.warSearches.set(key, running);
32908
+ }
32909
+ return running;
32910
+ }
32911
+ async warPages(near, radiusLy, centre, key, opts) {
32912
+ const systems = [];
32913
+ const seen = /* @__PURE__ */ new Set();
32914
+ let partial2 = false;
32915
+ for (let page2 = 0; ; page2++) {
32916
+ if (page2 > 0 && opts.deadline !== void 0 && this.now() >= opts.deadline) {
32917
+ partial2 = true;
32918
+ break;
32919
+ }
32920
+ const body = {
32921
+ filters: {
32922
+ distance: { min: 0, max: radiusLy },
32923
+ minor_faction_presences: [{ state: { value: ["War", "Civil War"] } }],
32924
+ stations: [{ services: { value: ["Frontline Solutions"] } }]
32925
+ },
32926
+ sort: [{ distance: { direction: "asc" } }],
32927
+ size: SWEEP_PAGE_SIZE,
32928
+ page: page2,
32929
+ reference_system: near
32930
+ };
32931
+ let res;
32932
+ try {
32933
+ res = await this.post("/systems/search", JSON.stringify(body), "application/json");
32934
+ } catch (err) {
32935
+ if (err instanceof SpanshError && err.status === 400) {
32936
+ throw new UserError(
32937
+ `Spansh rejected the search (${err.detail}). Check that "${near}" is an exact system name (spansh_system).`
32938
+ );
32939
+ }
32940
+ throw err;
32941
+ }
32942
+ const results = res.results ?? [];
32943
+ for (const raw of results) {
32944
+ const s = mapSystem(raw);
32945
+ if (s && !seen.has(s.id64)) {
32946
+ seen.add(s.id64);
32947
+ systems.push(s);
32948
+ }
32949
+ }
32950
+ if (results.length < SWEEP_PAGE_SIZE) break;
32951
+ }
32952
+ if (!partial2) this.warCache.set(key, systems);
32953
+ return { centre, systems, partial: partial2 };
32954
+ }
32955
+ /** The bodies of a system, with the stations and settlements on each. Bodies rarely change. */
32956
+ async systemBodies(id64) {
32957
+ return await this.systemCache.getOrLoad(
32958
+ `bodies:${id64}`,
32959
+ async () => {
32960
+ const raw = await this.get(`/dump/${id64}`);
32961
+ const bodies = raw.system?.bodies ?? raw.bodies ?? [];
32962
+ return bodies.map((b) => ({
32963
+ name: b.name,
32964
+ bodyId: b.bodyId,
32965
+ subType: b.subType ?? null,
32966
+ arrival_ls: b.distanceToArrival ?? 0,
32967
+ parentPlanetId: (b.parents ?? []).map((p) => p.Planet).find((x) => x !== void 0) ?? null,
32968
+ stations: (b.stations ?? []).map((s) => s.name)
32969
+ }));
32970
+ },
32971
+ // An empty list is probably a dump that was not ready yet; fetch it again next time.
32972
+ { keep: (b) => b.length > 0 }
32973
+ );
32974
+ }
32525
32975
  async route(q, opts = {}) {
32526
32976
  const form = new URLSearchParams({
32527
32977
  from: q.from,
@@ -32923,10 +33373,8 @@ function mapRoute(r) {
32923
33373
  };
32924
33374
  }
32925
33375
 
32926
- // src/plans/store.ts
32927
- import { randomBytes as randomBytes2 } from "crypto";
32928
- import { lstat as lstat2, mkdir as mkdir3, rename as rename3, rm, writeFile as writeFile3 } from "fs/promises";
32929
- import path8 from "path";
33376
+ // src/activity/store.ts
33377
+ import { lstat as lstat2, mkdir as mkdir3, unlink as unlink2 } from "fs/promises";
32930
33378
 
32931
33379
  // src/fs/lock.ts
32932
33380
  import { createHash as createHash2, randomUUID } from "crypto";
@@ -33090,6 +33538,276 @@ async function failure2(err, file2) {
33090
33538
  return { kind: "unsafe", reason: `not readable (${code ?? "unknown error"})` };
33091
33539
  }
33092
33540
 
33541
+ // src/plans/schema.ts
33542
+ var ItemKind = external_exports.enum([
33543
+ "buy",
33544
+ "engineer",
33545
+ "experimental",
33546
+ "unlock_engineer",
33547
+ "gather",
33548
+ "trade",
33549
+ "refit"
33550
+ ]);
33551
+ var ItemStatus = external_exports.enum(["todo", "in_progress", "done", "skipped"]);
33552
+ var PlanItemSchema = external_exports.object({
33553
+ id: external_exports.string().min(1),
33554
+ kind: ItemKind,
33555
+ description: external_exports.string().min(1),
33556
+ status: ItemStatus.default("todo"),
33557
+ station: external_exports.string().optional(),
33558
+ engineer: external_exports.string().optional(),
33559
+ materials: external_exports.array(external_exports.object({ name: external_exports.string().min(1), count: external_exports.number().int().positive() })).optional(),
33560
+ notes: external_exports.string().optional()
33561
+ });
33562
+ var StopKind = external_exports.enum([
33563
+ "shop",
33564
+ "trader",
33565
+ "engineer",
33566
+ "exobiology",
33567
+ "exploration",
33568
+ "combat",
33569
+ "mission",
33570
+ "hand_in",
33571
+ "trade",
33572
+ "mining",
33573
+ "fuel",
33574
+ "other"
33575
+ ]);
33576
+ var StopStatus = external_exports.enum(["todo", "done", "skipped"]);
33577
+ var StopSchema = external_exports.object({
33578
+ system: external_exports.string().min(1),
33579
+ // Older files can hold station "", which named nothing; it reads as no station.
33580
+ station: external_exports.string().transform((v) => v === "" ? void 0 : v).optional(),
33581
+ body: external_exports.string().min(1).optional(),
33582
+ purpose: external_exports.string().min(1),
33583
+ label: external_exports.string().min(1).optional(),
33584
+ kind: StopKind.optional(),
33585
+ after: external_exports.array(external_exports.string().min(1)).optional(),
33586
+ status: StopStatus.default("todo"),
33587
+ notes: external_exports.string().optional()
33588
+ });
33589
+ var PlanRouteSchema = external_exports.object({
33590
+ computed_at: external_exports.string(),
33591
+ from: external_exports.string(),
33592
+ stops: external_exports.array(external_exports.string()),
33593
+ total_min: external_exports.number(),
33594
+ plotted: external_exports.boolean(),
33595
+ // For each entry of stops, the indexes in the plan's stops that it visits. Names alone do not
33596
+ // map back: an engineer stop takes the engineer's name, and repeated names get -2, -3.
33597
+ // Optional, so routes saved before it load.
33598
+ stop_indexes: external_exports.array(external_exports.array(external_exports.number().int().min(0))).optional()
33599
+ });
33600
+ var PlanId = external_exports.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, "id must be lowercase letters, digits and dashes");
33601
+ var PlanInputSchema = external_exports.object({
33602
+ id: PlanId,
33603
+ name: external_exports.string().min(1),
33604
+ target_build: external_exports.unknown().optional(),
33605
+ items: external_exports.array(PlanItemSchema).default([]),
33606
+ stops: external_exports.array(StopSchema).default([]),
33607
+ route: PlanRouteSchema.optional(),
33608
+ notes: external_exports.string().optional()
33609
+ });
33610
+ var PlanSchema = PlanInputSchema.extend({
33611
+ created_at: external_exports.string(),
33612
+ updated_at: external_exports.string()
33613
+ });
33614
+ var PlanFileSchema = external_exports.object({
33615
+ version: external_exports.literal(1),
33616
+ plans: external_exports.array(PlanSchema)
33617
+ });
33618
+
33619
+ // src/activity/schema.ts
33620
+ var ActivityKind = external_exports.enum([
33621
+ "exobiology",
33622
+ "exploration",
33623
+ "massacre",
33624
+ "missions",
33625
+ "hauling",
33626
+ "trading",
33627
+ "mining",
33628
+ "engineering",
33629
+ "combat",
33630
+ "other"
33631
+ ]);
33632
+ var MAX_SUMMARY = 500;
33633
+ var ActivityInputSchema = external_exports.object({
33634
+ kind: ActivityKind,
33635
+ summary: external_exports.string().trim().min(1).max(MAX_SUMMARY),
33636
+ // Bounded like the other fields, so the note cannot outgrow the size limit for data files.
33637
+ plan_id: PlanId.optional()
33638
+ });
33639
+ var ActivitySchema = ActivityInputSchema.extend({
33640
+ updated_at: external_exports.string()
33641
+ });
33642
+ var ActivityFileSchema = ActivitySchema.extend({ version: external_exports.literal(1) });
33643
+
33644
+ // src/activity/store.ts
33645
+ var LOCK_TIMEOUT_MS = 5e3;
33646
+ var LOCKED = "Activity file is locked by another process; try again";
33647
+ var ActivityStore = class {
33648
+ constructor(file2, now = () => /* @__PURE__ */ new Date(), opts = {}) {
33649
+ this.file = file2;
33650
+ this.now = now;
33651
+ this.lockTimeoutMs = opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS;
33652
+ this.lockDir = opts.lockDir;
33653
+ this.guard = opts.guard;
33654
+ }
33655
+ file;
33656
+ now;
33657
+ queue = Promise.resolve();
33658
+ lockTimeoutMs;
33659
+ lockDir;
33660
+ guard;
33661
+ /** The saved note, or null when there is none. */
33662
+ async get() {
33663
+ await this.guard?.();
33664
+ const read2 = await readRegularText(this.file, MAX_DATA_FILE);
33665
+ if (read2.kind === "missing") return null;
33666
+ if (read2.kind === "unsafe") {
33667
+ throw new UserError(`Activity file ${this.file} is invalid: ${read2.reason}`);
33668
+ }
33669
+ let json3;
33670
+ try {
33671
+ json3 = JSON.parse(read2.text);
33672
+ } catch {
33673
+ throw new UserError(`Activity file ${this.file} is invalid: not JSON`);
33674
+ }
33675
+ const r = ActivityFileSchema.safeParse(json3);
33676
+ if (!r.success) {
33677
+ throw new UserError(`Activity file ${this.file} is invalid: ${external_exports.prettifyError(r.error)}`);
33678
+ }
33679
+ const { kind: kind2, summary, plan_id, updated_at } = r.data;
33680
+ return { kind: kind2, summary, ...plan_id === void 0 ? {} : { plan_id }, updated_at };
33681
+ }
33682
+ /** Replaces the note. */
33683
+ set(input2) {
33684
+ return this.serial(async () => {
33685
+ const parsed = ActivityInputSchema.safeParse(input2);
33686
+ if (!parsed.success) {
33687
+ throw new UserError(`Invalid activity: ${external_exports.prettifyError(parsed.error)}`);
33688
+ }
33689
+ const activity = { ...parsed.data, updated_at: this.now().toISOString() };
33690
+ await this.guard?.();
33691
+ return lockedWrite(
33692
+ this.file,
33693
+ { timeoutMs: this.lockTimeoutMs, lockDir: this.lockDir, lockedMessage: LOCKED },
33694
+ async () => ({
33695
+ content: JSON.stringify({ version: 1, ...activity }, null, 2),
33696
+ result: activity
33697
+ })
33698
+ );
33699
+ });
33700
+ }
33701
+ /** Deletes the note. Returns false when there was none. */
33702
+ clear() {
33703
+ return this.serial(async () => {
33704
+ await this.guard?.();
33705
+ const missing = await lstat2(this.file).then(
33706
+ () => false,
33707
+ (err) => {
33708
+ if (err.code === "ENOENT") return true;
33709
+ throw err;
33710
+ }
33711
+ );
33712
+ if (missing) return false;
33713
+ if (this.lockDir) await mkdir3(this.lockDir, { recursive: true });
33714
+ const release = await acquireLock(this.file, this.lockTimeoutMs, LOCKED, this.lockDir);
33715
+ try {
33716
+ await unlink2(this.file);
33717
+ return true;
33718
+ } catch (err) {
33719
+ const code = err.code;
33720
+ if (code === "ENOENT") return false;
33721
+ if (code === "EPERM" || code === "EACCES" || code === "EBUSY") {
33722
+ throw new UserError("Activity file is in use by another program; try again");
33723
+ }
33724
+ throw err;
33725
+ } finally {
33726
+ await release();
33727
+ }
33728
+ });
33729
+ }
33730
+ // One change at a time in this process; the lock file covers other processes.
33731
+ serial(fn) {
33732
+ const run = this.queue.then(fn);
33733
+ this.queue = run.catch(() => void 0);
33734
+ return run;
33735
+ }
33736
+ };
33737
+
33738
+ // src/plans/store.ts
33739
+ import { randomBytes as randomBytes2 } from "crypto";
33740
+ import { lstat as lstat3, mkdir as mkdir4, rename as rename3, rm, writeFile as writeFile3 } from "fs/promises";
33741
+ import path8 from "path";
33742
+
33743
+ // src/plans/stops.ts
33744
+ var lc = (s) => s.toLowerCase();
33745
+ function stopName(s) {
33746
+ return s.label ?? s.station ?? s.body ?? s.system;
33747
+ }
33748
+ function namesOf(s) {
33749
+ return [s.label, s.station, s.body, s.system].flatMap((n) => n ? [lc(n)] : []);
33750
+ }
33751
+ function visitOrder(plan) {
33752
+ const all = plan.stops.map((_, i) => i);
33753
+ const route = plan.route;
33754
+ if (!route) return all;
33755
+ const used = /* @__PURE__ */ new Set();
33756
+ const order = [];
33757
+ const take = (i) => {
33758
+ if (i === void 0 || used.has(i) || i >= plan.stops.length) return false;
33759
+ used.add(i);
33760
+ order.push(i);
33761
+ return true;
33762
+ };
33763
+ if (route.stop_indexes && route.stop_indexes.length === route.stops.length) {
33764
+ for (const entry2 of route.stop_indexes) for (const i of entry2) take(i);
33765
+ } else {
33766
+ const primary = plan.stops.map((s) => lc(stopName(s)));
33767
+ for (const raw of route.stops) {
33768
+ const name = lc(raw);
33769
+ if (take(all.find((j) => !used.has(j) && primary[j] === name))) continue;
33770
+ const m = /^(.*)-(\d+)$/.exec(name);
33771
+ if (m && take(all.filter((j) => primary[j] === m[1])[Number(m[2]) - 1])) continue;
33772
+ take(all.find((j) => !used.has(j) && namesOf(plan.stops[j]).includes(name)));
33773
+ }
33774
+ }
33775
+ return [...order, ...all.filter((i) => !used.has(i))];
33776
+ }
33777
+ function nextStop(plan) {
33778
+ const i = visitOrder(plan).find((j) => plan.stops[j].status === "todo");
33779
+ return i === void 0 ? null : { index: i, ...plan.stops[i] };
33780
+ }
33781
+ function findStop(plan, ref) {
33782
+ if (ref.label === void 0 && ref.index === void 0) {
33783
+ throw new UserError("Give the stop's label or its index");
33784
+ }
33785
+ if (ref.label !== void 0 && ref.index !== void 0) {
33786
+ throw new UserError("Give the stop's label or its index, not both");
33787
+ }
33788
+ if (plan.stops.length === 0) throw new UserError(`Plan "${plan.id}" has no stops.`);
33789
+ if (ref.index !== void 0) {
33790
+ if (!Number.isInteger(ref.index) || ref.index < 0 || ref.index >= plan.stops.length) {
33791
+ throw new UserError(
33792
+ `Plan "${plan.id}" has no stop ${ref.index}; indexes run from 0 to ${plan.stops.length - 1}`
33793
+ );
33794
+ }
33795
+ return ref.index;
33796
+ }
33797
+ const want = lc(ref.label);
33798
+ const byLabel = plan.stops.flatMap((s, i) => s.label && lc(s.label) === want ? [i] : []);
33799
+ const byName = plan.stops.flatMap((s, i) => lc(stopName(s)) === want ? [i] : []);
33800
+ const hits = byLabel.length ? byLabel : byName.length ? byName : plan.stops.flatMap((s, i) => namesOf(s).includes(want) ? [i] : []);
33801
+ if (hits.length === 1) return hits[0];
33802
+ if (hits.length > 1) {
33803
+ throw new UserError(
33804
+ `${hits.length} stops in plan "${plan.id}" match "${ref.label}"; give the index instead (${hits.join(", ")})`
33805
+ );
33806
+ }
33807
+ const known = plan.stops.map((s, i) => `${i}: ${stopName(s)}`).join(", ");
33808
+ throw new UserError(`No stop "${ref.label}" in plan "${plan.id}". Stops: ${known}`);
33809
+ }
33810
+
33093
33811
  // src/plans/markdown.ts
33094
33812
  var box = {
33095
33813
  todo: "[ ]",
@@ -33097,6 +33815,7 @@ var box = {
33097
33815
  done: "[x]",
33098
33816
  skipped: "[-]"
33099
33817
  };
33818
+ var stopBox = { todo: "[ ]", done: "[x]", skipped: "[-]" };
33100
33819
  function planToMarkdown(plan) {
33101
33820
  const done = plan.items.filter((i) => i.status === "done" || i.status === "skipped").length;
33102
33821
  const out = [
@@ -33125,8 +33844,13 @@ function planToMarkdown(plan) {
33125
33844
  }
33126
33845
  if (plan.stops.length) {
33127
33846
  out.push("## Stops", "");
33847
+ const next = nextStop(plan)?.index;
33128
33848
  plan.stops.forEach((s, n) => {
33129
- out.push(`${n + 1}. ${s.system}${s.station ? ` \u2014 ${s.station}` : ""}: ${s.purpose}`);
33849
+ const place2 = [s.system, s.body, s.station].filter(Boolean).join(" \u2014 ");
33850
+ const kind2 = s.kind ? `**${s.kind}** ` : "";
33851
+ const mark = n === next ? " \u2190 next" : "";
33852
+ out.push(`${n + 1}. ${stopBox[s.status]} ${kind2}${place2}: ${s.purpose}${mark}`);
33853
+ if (s.notes) out.push(` - Notes: ${s.notes}`);
33130
33854
  });
33131
33855
  out.push("");
33132
33856
  }
@@ -33144,63 +33868,8 @@ function planToMarkdown(plan) {
33144
33868
  return out.join("\n");
33145
33869
  }
33146
33870
 
33147
- // src/plans/schema.ts
33148
- var ItemKind = external_exports.enum([
33149
- "buy",
33150
- "engineer",
33151
- "experimental",
33152
- "unlock_engineer",
33153
- "gather",
33154
- "trade",
33155
- "refit"
33156
- ]);
33157
- var ItemStatus = external_exports.enum(["todo", "in_progress", "done", "skipped"]);
33158
- var PlanItemSchema = external_exports.object({
33159
- id: external_exports.string().min(1),
33160
- kind: ItemKind,
33161
- description: external_exports.string().min(1),
33162
- status: ItemStatus.default("todo"),
33163
- station: external_exports.string().optional(),
33164
- engineer: external_exports.string().optional(),
33165
- materials: external_exports.array(external_exports.object({ name: external_exports.string().min(1), count: external_exports.number().int().positive() })).optional(),
33166
- notes: external_exports.string().optional()
33167
- });
33168
- var StopKind = external_exports.enum(["shop", "trader", "engineer", "other"]);
33169
- var StopSchema = external_exports.object({
33170
- system: external_exports.string().min(1),
33171
- station: external_exports.string().optional(),
33172
- purpose: external_exports.string().min(1),
33173
- label: external_exports.string().min(1).optional(),
33174
- kind: StopKind.optional(),
33175
- after: external_exports.array(external_exports.string().min(1)).optional()
33176
- });
33177
- var PlanRouteSchema = external_exports.object({
33178
- computed_at: external_exports.string(),
33179
- from: external_exports.string(),
33180
- stops: external_exports.array(external_exports.string()),
33181
- total_min: external_exports.number(),
33182
- plotted: external_exports.boolean()
33183
- });
33184
- var PlanInputSchema = external_exports.object({
33185
- id: external_exports.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, "id must be lowercase letters, digits and dashes"),
33186
- name: external_exports.string().min(1),
33187
- target_build: external_exports.unknown().optional(),
33188
- items: external_exports.array(PlanItemSchema).default([]),
33189
- stops: external_exports.array(StopSchema).default([]),
33190
- route: PlanRouteSchema.optional(),
33191
- notes: external_exports.string().optional()
33192
- });
33193
- var PlanSchema = PlanInputSchema.extend({
33194
- created_at: external_exports.string(),
33195
- updated_at: external_exports.string()
33196
- });
33197
- var PlanFileSchema = external_exports.object({
33198
- version: external_exports.literal(1),
33199
- plans: external_exports.array(PlanSchema)
33200
- });
33201
-
33202
33871
  // src/plans/store.ts
33203
- var LOCK_TIMEOUT_MS = 5e3;
33872
+ var LOCK_TIMEOUT_MS2 = 5e3;
33204
33873
  var PlanExistsError = class extends UserError {
33205
33874
  constructor(id) {
33206
33875
  super(`Plan ${id} exists; pass overwrite: true to replace it`);
@@ -33210,7 +33879,7 @@ var PlanStore = class {
33210
33879
  constructor(file2, now = () => /* @__PURE__ */ new Date(), opts = {}) {
33211
33880
  this.file = file2;
33212
33881
  this.now = now;
33213
- this.lockTimeoutMs = opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS;
33882
+ this.lockTimeoutMs = opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS2;
33214
33883
  this.lockDir = opts.lockDir;
33215
33884
  this.guard = opts.guard;
33216
33885
  this.markdownDir = opts.markdownDir;
@@ -33230,6 +33899,7 @@ var PlanStore = class {
33230
33899
  name: p.name,
33231
33900
  items: p.items.length,
33232
33901
  done: p.items.filter((i) => i.status === "done" || i.status === "skipped").length,
33902
+ next_stop: nextStop(p),
33233
33903
  updated_at: p.updated_at
33234
33904
  }));
33235
33905
  }
@@ -33247,11 +33917,16 @@ var PlanStore = class {
33247
33917
  if (idx >= 0 && opts.overwrite === false) {
33248
33918
  throw new PlanExistsError(parsed.data.id);
33249
33919
  }
33250
- const plan = {
33920
+ const old = idx >= 0 ? data.plans[idx] : void 0;
33921
+ const plan = cleanRoute({
33251
33922
  ...parsed.data,
33252
- created_at: idx >= 0 ? data.plans[idx].created_at : stamp,
33923
+ created_at: old ? old.created_at : stamp,
33253
33924
  updated_at: stamp
33254
- };
33925
+ });
33926
+ const route = plan.route;
33927
+ if (route?.stop_indexes && old?.route?.computed_at === route.computed_at && !sameStops(old.stops, plan.stops)) {
33928
+ delete route.stop_indexes;
33929
+ }
33255
33930
  if (idx >= 0) data.plans[idx] = plan;
33256
33931
  else data.plans.push(plan);
33257
33932
  return plan;
@@ -33269,10 +33944,30 @@ var PlanStore = class {
33269
33944
  return plan;
33270
33945
  });
33271
33946
  }
33272
- setRoute(planId, route) {
33947
+ updateStop(planId, ref, patch) {
33948
+ return this.mutate((data) => {
33949
+ const plan = data.plans.find((p) => p.id === planId);
33950
+ if (!plan) throw new UserError(`Plan "${planId}" not found`);
33951
+ const stop = plan.stops[findStop(plan, ref)];
33952
+ if (patch.status !== void 0) stop.status = patch.status;
33953
+ if (patch.notes !== void 0) stop.notes = patch.notes;
33954
+ plan.updated_at = this.now().toISOString();
33955
+ return plan;
33956
+ });
33957
+ }
33958
+ /**
33959
+ * Saves a computed route. With `stops`, the stops the route was computed from, it refuses when
33960
+ * the plan's stops have changed since, because the route's stop_indexes would point at others.
33961
+ */
33962
+ setRoute(planId, route, opts = {}) {
33273
33963
  return this.mutate((data) => {
33274
33964
  const plan = data.plans.find((p) => p.id === planId);
33275
33965
  if (!plan) throw new UserError(`Plan "${planId}" not found`);
33966
+ if (opts.stops && !sameStops(opts.stops, plan.stops)) {
33967
+ throw new UserError(
33968
+ `The stops of plan "${planId}" changed while the route was computed; run route_stops again to save it`
33969
+ );
33970
+ }
33276
33971
  plan.route = route;
33277
33972
  plan.updated_at = this.now().toISOString();
33278
33973
  return plan;
@@ -33303,6 +33998,7 @@ var PlanStore = class {
33303
33998
  if (!parsed.success) {
33304
33999
  throw new UserError(`Plan file ${this.file} is invalid: ${external_exports.prettifyError(parsed.error)}`);
33305
34000
  }
34001
+ parsed.data.plans.forEach(cleanRoute);
33306
34002
  return parsed.data;
33307
34003
  }
33308
34004
  // Read-modify-write runs one at a time so concurrent tool calls cannot lose updates.
@@ -33354,7 +34050,7 @@ var PlanStore = class {
33354
34050
  if (!dir) return null;
33355
34051
  let st;
33356
34052
  try {
33357
- st = await lstat2(dir);
34053
+ st = await lstat3(dir);
33358
34054
  } catch {
33359
34055
  return null;
33360
34056
  }
@@ -33367,7 +34063,7 @@ var PlanStore = class {
33367
34063
  async writeMarkdown(data, before) {
33368
34064
  const dir = this.markdownDir;
33369
34065
  try {
33370
- await mkdir3(dir, { recursive: true });
34066
+ await mkdir4(dir, { recursive: true });
33371
34067
  const problem = await this.directoryProblem();
33372
34068
  if (problem) {
33373
34069
  console.error(problem);
@@ -33390,7 +34086,7 @@ var PlanStore = class {
33390
34086
  const n = `${id}.md`;
33391
34087
  if (keep.has(n) || !PRUNABLE.test(n)) continue;
33392
34088
  const file2 = path8.join(dir, n);
33393
- const st = await lstat2(file2).catch(() => null);
34089
+ const st = await lstat3(file2).catch(() => null);
33394
34090
  if (st?.isFile()) await rm(file2, { force: true });
33395
34091
  }
33396
34092
  this.lastCopyError = null;
@@ -33400,6 +34096,20 @@ var PlanStore = class {
33400
34096
  }
33401
34097
  }
33402
34098
  };
34099
+ function cleanRoute(plan) {
34100
+ const route = plan.route;
34101
+ if (!route?.stop_indexes) return plan;
34102
+ if (route.stop_indexes.length !== route.stops.length) {
34103
+ delete route.stop_indexes;
34104
+ } else {
34105
+ route.stop_indexes = route.stop_indexes.map((e) => e.filter((i) => i < plan.stops.length));
34106
+ }
34107
+ return plan;
34108
+ }
34109
+ var stopKey = (s) => JSON.stringify([s.system, s.station, s.body, s.label, s.purpose]);
34110
+ function sameStops(a, b) {
34111
+ return a.length === b.length && a.every((s, i) => stopKey(s) === stopKey(b[i]));
34112
+ }
33403
34113
  var PRUNABLE = /^[a-z0-9][a-z0-9-]{0,63}\.md$/;
33404
34114
 
33405
34115
  // src/prefs/schema.ts
@@ -33522,11 +34232,11 @@ function applyPatch(base2, patch) {
33522
34232
  }
33523
34233
 
33524
34234
  // src/prefs/store.ts
33525
- var LOCK_TIMEOUT_MS2 = 5e3;
34235
+ var LOCK_TIMEOUT_MS3 = 5e3;
33526
34236
  var PreferencesStore = class {
33527
34237
  constructor(file2, opts = {}) {
33528
34238
  this.file = file2;
33529
- this.lockTimeoutMs = opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS2;
34239
+ this.lockTimeoutMs = opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS3;
33530
34240
  this.lockDir = opts.lockDir;
33531
34241
  this.guard = opts.guard;
33532
34242
  }
@@ -33587,7 +34297,7 @@ var PreferencesStore = class {
33587
34297
  };
33588
34298
 
33589
34299
  // src/ships/data.ts
33590
- import { mkdir as mkdir4, readFile as readFile5, rename as rename4, writeFile as writeFile4 } from "fs/promises";
34300
+ import { mkdir as mkdir5, readFile as readFile5, rename as rename4, writeFile as writeFile4 } from "fs/promises";
33591
34301
  import path9 from "path";
33592
34302
  import { gunzipSync } from "zlib";
33593
34303
 
@@ -33789,7 +34499,7 @@ var ships_default = {
33789
34499
 
33790
34500
  // src/ships/tar.ts
33791
34501
  var BLOCK = 512;
33792
- function str3(buf, start, len) {
34502
+ function str4(buf, start, len) {
33793
34503
  const field = buf.subarray(start, start + len);
33794
34504
  const end = field.indexOf(0);
33795
34505
  return field.subarray(0, end < 0 ? len : end).toString("utf8");
@@ -33817,7 +34527,7 @@ function readTar(buf) {
33817
34527
  while (pos + BLOCK <= buf.length) {
33818
34528
  const h = buf.subarray(pos, pos + BLOCK);
33819
34529
  if (h.every((b) => b === 0)) break;
33820
- const size = Number.parseInt(str3(h, 124, 12).trim() || "0", 8);
34530
+ const size = Number.parseInt(str4(h, 124, 12).trim() || "0", 8);
33821
34531
  const type = String.fromCharCode(h[156] ?? 0);
33822
34532
  const start = pos + BLOCK;
33823
34533
  if (start + size > buf.length) throw new Error("Truncated tar archive");
@@ -33831,9 +34541,9 @@ function readTar(buf) {
33831
34541
  comment = paxFields(data).comment ?? comment;
33832
34542
  continue;
33833
34543
  }
33834
- let path15 = str3(h, 0, 100);
33835
- if (str3(h, 257, 6) === "ustar") {
33836
- const prefix = str3(h, 345, 155);
34544
+ let path15 = str4(h, 0, 100);
34545
+ if (str4(h, 257, 6) === "ustar") {
34546
+ const prefix = str4(h, 345, 155);
33837
34547
  if (prefix) path15 = `${prefix}/${path15}`;
33838
34548
  }
33839
34549
  if (nextPath !== null) path15 = nextPath;
@@ -34115,7 +34825,7 @@ var ShipDataService = class {
34115
34825
  }
34116
34826
  }
34117
34827
  async save(snapshot) {
34118
- await mkdir4(path9.dirname(this.file), { recursive: true });
34828
+ await mkdir5(path9.dirname(this.file), { recursive: true });
34119
34829
  const tmp = `${this.file}.${process.pid}.tmp`;
34120
34830
  await writeFile4(tmp, JSON.stringify(snapshot));
34121
34831
  await rename4(tmp, this.file);
@@ -34124,7 +34834,7 @@ var ShipDataService = class {
34124
34834
  var message = (err) => err instanceof Error ? err.message : String(err);
34125
34835
 
34126
34836
  // src/settings/secrets.ts
34127
- import { lstat as lstat3, mkdir as mkdir5, rm as rm2 } from "fs/promises";
34837
+ import { lstat as lstat4, mkdir as mkdir6, rm as rm2 } from "fs/promises";
34128
34838
  import path10 from "path";
34129
34839
 
34130
34840
  // src/settings/crypto.ts
@@ -34227,7 +34937,7 @@ var SecretsSchema = external_exports.strictObject({
34227
34937
  });
34228
34938
  var WrongPasswordError = class extends SecretsError {
34229
34939
  };
34230
- var LOCK_TIMEOUT_MS3 = 5e3;
34940
+ var LOCK_TIMEOUT_MS4 = 5e3;
34231
34941
  var SecretStore = class _SecretStore {
34232
34942
  constructor(file2, opts = {}) {
34233
34943
  this.file = file2;
@@ -34261,7 +34971,7 @@ var SecretStore = class _SecretStore {
34261
34971
  await lockedWrite(
34262
34972
  this.file,
34263
34973
  {
34264
- timeoutMs: this.opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS3,
34974
+ timeoutMs: this.opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS4,
34265
34975
  lockedMessage: "Secrets file is locked by another process; try again",
34266
34976
  lockDir: this.opts.lockDir,
34267
34977
  mode: 384
@@ -34290,11 +35000,11 @@ var SecretStore = class _SecretStore {
34290
35000
  reset() {
34291
35001
  const run = this.queue.then(async () => {
34292
35002
  await this.opts.guard?.();
34293
- await mkdir5(path10.dirname(this.file), { recursive: true });
34294
- if (this.opts.lockDir) await mkdir5(this.opts.lockDir, { recursive: true });
35003
+ await mkdir6(path10.dirname(this.file), { recursive: true });
35004
+ if (this.opts.lockDir) await mkdir6(this.opts.lockDir, { recursive: true });
34295
35005
  const release = await acquireLock(
34296
35006
  this.file,
34297
- this.opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS3,
35007
+ this.opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS4,
34298
35008
  "Secrets file is locked by another process; try again",
34299
35009
  this.opts.lockDir
34300
35010
  );
@@ -34330,7 +35040,7 @@ var SecretStore = class _SecretStore {
34330
35040
  async checkFile() {
34331
35041
  let st;
34332
35042
  try {
34333
- st = await lstat3(this.file);
35043
+ st = await lstat4(this.file);
34334
35044
  } catch (err) {
34335
35045
  const code = err.code;
34336
35046
  if (code === "ENOENT") return null;
@@ -34388,7 +35098,7 @@ var SecretStore = class _SecretStore {
34388
35098
  return lockedWrite(
34389
35099
  this.file,
34390
35100
  {
34391
- timeoutMs: this.opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS3,
35101
+ timeoutMs: this.opts.lockTimeoutMs ?? LOCK_TIMEOUT_MS4,
34392
35102
  lockedMessage: "Secrets file is locked by another process; try again",
34393
35103
  lockDir: this.opts.lockDir,
34394
35104
  mode: 384
@@ -34866,7 +35576,7 @@ function buildDeps(config2, opts = {}) {
34866
35576
  markdownDir: config2.plansMarkdownDir
34867
35577
  });
34868
35578
  const legacyPlans = config2.legacyPlansEnv ? `ED_MCP_PLANS is no longer used. Plans are now in ${config2.plansFile}. Move your old plans file there, or use plan_import, then remove ED_MCP_PLANS.` : void 0;
34869
- const syncWarnings = sync ? () => sync.warnings([config2.plansFile, config2.prefsFile, config2.secretsFile]) : void 0;
35579
+ const syncWarnings = sync ? () => sync.warnings([config2.plansFile, config2.prefsFile, config2.activityFile, config2.secretsFile]) : void 0;
34870
35580
  const inaraCache = new TtlCache("inara", 6e4);
34871
35581
  caches.register(inaraCache);
34872
35582
  const inara = new InaraClient(http, () => settings.inara(), inaraCache);
@@ -34881,10 +35591,13 @@ function buildDeps(config2, opts = {}) {
34881
35591
  caches.register(spanshMarketCache);
34882
35592
  const spanshSweepCache = new SweepCache();
34883
35593
  caches.register(spanshSweepCache);
35594
+ const spanshWarCache = new TtlCache("spansh-wars", 6e5);
35595
+ caches.register(spanshWarCache);
34884
35596
  const spansh = new SpanshClient(http, spanshCache, {
34885
35597
  systemCache: spanshSystemCache,
34886
35598
  marketCache: spanshMarketCache,
34887
- sweepCache: spanshSweepCache
35599
+ sweepCache: spanshSweepCache,
35600
+ warCache: spanshWarCache
34888
35601
  });
34889
35602
  const capiHttp = new HttpClient({ userAgent: `EDCD-EdMcp-${VERSION}`, fetchFn: opts.fetchFn });
34890
35603
  const capiCache = new TtlCache("capi", 6e4);
@@ -34930,6 +35643,10 @@ function buildDeps(config2, opts = {}) {
34930
35643
  lockDir: lockFor(config2.prefsFile),
34931
35644
  guard: guardFor(config2.prefsFile)
34932
35645
  }),
35646
+ activity: new ActivityStore(config2.activityFile, void 0, {
35647
+ lockDir: lockFor(config2.activityFile),
35648
+ guard: guardFor(config2.activityFile)
35649
+ }),
34933
35650
  sync,
34934
35651
  syncWarnings,
34935
35652
  versionCheck: new VersionCheck({ http, cacheDir: config2.cacheDir, current: VERSION }),
@@ -42982,6 +43699,124 @@ var EMPTY_COMPLETION_RESULT = {
42982
43699
  }
42983
43700
  };
42984
43701
 
43702
+ // src/tools/result.ts
43703
+ function ok(value) {
43704
+ return { content: [{ type: "text", text: JSON.stringify(value) }] };
43705
+ }
43706
+ function fail(message2) {
43707
+ return { isError: true, content: [{ type: "text", text: message2 }] };
43708
+ }
43709
+ function wrap(fn) {
43710
+ return async (args) => {
43711
+ try {
43712
+ return ok(await fn(args));
43713
+ } catch (err) {
43714
+ if (err instanceof UserError) return fail(err.message);
43715
+ console.error(err);
43716
+ return fail(`Internal error: ${err instanceof Error ? err.message : String(err)}`);
43717
+ }
43718
+ };
43719
+ }
43720
+ function wrapWarn(fn, warnings) {
43721
+ const inner = wrap(fn);
43722
+ return async (args) => {
43723
+ const result = await inner(args);
43724
+ const list2 = warnings ? await warnings().catch(() => []) : [];
43725
+ if (!list2.length) return result;
43726
+ return {
43727
+ ...result,
43728
+ content: [
43729
+ ...result.content,
43730
+ { type: "text", text: `Warnings:
43731
+ ${list2.map((w) => `- ${w}`).join("\n")}` }
43732
+ ]
43733
+ };
43734
+ };
43735
+ }
43736
+ function reason(err) {
43737
+ if (err instanceof UserError) return err.message;
43738
+ console.error(err);
43739
+ return err instanceof Error ? err.message : String(err);
43740
+ }
43741
+
43742
+ // src/tools/activity.ts
43743
+ var NO_NOTE = "No activity is saved. Ask the player what they are doing, then save it.";
43744
+ function registerActivityTools(server, deps) {
43745
+ const now = deps.now ?? Date.now;
43746
+ const location = async () => {
43747
+ if (!deps.journal) return { location: null, location_note: "The journal is not available." };
43748
+ try {
43749
+ const s = await deps.journal.state();
43750
+ return {
43751
+ location: {
43752
+ system: s.location.system,
43753
+ station: s.location.station,
43754
+ docked: s.location.docked,
43755
+ as_of: s.as_of,
43756
+ age_minutes: s.age_minutes,
43757
+ warnings: s.warnings
43758
+ }
43759
+ };
43760
+ } catch (err) {
43761
+ return { location: null, location_note: `The journal could not be read: ${reason(err)}` };
43762
+ }
43763
+ };
43764
+ server.registerTool(
43765
+ "activity_get",
43766
+ {
43767
+ annotations: {
43768
+ title: "Get Activity",
43769
+ readOnlyHint: true,
43770
+ destructiveHint: false,
43771
+ idempotentHint: true,
43772
+ openWorldHint: false
43773
+ },
43774
+ description: "What the commander was last doing: a short saved note (kind, summary, optional plan_id), its age, and the commander's current location from the journal. Call it at the start of a conversation. When the location or age says the player has moved on, ask before relying on the note."
43775
+ },
43776
+ wrapWarn(async () => {
43777
+ const [activity, here] = await Promise.all([deps.activity.get(), location()]);
43778
+ if (!activity) return { activity: null, note: NO_NOTE, ...here };
43779
+ const saved = Date.parse(activity.updated_at);
43780
+ const age = Number.isNaN(saved) ? null : Math.max(0, Math.round((now() - saved) / 6e4));
43781
+ return { activity, age_minutes: age, ...here };
43782
+ }, deps.warnings)
43783
+ );
43784
+ server.registerTool(
43785
+ "activity_set",
43786
+ {
43787
+ annotations: {
43788
+ title: "Set Activity",
43789
+ readOnlyHint: false,
43790
+ destructiveHint: true,
43791
+ idempotentHint: true,
43792
+ openWorldHint: false
43793
+ },
43794
+ description: `Replace the saved note of what the commander is doing, so the next conversation can pick it up. kind and summary (at most ${MAX_SUMMARY} characters: where, the goal, and progress) are required; plan_id names the active route or build plan. Update it when the player starts something new or the goal changes. clear: true deletes the note.`,
43795
+ inputSchema: {
43796
+ kind: ActivityKind.optional(),
43797
+ summary: external_exports.string().max(MAX_SUMMARY).optional(),
43798
+ plan_id: PlanId.optional(),
43799
+ clear: external_exports.boolean().optional()
43800
+ }
43801
+ },
43802
+ wrapWarn(
43803
+ async (a) => {
43804
+ if (a.clear) {
43805
+ if (a.kind !== void 0 || a.summary !== void 0 || a.plan_id !== void 0) {
43806
+ throw new UserError("Give clear: true alone, or kind and summary without clear");
43807
+ }
43808
+ return { cleared: await deps.activity.clear() };
43809
+ }
43810
+ if (a.kind === void 0 || a.summary === void 0) {
43811
+ throw new UserError("Give kind and summary, or clear: true");
43812
+ }
43813
+ return deps.activity.set({ kind: a.kind, summary: a.summary, plan_id: a.plan_id });
43814
+ },
43815
+ deps.warnings
43816
+ )
43817
+ );
43818
+ }
43819
+
42985
43820
  // src/ships/constants.ts
42986
43821
  var MODULE_GROUP_NAMES = {
42987
43822
  pp: "Power Plant",
@@ -43718,8 +44553,8 @@ var family = (symbol2) => (symbol2 ?? "").toLowerCase().replace(/size\d+|class\d
43718
44553
  function pick2(candidates, current) {
43719
44554
  let list2 = candidates;
43720
44555
  if (current?.module.symbol) {
43721
- const same4 = list2.filter((m) => family(m.symbol) === family(current.module.symbol));
43722
- if (same4.length) list2 = same4;
44556
+ const same6 = list2.filter((m) => family(m.symbol) === family(current.module.symbol));
44557
+ if (same6.length) list2 = same6;
43723
44558
  }
43724
44559
  const plain = list2.filter((m) => m.name === void 0 && m.preEngineered === void 0);
43725
44560
  return (plain.length ? plain : list2)[0] ?? null;
@@ -43944,8 +44779,8 @@ function toSlef(build) {
43944
44779
 
43945
44780
  // src/ships/calc/core.ts
43946
44781
  var CARGO_HATCH_MW = 0.6;
43947
- var num3 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
43948
- var prop = (b, name) => num3(b.ship.properties[name]);
44782
+ var num4 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
44783
+ var prop = (b, name) => num4(b.ship.properties[name]);
43949
44784
  var find = (b, grp) => b.modules.find((m) => m.module.grp === grp) ?? null;
43950
44785
  function scale(m, feature) {
43951
44786
  const v = m.engineering?.modifiers[feature];
@@ -43954,17 +44789,17 @@ function scale(m, feature) {
43954
44789
  function moduleStat(m, feature) {
43955
44790
  const mods = m.engineering?.modifiers ?? {};
43956
44791
  if ((feature === "minmass" || feature === "maxmass") && mods[feature] === void 0) {
43957
- const base2 = num3(m.module[feature]);
44792
+ const base2 = num4(m.module[feature]);
43958
44793
  const optmass = scale(m, "optmass");
43959
44794
  if (SHIELD_GENERATORS.has(m.module.grp) && feature === "maxmass" && optmass < 1) return base2;
43960
44795
  return base2 * optmass;
43961
44796
  }
43962
44797
  const mul = /^(min|opt|max)mul(.*)$/.exec(feature);
43963
44798
  if (mul) {
43964
- const base2 = num3(m.module[feature]) || num3(m.module[`${mul[1]}mul`]);
44799
+ const base2 = num4(m.module[feature]) || num4(m.module[`${mul[1]}mul`]);
43965
44800
  return base2 * scale(m, "optmul");
43966
44801
  }
43967
- return num3(modifiedValue(m.module, mods, feature));
44802
+ return num4(modifiedValue(m.module, mods, feature));
43968
44803
  }
43969
44804
  var sum = (b, feature, round2 = (v) => v) => b.modules.reduce((s, m) => s + round2(moduleStat(m, feature)), 0);
43970
44805
  function massFuel(b) {
@@ -44101,8 +44936,8 @@ var BROKEN_DELAY_S = 16;
44101
44936
  var HRPS = /* @__PURE__ */ new Set(["hr", "ghrp", "mahr"]);
44102
44937
  var MRPS = /* @__PURE__ */ new Set(["mrp", "gmrp"]);
44103
44938
  var RESISTANCES = { explosive: "explres", kinetic: "kinres", thermal: "thermres" };
44104
- var num4 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
44105
- var prop2 = (b, name) => num4(b.ship.properties[name]);
44939
+ var num5 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
44940
+ var prop2 = (b, name) => num5(b.ship.properties[name]);
44106
44941
  var active = (b, grps) => b.modules.filter((m) => grps.has(m.module.grp) && online(b, m));
44107
44942
  function pipRecharge(rate, pips) {
44108
44943
  return rate * Math.pow(pips / 4, 1.1);
@@ -44276,46 +45111,6 @@ function weapons(b) {
44276
45111
  };
44277
45112
  }
44278
45113
 
44279
- // src/tools/result.ts
44280
- function ok(value) {
44281
- return { content: [{ type: "text", text: JSON.stringify(value) }] };
44282
- }
44283
- function fail(message2) {
44284
- return { isError: true, content: [{ type: "text", text: message2 }] };
44285
- }
44286
- function wrap(fn) {
44287
- return async (args) => {
44288
- try {
44289
- return ok(await fn(args));
44290
- } catch (err) {
44291
- if (err instanceof UserError) return fail(err.message);
44292
- console.error(err);
44293
- return fail(`Internal error: ${err instanceof Error ? err.message : String(err)}`);
44294
- }
44295
- };
44296
- }
44297
- function wrapWarn(fn, warnings) {
44298
- const inner = wrap(fn);
44299
- return async (args) => {
44300
- const result = await inner(args);
44301
- const list2 = warnings ? await warnings().catch(() => []) : [];
44302
- if (!list2.length) return result;
44303
- return {
44304
- ...result,
44305
- content: [
44306
- ...result.content,
44307
- { type: "text", text: `Warnings:
44308
- ${list2.map((w) => `- ${w}`).join("\n")}` }
44309
- ]
44310
- };
44311
- };
44312
- }
44313
- function reason(err) {
44314
- if (err instanceof UserError) return err.message;
44315
- console.error(err);
44316
- return err instanceof Error ? err.message : String(err);
44317
- }
44318
-
44319
45114
  // src/tools/build.ts
44320
45115
  var SCO_NOTE = "Jump range uses the drive's normal values, not supercruise overcharge.";
44321
45116
  var SlefInput = external_exports.union([external_exports.string(), external_exports.array(external_exports.unknown()), external_exports.record(external_exports.string(), external_exports.unknown())]);
@@ -45534,7 +46329,7 @@ async function locate(deps) {
45534
46329
  // src/routes/legs.ts
45535
46330
  var STATION_LIMIT = 500;
45536
46331
  var PLANETARY = /planetary|surface|settlement/i;
45537
- var same = (a, b) => a !== null && b !== null && a.toLowerCase() === b.toLowerCase();
46332
+ var same3 = (a, b) => a !== null && b !== null && a.toLowerCase() === b.toLowerCase();
45538
46333
  function createPlacer(spansh, notes) {
45539
46334
  const systems = /* @__PURE__ */ new Map();
45540
46335
  const lookup2 = (name) => {
@@ -45546,7 +46341,7 @@ function createPlacer(spansh, notes) {
45546
46341
  return {
45547
46342
  async place(system, station) {
45548
46343
  const info = await lookup2(system);
45549
- const hit = station ? info.stations.find((s) => same(s.name, station)) : void 0;
46344
+ const hit = station ? info.stations.find((s) => same3(s.name, station)) : void 0;
45550
46345
  if (station && !hit) {
45551
46346
  notes.add(`Station ${station} not found in ${info.name}; assumed 1000 ls from arrival.`);
45552
46347
  }
@@ -45560,7 +46355,7 @@ function createPlacer(spansh, notes) {
45560
46355
  },
45561
46356
  async placeEngineer(e) {
45562
46357
  const info = await lookup2(e.system);
45563
- const hit = info.stations.find((s) => same(s.name, e.base));
46358
+ const hit = info.stations.find((s) => same3(s.name, e.base));
45564
46359
  if (!hit) {
45565
46360
  notes.add(
45566
46361
  `${e.name}'s base ${e.base} is not in Spansh; assumed a planetary base 1000 ls from arrival.`
@@ -45577,10 +46372,10 @@ function createPlacer(spansh, notes) {
45577
46372
  };
45578
46373
  }
45579
46374
  function legBetween(from, to, travel) {
45580
- if (same(from.system, to.system) && same(from.station, to.station)) {
46375
+ if (same3(from.system, to.system) && same3(from.station, to.station)) {
45581
46376
  return { jumps: 0, seconds: 0, distanceLy: 0 };
45582
46377
  }
45583
- const distanceLy2 = same(from.system, to.system) ? 0 : Math.hypot(
46378
+ const distanceLy2 = same3(from.system, to.system) ? 0 : Math.hypot(
45584
46379
  from.coords.x - to.coords.x,
45585
46380
  from.coords.y - to.coords.y,
45586
46381
  from.coords.z - to.coords.z
@@ -45967,6 +46762,13 @@ function registerMissionTools(server, deps) {
45967
46762
  // src/tools/journal.ts
45968
46763
  var FRESHNESS_TEXT = "as_of is the time of the newest journal event and age_minutes its age; warnings flag stale data (over 30 minutes old) and unreadable journal files.";
45969
46764
  var NAMES_MISSING = 'Material names are not available until the game data catalog is downloaded. Call any catalog tool (for example catalog_material) or cache_refresh with source "catalog".';
46765
+ var MASSACRE_NOTE = "Kill counts are an estimate from the journal. Kills by wing mates, kills before a mission was accepted elsewhere and the game's own rules can differ; the mission panel in the game is authoritative.";
46766
+ var READ_ONLY = {
46767
+ readOnlyHint: true,
46768
+ destructiveHint: false,
46769
+ idempotentHint: true,
46770
+ openWorldHint: false
46771
+ };
45970
46772
  var freshness = (s) => ({
45971
46773
  as_of: s.as_of,
45972
46774
  age_minutes: s.age_minutes,
@@ -46086,7 +46888,104 @@ function registerJournalTools(server, deps) {
46086
46888
  return { engineers: s.engineers, ...freshness(s) };
46087
46889
  })
46088
46890
  );
46891
+ server.registerTool(
46892
+ "journal_exobiology",
46893
+ {
46894
+ annotations: { title: "Journal Exobiology", ...READ_ONLY },
46895
+ description: 'Exobiology progress in a system from the local game journal (last 14 days). For each body with biological signals: bio_signals (the count from the FSS or surface scan), genera found by the surface scan, each species sampled with samples taken (stage 0/3 to 3/3, analysed once complete; abandoned when another species was started first, which drops an unfinished sample, or after a death), and not_started genera with no sample yet. unsold_analysed counts species analysed since the last sale of organic data (or death), in any system. The journal has no sample positions. system defaults to the current system; body is a full body name or the part after the system name, such as "A 4". ' + FRESHNESS_TEXT,
46896
+ inputSchema: {
46897
+ system: external_exports.string().optional().describe("System name; default the current system"),
46898
+ body: external_exports.string().optional().describe('Body name, or the part after the system ("A 4")')
46899
+ }
46900
+ },
46901
+ wrap(async (a) => {
46902
+ const s = await deps.journal.state();
46903
+ const system = systemOf(a.system, s.location.system);
46904
+ const address = systemAddress(s.events, system);
46905
+ const r = exobiology(s.events, address ?? -1, { system, body: a.body });
46906
+ return {
46907
+ system,
46908
+ ...r,
46909
+ ...address === null ? { note: notVisited(system) } : {},
46910
+ ...freshness(s)
46911
+ };
46912
+ })
46913
+ );
46914
+ server.registerTool(
46915
+ "journal_exploration",
46916
+ {
46917
+ annotations: { title: "Journal Exploration", ...READ_ONLY },
46918
+ description: "Exploration scans of a system from the local game journal (last 14 days). discovery has the FSS discovery scan body count and progress, and all_bodies_found once the FSS is complete. bodies lists each scanned body, nearest first: type, distance_ls from the arrival star, landable, terraformable, atmosphere, rings, was_discovered and was_mapped (by someone before you), mapped_by_you (surface scan complete) and signals. Values are not estimated. system defaults to the current system. " + FRESHNESS_TEXT,
46919
+ inputSchema: {
46920
+ system: external_exports.string().optional().describe("System name; default the current system")
46921
+ }
46922
+ },
46923
+ wrap(async (a) => {
46924
+ const s = await deps.journal.state();
46925
+ const system = systemOf(a.system, s.location.system);
46926
+ const address = systemAddress(s.events, system);
46927
+ return {
46928
+ system,
46929
+ ...exploration(s.events, address ?? -1),
46930
+ ...address === null ? { note: notVisited(system) } : {},
46931
+ ...freshness(s)
46932
+ };
46933
+ })
46934
+ );
46935
+ server.registerTool(
46936
+ "journal_massacre",
46937
+ {
46938
+ annotations: { title: "Journal Massacre Progress", ...READ_ONLY },
46939
+ description: "Progress of active massacre missions from the local game journal, grouped by target faction and then by giving faction. Each mission has kills_required, kills_counted (from bounties and combat bonds against the target since it was accepted) and kills_left. A kill counts toward the oldest open mission of each giving faction, as the game stacks them; a mission redirected back to its giver is done. A target's kills_left is the most any one giver still needs. The counts are an estimate. uncounted lists massacre missions accepted before the 14-day journal window. " + FRESHNESS_TEXT
46940
+ },
46941
+ wrap(async () => {
46942
+ const s = await deps.journal.state();
46943
+ const r = massacre(s.missions, s.events);
46944
+ return {
46945
+ targets: r.groups,
46946
+ uncounted: r.uncounted.map((m) => ({ id: m.id, name: m.name, expires: m.expires })),
46947
+ note: MASSACRE_NOTE,
46948
+ ...freshness(s)
46949
+ };
46950
+ })
46951
+ );
46952
+ server.registerTool(
46953
+ "journal_events",
46954
+ {
46955
+ annotations: { title: "Journal Events", ...READ_ONLY },
46956
+ description: "Raw events from the local game journal (last 14 days), newest first, as the game wrote them. Use it for anything the other journal tools do not cover, instead of reading the journal files. events limits the event names (for example Bounty or Docked); since is an ISO time; system keeps events in that system. limit defaults to 50 (at most 200). Arrays longer than 20 entries are cut, with a last entry that says how many were left out. " + FRESHNESS_TEXT,
46957
+ inputSchema: {
46958
+ events: external_exports.array(external_exports.string()).optional().describe("Event names, such as Bounty or Docked"),
46959
+ since: external_exports.string().optional().describe("Only events at or after this ISO time"),
46960
+ system: external_exports.string().optional().describe("Only events in this system"),
46961
+ limit: external_exports.number().int().min(1).max(200).default(50)
46962
+ }
46963
+ },
46964
+ wrap(async (a) => {
46965
+ const since = a.since === void 0 ? void 0 : Date.parse(a.since);
46966
+ if (since !== void 0 && Number.isNaN(since)) {
46967
+ throw new UserError(`since is not a time: ${a.since}. Use an ISO time.`);
46968
+ }
46969
+ const s = await deps.journal.state();
46970
+ const r = await deps.journal.recentEvents(
46971
+ { events: a.events, since, system: a.system },
46972
+ a.limit
46973
+ );
46974
+ return {
46975
+ events: r.events.map(truncate),
46976
+ ...freshness({ ...s, warnings: [...s.warnings, ...r.warnings] })
46977
+ };
46978
+ })
46979
+ );
46980
+ }
46981
+ function systemOf(given, current) {
46982
+ const system = given?.trim() || current;
46983
+ if (!system) {
46984
+ throw new UserError("The journal has no current system. Give a system name.");
46985
+ }
46986
+ return system;
46089
46987
  }
46988
+ var notVisited = (system) => `The journal has no events in ${system} in the last 14 days. Check the system name.`;
46090
46989
 
46091
46990
  // src/trade/loops.ts
46092
46991
  function bestCargo(from, to, cargo, capital) {
@@ -46243,7 +47142,7 @@ var NO_LOOPS = "No profitable loop found. Try a larger radius, another centre, o
46243
47142
  var PERMIT_SPARES = 30;
46244
47143
  var PERMIT_EXTRA_MS = 8e3;
46245
47144
  var Station = external_exports.object({ system: external_exports.string(), station: external_exports.string() });
46246
- var same2 = (a, b) => a.toLowerCase() === b.toLowerCase();
47145
+ var same4 = (a, b) => a.toLowerCase() === b.toLowerCase();
46247
47146
  var tenth = (n) => Math.round(n * 10) / 10;
46248
47147
  var minutes = (seconds) => tenth(seconds / 60);
46249
47148
  var placeOf = (s) => ({
@@ -46448,11 +47347,11 @@ function registerLoopTools(server, deps) {
46448
47347
  if (here) {
46449
47348
  const name = centre.station;
46450
47349
  let startMarket = found.stations.find(
46451
- (st) => same2(st.name, name) && same2(st.system, centre.system)
47350
+ (st) => same4(st.name, name) && same4(st.system, centre.system)
46452
47351
  );
46453
47352
  if (!startMarket) {
46454
47353
  const info = await spansh.system(centre.system, STATION_LIMIT);
46455
- const entry2 = info.stations.find((st) => same2(st.name, name));
47354
+ const entry2 = info.stations.find((st) => same4(st.name, name));
46456
47355
  if (entry2?.market_id == null) {
46457
47356
  throw new UserError(`Spansh has no market for ${name} in ${info.name}.`);
46458
47357
  }
@@ -46641,7 +47540,7 @@ function registerLoopTools(server, deps) {
46641
47540
  );
46642
47541
  async function includeMarket(inc) {
46643
47542
  const info = await spansh.system(inc.system, STATION_LIMIT);
46644
- const id = info.stations.find((st) => same2(st.name, inc.station))?.market_id;
47543
+ const id = info.stations.find((st) => same4(st.name, inc.station))?.market_id;
46645
47544
  if (id == null) return null;
46646
47545
  return spansh.stationMarket(id).catch((err) => {
46647
47546
  if (err instanceof UserError) return null;
@@ -46650,6 +47549,116 @@ function registerLoopTools(server, deps) {
46650
47549
  }
46651
47550
  }
46652
47551
 
47552
+ // src/systems/groundcz.ts
47553
+ var AT_WAR = /* @__PURE__ */ new Set(["war", "civil war"]);
47554
+ var FRONTLINE = "Frontline Solutions";
47555
+ var PAD_RANK2 = { S: 1, M: 2, L: 3 };
47556
+ var padOk2 = (pad, min3) => !min3 || (PAD_RANK2[pad ?? ""] ?? 0) >= PAD_RANK2[min3];
47557
+ var smallestGap = (s) => s.settlements[0]?.gap_ls ?? Infinity;
47558
+ function groundCzTargets(systems, centre, opts) {
47559
+ const leftOut = { stale: 0, no_warring_settlements: 0, no_station_near: 0, pad_too_small: 0 };
47560
+ const targets = [];
47561
+ for (const system of systems) {
47562
+ const updated = system.updated_at ? Date.parse(system.updated_at) : NaN;
47563
+ const ageHours2 = Number.isNaN(updated) ? Infinity : (opts.now - updated) / 36e5;
47564
+ if (ageHours2 > opts.maxAgeHours) {
47565
+ leftOut.stale++;
47566
+ continue;
47567
+ }
47568
+ const warring = system.factions.flatMap((f) => {
47569
+ const state = f.active_states.find((s) => AT_WAR.has(s.toLowerCase()));
47570
+ return state ? [{ name: f.name, state }] : [];
47571
+ });
47572
+ const sides = new Set(warring.map((w) => w.name));
47573
+ const settlements = system.stations.filter(
47574
+ (s) => s.type === "Settlement" && s.owner !== null && sides.has(s.owner) && s.arrival_ls !== null
47575
+ );
47576
+ if (!settlements.length) {
47577
+ leftOut.no_warring_settlements++;
47578
+ continue;
47579
+ }
47580
+ const warringSettlements = {};
47581
+ for (const s of settlements)
47582
+ warringSettlements[s.owner] = (warringSettlements[s.owner] ?? 0) + 1;
47583
+ const stations = system.stations.filter((s) => s.services.includes(FRONTLINE) && s.arrival_ls !== null).map((s) => {
47584
+ const planetary = PLANETARY.test(s.type ?? "");
47585
+ const near = settlements.map((x) => ({
47586
+ name: x.name,
47587
+ owner: x.owner,
47588
+ arrival_ls: x.arrival_ls,
47589
+ gap_ls: Math.abs(x.arrival_ls - s.arrival_ls)
47590
+ })).filter((x) => x.gap_ls <= opts.maxGapLs).sort((a, b) => a.gap_ls - b.gap_ls);
47591
+ return {
47592
+ name: s.name,
47593
+ type: s.type,
47594
+ pad: s.pad,
47595
+ arrival_ls: s.arrival_ls,
47596
+ planetary,
47597
+ dock_s: opts.dockSeconds(s.arrival_ls, planetary),
47598
+ settlements: near
47599
+ };
47600
+ }).filter((s) => s.settlements.length > 0);
47601
+ const fit = stations.filter((s) => padOk2(s.pad, opts.minPad)).sort(
47602
+ (a, b) => a.dock_s - b.dock_s || b.settlements.length - a.settlements.length || smallestGap(a) - smallestGap(b)
47603
+ );
47604
+ if (!stations.length) {
47605
+ leftOut.no_station_near++;
47606
+ continue;
47607
+ }
47608
+ if (!fit.length) {
47609
+ leftOut.pad_too_small++;
47610
+ continue;
47611
+ }
47612
+ targets.push({
47613
+ system,
47614
+ distanceLy: distanceLy(system.coords, centre),
47615
+ ageHours: ageHours2,
47616
+ warring,
47617
+ best: fit[0],
47618
+ others: fit.slice(1),
47619
+ warringSettlements
47620
+ });
47621
+ }
47622
+ targets.sort(
47623
+ (a, b) => Math.floor(a.ageHours) - Math.floor(b.ageHours) || b.best.settlements.length - a.best.settlements.length || smallestGap(a.best) - smallestGap(b.best) || a.distanceLy - b.distanceLy
47624
+ );
47625
+ return { targets, leftOut };
47626
+ }
47627
+ var isStar = (b) => /Star$/.test(b.subType ?? "");
47628
+ function czDetail(best, bodies, maxGapLs) {
47629
+ const bodyOf = (station) => bodies.find((b) => b.stations.includes(station)) ?? null;
47630
+ const settlementBodies = {};
47631
+ for (const s of best.settlements) {
47632
+ const b = bodyOf(s.name);
47633
+ if (b) settlementBodies[s.name] = b.name;
47634
+ }
47635
+ const counts2 = /* @__PURE__ */ new Map();
47636
+ for (const name of Object.values(settlementBodies)) counts2.set(name, (counts2.get(name) ?? 0) + 1);
47637
+ const body_family = bodies.filter((b) => counts2.has(b.name)).map((b) => ({ name: b.name, subType: b.subType, settlements: counts2.get(b.name) }));
47638
+ const onBody = (b) => !!b && counts2.has(b.name);
47639
+ if (best.planetary) {
47640
+ const own2 = bodyOf(best.name);
47641
+ return {
47642
+ drop: onBody(own2) ? "same_body" : "gap_only",
47643
+ near_body: own2?.name ?? null,
47644
+ settlementBodies,
47645
+ body_family
47646
+ };
47647
+ }
47648
+ const near = bodies.filter((b) => !isStar(b) && Math.abs(b.arrival_ls - best.arrival_ls) <= maxGapLs).sort(
47649
+ (a, b) => Math.abs(a.arrival_ls - best.arrival_ls) - Math.abs(b.arrival_ls - best.arrival_ls)
47650
+ )[0];
47651
+ if (!near) return { drop: "gap_only", near_body: null, settlementBodies, body_family };
47652
+ const root = near.parentPlanetId ?? near.bodyId;
47653
+ const family2 = bodies.filter((b) => b.bodyId === root || b.parentPlanetId === root);
47654
+ return {
47655
+ drop: family2.some(onBody) ? "orbital" : "gap_only",
47656
+ near_body: near.name,
47657
+ settlementBodies,
47658
+ body_family
47659
+ };
47660
+ }
47661
+
46653
47662
  // src/systems/massacre.ts
46654
47663
  var SOURCE_RANGE_LY = 10;
46655
47664
  var NO_MASSACRES = /* @__PURE__ */ new Set(["war", "civil war", "election"]);
@@ -46985,6 +47994,121 @@ function registerSystemTools(server, deps) {
46985
47994
  }
46986
47995
  )
46987
47996
  );
47997
+ server.registerTool(
47998
+ "ground_cz_targets",
47999
+ {
48000
+ annotations: {
48001
+ title: "Find Ground Conflict Zones",
48002
+ readOnlyHint: true,
48003
+ destructiveHint: false,
48004
+ idempotentHint: true,
48005
+ openWorldHint: true
48006
+ },
48007
+ description: "Find likely ground (on-foot) conflict zones near you: systems with a faction at War or Civil War, settlements owned by the warring factions, and a Frontline Solutions station close to those settlements (within max_gap_ls of distance from the arrival star, so on the same planet or its moons) that meets the minimum pad; left_out.pad_too_small counts systems where only smaller-pad stations are close. Data older than max_age_hours (default 6) is left out: wars often end at the daily tick. The best station is the quickest to dock at (a planetary landing counts against it), then the one with most warring settlements nearby. For the top detail_count results, body detail says whether the station is a planetary port on the settlements' planet (drop: same_body) or an orbital station next to that planet and its moons (drop: orbital). drop: gap_only means the station and settlements are at about the same distance from the arrival star, but the body data could not confirm the same planet or its moons: check each settlement's body. drop: null (with near_body and body_family null, and no body on settlements) means no body detail was fetched: the result is beyond detail_count, the time limit was reached, or the fetch failed. Zones are likely, not confirmed. near defaults to the journal location.",
48008
+ inputSchema: {
48009
+ near: external_exports.string().optional().describe("Exact reference system name"),
48010
+ radius_ly: Radius,
48011
+ max_age_hours: external_exports.number().min(1).max(168).default(6),
48012
+ max_gap_ls: external_exports.number().min(0.1).max(20).default(1),
48013
+ detail_count: external_exports.number().int().min(0).max(15).default(8),
48014
+ limit: external_exports.number().int().min(1).max(100).default(20),
48015
+ preferences: PrefsPatchSchema.optional(),
48016
+ use_preferences: external_exports.boolean().default(true)
48017
+ }
48018
+ },
48019
+ wrap(
48020
+ async (a) => {
48021
+ const notes = [];
48022
+ const near = await resolveNear(a.near, notes);
48023
+ const rules = await resolveSearchRules(deps, a.preferences, a.use_preferences);
48024
+ const deadline = now() + TIME_LIMIT_MS2;
48025
+ const found = await deps.spansh.frontlineWarSystems(near, a.radius_ly, { deadline });
48026
+ const { targets, leftOut } = groundCzTargets(found.systems, found.centre, {
48027
+ now: now(),
48028
+ maxAgeHours: a.max_age_hours,
48029
+ maxGapLs: a.max_gap_ls,
48030
+ minPad: rules.pad.size,
48031
+ dockSeconds: (arrivalLs, planetary) => legSeconds({ distanceLy: 0, arrivalLs, planetary, stationKnown: true }, rules.travel).seconds
48032
+ });
48033
+ const shown = targets.slice(0, a.limit);
48034
+ const details = /* @__PURE__ */ new Map();
48035
+ let timedOut = 0;
48036
+ for (const t of shown.slice(0, a.detail_count)) {
48037
+ if (now() >= deadline) {
48038
+ timedOut++;
48039
+ continue;
48040
+ }
48041
+ try {
48042
+ const bodies = await deps.spansh.systemBodies(t.system.id64);
48043
+ if (!bodies.length)
48044
+ notes.push(`No body detail for ${t.system.name}: Spansh returned no bodies.`);
48045
+ else details.set(t.system.id64, czDetail(t.best, bodies, a.max_gap_ls));
48046
+ } catch (err) {
48047
+ notes.push(
48048
+ `No body detail for ${t.system.name}: ${err instanceof Error ? err.message : String(err)}`
48049
+ );
48050
+ }
48051
+ }
48052
+ notes.push(
48053
+ "These are likely ground conflict zones, not confirmed: Spansh does not see live conflict zone signals, and a war can end at the daily tick."
48054
+ );
48055
+ if (timedOut) {
48056
+ notes.push(
48057
+ `${timedOut} ${timedOut === 1 ? "result is" : "results are"} without body detail: the time limit was reached. Call again to fill them in.`
48058
+ );
48059
+ }
48060
+ if (found.partial) {
48061
+ notes.push("Spansh did not return every system in time; results may be missing.");
48062
+ }
48063
+ const minutes2 = (s) => Math.round(s / 6) / 10;
48064
+ return {
48065
+ near,
48066
+ searched: found.systems.length,
48067
+ left_out: leftOut,
48068
+ partial: found.partial,
48069
+ travel: describeRules(rules).travel,
48070
+ targets: shown.map((t) => {
48071
+ const d = details.get(t.system.id64) ?? null;
48072
+ const l = leg(t.distanceLy, rules.travel);
48073
+ return {
48074
+ system: t.system.name,
48075
+ distance_ly: Math.round(t.distanceLy * 100) / 100,
48076
+ jumps: l.jumps,
48077
+ travel_min: minutes2(l.seconds),
48078
+ age_hours: Math.round(t.ageHours * 10) / 10,
48079
+ updated_at: t.system.updated_at,
48080
+ warring_factions: t.warring,
48081
+ best_station: {
48082
+ name: t.best.name,
48083
+ type: t.best.type,
48084
+ pad: t.best.pad,
48085
+ arrival_ls: Math.round(t.best.arrival_ls * 100) / 100,
48086
+ dock_min: minutes2(t.best.dock_s),
48087
+ drop: d?.drop ?? null,
48088
+ near_body: d?.near_body ?? null,
48089
+ settlements: t.best.settlements.map((s) => ({
48090
+ name: s.name,
48091
+ owner: s.owner,
48092
+ gap_ls: Math.round(s.gap_ls * 1e3) / 1e3,
48093
+ ...d ? { body: d.settlementBodies[s.name] ?? null } : {}
48094
+ }))
48095
+ },
48096
+ other_frontline_stations: t.others.map((s) => ({
48097
+ name: s.name,
48098
+ type: s.type,
48099
+ pad: s.pad,
48100
+ arrival_ls: Math.round(s.arrival_ls * 100) / 100,
48101
+ settlements: s.settlements.length
48102
+ })),
48103
+ warring_settlements: t.warringSettlements,
48104
+ body_family: d?.body_family ?? null
48105
+ };
48106
+ }),
48107
+ notes
48108
+ };
48109
+ }
48110
+ )
48111
+ );
46988
48112
  }
46989
48113
 
46990
48114
  // src/tools/inara.ts
@@ -47057,8 +48181,8 @@ function registerEdsmTools(server, deps) {
47057
48181
  }
47058
48182
 
47059
48183
  // src/routes/pool.ts
47060
- var lc = (s) => s.toLowerCase();
47061
- var namesOf = (s) => [s.label, ...s.aliases];
48184
+ var lc2 = (s) => s.toLowerCase();
48185
+ var namesOf2 = (s) => [s.label, ...s.aliases];
47062
48186
  function joinPurposes(a, b) {
47063
48187
  const parts = [a, b].flatMap((p) => p ? p.split("; ") : []);
47064
48188
  return parts.length ? [...new Set(parts)].join("; ") : null;
@@ -47067,8 +48191,8 @@ function buildPool(a, catalog) {
47067
48191
  const used = /* @__PURE__ */ new Set();
47068
48192
  const unique = (label) => {
47069
48193
  let candidate = label;
47070
- for (let n = 2; used.has(lc(candidate)); n++) candidate = `${label}-${n}`;
47071
- used.add(lc(candidate));
48194
+ for (let n = 2; used.has(lc2(candidate)); n++) candidate = `${label}-${n}`;
48195
+ used.add(lc2(candidate));
47072
48196
  return candidate;
47073
48197
  };
47074
48198
  const resolveEngineer = (name) => {
@@ -47087,13 +48211,16 @@ function buildPool(a, catalog) {
47087
48211
  );
47088
48212
  }
47089
48213
  const station = input2.station ?? (input2.system ? void 0 : engineer2?.base);
47090
- const key = engineer2 ? `${lc(engineer2.system)}|${lc(engineer2.base)}` : `${lc(system)}|${lc(station ?? "")}`;
47091
- const name = input2.label ?? engineer2?.name ?? input2.station ?? system;
48214
+ const key = engineer2 ? `${lc2(engineer2.system)}|${lc2(engineer2.base)}` : `${lc2(system)}|${lc2(station ?? "")}${!station && input2.body ? `|${lc2(input2.body)}` : ""}`;
48215
+ const name = input2.label ?? engineer2?.name ?? input2.station ?? input2.body ?? system;
47092
48216
  const hit = byKey.get(key);
47093
48217
  if (hit) {
47094
- if (!namesOf(hit).some((n) => lc(n) === lc(name))) hit.aliases.push(unique(name));
48218
+ if (!namesOf2(hit).some((n) => lc2(n) === lc2(name))) hit.aliases.push(unique(name));
47095
48219
  if (input2.after) hit.after = [.../* @__PURE__ */ new Set([...hit.after ?? [], ...input2.after])];
47096
48220
  hit.purpose = joinPurposes(hit.purpose, over.purpose ?? null);
48221
+ hit.notes = joinPurposes(hit.notes, over.notes ?? null);
48222
+ hit.body ??= input2.body ?? null;
48223
+ if (over.planStop !== void 0) hit.planStops.push(over.planStop);
47097
48224
  hit.engineer ??= engineer2;
47098
48225
  if (engineer2 || (over.kind ?? input2.kind) === "engineer") hit.kind = "engineer";
47099
48226
  return hit;
@@ -47104,34 +48231,43 @@ function buildPool(a, catalog) {
47104
48231
  aliases: [],
47105
48232
  system,
47106
48233
  station: station ?? null,
48234
+ body: input2.body ?? null,
47107
48235
  engineer: engineer2,
47108
48236
  kind: over.kind ?? input2.kind ?? (engineer2 ? "engineer" : "other"),
47109
48237
  purpose: over.purpose ?? null,
47110
- after: input2.after
48238
+ notes: over.notes ?? null,
48239
+ after: input2.after,
48240
+ planStops: over.planStop === void 0 ? [] : [over.planStop]
47111
48241
  };
47112
48242
  byKey.set(key, stop);
47113
48243
  return stop;
47114
48244
  };
47115
48245
  const entries = a.stops ?? [];
47116
48246
  const candidates = [];
48247
+ const finished = /* @__PURE__ */ new Map();
47117
48248
  if (a.plan) {
48249
+ const planStops = a.plan.stops.filter((s) => s.status !== "done" && s.status !== "skipped");
48250
+ for (const s of a.plan.stops) {
48251
+ if (s.status !== "done" && s.status !== "skipped") continue;
48252
+ for (const n of [s.label, s.station, s.body, s.system]) if (n) finished.set(lc2(n), n);
48253
+ }
47118
48254
  const named = /* @__PURE__ */ new Set();
47119
- for (const s of a.plan.stops) if (s.kind === "engineer" && s.label) named.add(lc(s.label));
48255
+ for (const s of planStops) if (s.kind === "engineer" && s.label) named.add(lc2(s.label));
47120
48256
  for (const e of entries) {
47121
48257
  if ("one_of" in e) continue;
47122
- if (e.engineer) named.add(lc(e.engineer));
47123
- if (e.kind === "engineer" && e.label) named.add(lc(e.label));
48258
+ if (e.engineer) named.add(lc2(e.engineer));
48259
+ if (e.kind === "engineer" && e.label) named.add(lc2(e.label));
47124
48260
  }
47125
48261
  const byEngineer = /* @__PURE__ */ new Map();
47126
48262
  for (const item of a.plan.items) {
47127
48263
  if (!item.engineer || item.status === "done" || item.status === "skipped") continue;
47128
- const entry2 = byEngineer.get(lc(item.engineer)) ?? {
48264
+ const entry2 = byEngineer.get(lc2(item.engineer)) ?? {
47129
48265
  id: item.id,
47130
48266
  name: item.engineer,
47131
48267
  descriptions: []
47132
48268
  };
47133
48269
  entry2.descriptions.push(item.description);
47134
- byEngineer.set(lc(item.engineer), entry2);
48270
+ byEngineer.set(lc2(item.engineer), entry2);
47135
48271
  }
47136
48272
  const baseFor = /* @__PURE__ */ new Map();
47137
48273
  if (catalog) {
@@ -47142,26 +48278,27 @@ function buildPool(a, catalog) {
47142
48278
  } catch {
47143
48279
  continue;
47144
48280
  }
47145
- const here = a.plan.stops.filter(
47146
- (s) => s.kind === "engineer" && !s.station && !baseFor.has(s) && lc(s.system ?? "") === lc(e.system)
48281
+ const here = planStops.filter(
48282
+ (s) => s.kind === "engineer" && !s.station && !baseFor.has(s) && lc2(s.system ?? "") === lc2(e.system)
47147
48283
  );
47148
- const mentions = (s) => lc(`${s.label ?? ""} ${s.purpose ?? ""}`).includes(lc(e.name));
48284
+ const mentions = (s) => lc2(`${s.label ?? ""} ${s.purpose ?? ""}`).includes(lc2(e.name));
47149
48285
  const match = here.find(mentions) ?? (named.has(key) ? void 0 : here[0]);
47150
48286
  if (match) baseFor.set(match, { base: e.base, engineer: e.name });
47151
48287
  }
47152
48288
  }
47153
- for (const s of a.plan.stops) {
48289
+ for (const s of planStops) {
47154
48290
  candidates.push({
47155
48291
  fixed: make(
47156
48292
  {
47157
48293
  label: s.label,
47158
48294
  system: s.system,
47159
48295
  station: s.station ?? baseFor.get(s)?.base,
48296
+ body: s.body,
47160
48297
  engineer: baseFor.get(s)?.engineer,
47161
48298
  kind: s.kind,
47162
48299
  after: s.after
47163
48300
  },
47164
- { purpose: s.purpose }
48301
+ { purpose: s.purpose, notes: s.notes, planStop: a.plan.stops.indexOf(s) }
47165
48302
  )
47166
48303
  });
47167
48304
  }
@@ -47193,26 +48330,30 @@ function buildPool(a, catalog) {
47193
48330
  const notes = [];
47194
48331
  const known = /* @__PURE__ */ new Map();
47195
48332
  for (const c of candidates) {
47196
- if ("group" in c) known.set(lc(c.group), c.group);
48333
+ if ("group" in c) known.set(lc2(c.group), c.group);
47197
48334
  for (const s of "fixed" in c ? [c.fixed] : c.options) {
47198
- for (const n of namesOf(s)) known.set(lc(n), n);
48335
+ for (const n of namesOf2(s)) known.set(lc2(n), n);
47199
48336
  }
47200
48337
  }
47201
48338
  for (const l of [...a.only ?? [], ...a.skip ?? []]) {
47202
- if (!known.has(lc(l))) {
48339
+ if (!known.has(lc2(l)) && finished.has(lc2(l))) {
48340
+ notes.push(`"${l}" is done or skipped in the plan, so the route leaves it out.`);
48341
+ continue;
48342
+ }
48343
+ if (!known.has(lc2(l))) {
47203
48344
  throw new UserError(`Unknown stop label "${l}". Known: ${[...known.values()].join(", ")}`);
47204
48345
  }
47205
48346
  }
47206
- const only = a.only ? new Set(a.only.map(lc)) : null;
47207
- const skip = new Set((a.skip ?? []).map(lc));
48347
+ const only = a.only ? new Set(a.only.map(lc2)) : null;
48348
+ const skip = new Set((a.skip ?? []).map(lc2));
47208
48349
  const keep = (s, viaGroup) => {
47209
- const names = namesOf(s).map(lc);
48350
+ const names = namesOf2(s).map(lc2);
47210
48351
  return (!only || viaGroup || names.some((n) => only.has(n))) && !names.some((n) => skip.has(n));
47211
48352
  };
47212
48353
  const kept = candidates.map((c) => {
47213
48354
  if ("fixed" in c) return keep(c.fixed, false) ? [c.fixed] : [];
47214
- if (skip.has(lc(c.group))) return [];
47215
- const viaGroup = !!only?.has(lc(c.group));
48355
+ if (skip.has(lc2(c.group))) return [];
48356
+ const viaGroup = !!only?.has(lc2(c.group));
47216
48357
  return [...new Set(c.options.filter((o) => keep(o, viaGroup)))];
47217
48358
  });
47218
48359
  const fixedStops = new Set(
@@ -47230,8 +48371,8 @@ function buildPool(a, catalog) {
47230
48371
  }
47231
48372
  const met = options.length > 1 ? options.find((o) => fixedStops.has(o)) : void 0;
47232
48373
  groupLabels.set(
47233
- lc(c.group),
47234
- (met ? [met] : options).map((o) => lc(o.label))
48374
+ lc2(c.group),
48375
+ (met ? [met] : options).map((o) => lc2(o.label))
47235
48376
  );
47236
48377
  if (options.length === 0) {
47237
48378
  notes.push(`Group "${c.group}" has no stops left after only/skip; dropped.`);
@@ -47248,17 +48389,17 @@ function buildPool(a, catalog) {
47248
48389
  });
47249
48390
  if (stops.length === 0 && groups.length === 0) throw new UserError("No stops to route.");
47250
48391
  const all = [.../* @__PURE__ */ new Set([...stops, ...groups.flatMap((g) => g.options)])];
47251
- const present = new Map(all.flatMap((s) => namesOf(s).map((n) => [lc(n), lc(s.label)])));
48392
+ const present = new Map(all.flatMap((s) => namesOf2(s).map((n) => [lc2(n), lc2(s.label)])));
47252
48393
  const rulesApplied = [];
47253
48394
  const after = /* @__PURE__ */ new Map();
47254
48395
  const planFallback = !!a.plan && a.planRules;
47255
48396
  const fallbackTargets = all.filter((s) => s.kind === "shop" || s.kind === "trader");
47256
48397
  let fallbackUsed = false;
47257
48398
  for (const s of all) {
47258
- const key = lc(s.label);
48399
+ const key = lc2(s.label);
47259
48400
  if (s.after === void 0) {
47260
48401
  if (!planFallback || s.kind !== "engineer") continue;
47261
- const targets = fallbackTargets.filter((t) => t !== s).map((t) => lc(t.label));
48402
+ const targets = fallbackTargets.filter((t) => t !== s).map((t) => lc2(t.label));
47262
48403
  if (targets.length === 0) continue;
47263
48404
  after.set(key, targets);
47264
48405
  fallbackUsed = true;
@@ -47267,8 +48408,8 @@ function buildPool(a, catalog) {
47267
48408
  const resolved = [];
47268
48409
  const named = [];
47269
48410
  for (const l of s.after) {
47270
- const main2 = present.get(lc(l));
47271
- const expanded = groupLabels.get(lc(l)) ?? (main2 ? [main2] : []);
48411
+ const main2 = present.get(lc2(l));
48412
+ const expanded = groupLabels.get(lc2(l)) ?? (main2 ? [main2] : []);
47272
48413
  const targets = expanded.filter((t) => t !== key);
47273
48414
  if (targets.length === 0) {
47274
48415
  notes.push(`Rule "${s.label} after ${l}" ignored: ${l} is not in this route.`);
@@ -47341,16 +48482,17 @@ function solveRoute(input2) {
47341
48482
 
47342
48483
  // src/tools/routes.ts
47343
48484
  var min2 = (s) => Math.round(s / 6) / 10;
47344
- var lc2 = (s) => s.toLowerCase();
48485
+ var lc3 = (s) => s.toLowerCase();
47345
48486
  var LONG_LEG_LY = 200;
47346
48487
  var PLOT_ROUNDS = 2;
47347
48488
  var PLOT_DIFF = 0.2;
47348
48489
  var PLOT_PENDING_NOTE = "Spansh is still plotting; call again to finish.";
47349
48490
  var PLOT_BUDGET_MS = 4e4;
47350
48491
  var StopInputSchema = external_exports.object({
47351
- label: external_exports.string().min(1).optional().describe("Name for rules and output; default the engineer, station or system"),
48492
+ label: external_exports.string().min(1).optional().describe("Name for rules and output; default the engineer, station, body or system"),
47352
48493
  system: external_exports.string().optional(),
47353
48494
  station: external_exports.string().optional(),
48495
+ body: external_exports.string().min(1).optional().describe("Planet or moon, for a stop with no station"),
47354
48496
  engineer: external_exports.string().optional().describe("Places the stop from the engineer catalog"),
47355
48497
  kind: StopKind.optional(),
47356
48498
  after: external_exports.array(external_exports.string()).optional().describe("Labels of stops that must come before this one")
@@ -47374,7 +48516,7 @@ function registerRouteTools(server, deps) {
47374
48516
  idempotentHint: true,
47375
48517
  openWorldHint: true
47376
48518
  },
47377
- description: "Order the stops for a build: shops, material traders and engineers, from a plan (plan_id) or a list (stops). Uses Spansh coordinates and the travel-time model. For each engineer stop, set `after` to the shops and traders that supply what that engineer needs (the module to buy, the materials to trade). Give `after: []` to an engineer who needs nothing from the other stops, so they can come first. Without your rules, a plan puts every engineer after every shop and trader. Use `one_of` for alternatives, for example two engineers who both do the work; the tool tries every combination (at most 16) and keeps the fastest. `only` and `skip` choose stops from the plan, so one plan can serve several trips. One place (the same engineer, or the same system and station) can be an option in several groups; it is visited once, and each stop's `meets` lists the groups it covers. Leg times are estimates from straight-line jumps; pass plot: true to plot the chosen route with Spansh.",
48519
+ description: "Order the stops of a plan (plan_id) or a list (stops): shops, material traders and engineers for a build, or the systems, bodies and stations of a route. Plan stops marked done or skipped are left out. Uses Spansh coordinates and the travel-time model. For each engineer stop, set `after` to the shops and traders that supply what that engineer needs (the module to buy, the materials to trade). Give `after: []` to an engineer who needs nothing from the other stops, so they can come first. Without your rules, a plan puts every engineer after every shop and trader. Use `one_of` for alternatives, for example two engineers who both do the work; the tool tries every combination (at most 16) and keeps the fastest. `only` and `skip` choose stops from the plan, so one plan can serve several trips. One place (the same engineer, or the same system and station) can be an option in several groups; it is visited once, and each stop's `meets` lists the groups it covers. Leg times are estimates from straight-line jumps; pass plot: true to plot the chosen route with Spansh.",
47378
48520
  inputSchema: {
47379
48521
  plan_id: external_exports.string().optional(),
47380
48522
  stops: external_exports.array(StopEntrySchema).optional(),
@@ -47409,9 +48551,9 @@ function registerRouteTools(server, deps) {
47409
48551
  countCombinations(pool.groups.map((g) => g.options.length));
47410
48552
  const all = [.../* @__PURE__ */ new Set([...pool.stops, ...pool.groups.flatMap((g) => g.options)])];
47411
48553
  const names = (s) => [s.label, ...s.aliases];
47412
- const labelIndex = new Map(all.map((s, i) => [lc2(s.label), i]));
48554
+ const labelIndex = new Map(all.map((s, i) => [lc3(s.label), i]));
47413
48555
  const graph = all.map(
47414
- (s) => (pool.after.get(lc2(s.label)) ?? []).flatMap((l) => labelIndex.get(l) ?? [])
48556
+ (s) => (pool.after.get(lc3(s.label)) ?? []).flatMap((l) => labelIndex.get(l) ?? [])
47415
48557
  );
47416
48558
  const cycle = findCycle(graph);
47417
48559
  if (cycle) {
@@ -47449,7 +48591,7 @@ function registerRouteTools(server, deps) {
47449
48591
  const seen = nodeOfStop.get(s);
47450
48592
  if (seen !== void 0) return seen;
47451
48593
  const p = await placeStop(s);
47452
- const key2 = p.station ? `${lc2(p.system)}|${lc2(p.station)}` : null;
48594
+ const key2 = p.station ? `${lc3(p.system)}|${lc3(p.station)}` : null;
47453
48595
  const hit = merge2 && key2 ? byStation.get(key2) : void 0;
47454
48596
  let idx;
47455
48597
  if (hit !== void 0) {
@@ -47458,6 +48600,9 @@ function registerRouteTools(server, deps) {
47458
48600
  node2.engineer ??= s.engineer?.name ?? null;
47459
48601
  if (s.kind === "engineer" || s.engineer) node2.kind = "engineer";
47460
48602
  node2.purpose = joinPurposes(node2.purpose, s.purpose);
48603
+ node2.notes = joinPurposes(node2.notes, s.notes);
48604
+ node2.body ??= s.body;
48605
+ node2.planStops.push(...s.planStops);
47461
48606
  idx = hit;
47462
48607
  } else {
47463
48608
  nodes.push({
@@ -47465,12 +48610,15 @@ function registerRouteTools(server, deps) {
47465
48610
  place: p,
47466
48611
  engineer: s.engineer?.name ?? null,
47467
48612
  kind: s.kind,
47468
- purpose: s.purpose
48613
+ purpose: s.purpose,
48614
+ body: s.body,
48615
+ notes: s.notes,
48616
+ planStops: [...s.planStops]
47469
48617
  });
47470
48618
  idx = nodes.length - 1;
47471
48619
  if (merge2 && key2) byStation.set(key2, idx);
47472
48620
  }
47473
- nodeOf.set(lc2(s.label), idx);
48621
+ nodeOf.set(lc3(s.label), idx);
47474
48622
  nodeOfStop.set(s, idx);
47475
48623
  return idx;
47476
48624
  };
@@ -47533,7 +48681,7 @@ function registerRouteTools(server, deps) {
47533
48681
  ...returnPlace ? [returnPlace] : []
47534
48682
  ];
47535
48683
  const leg = (from, to) => legBetween(places[from], places[to], travel);
47536
- const sameSystem = (f, t) => lc2(places[f].system) === lc2(places[t].system);
48684
+ const sameSystem = (f, t) => lc3(places[f].system) === lc3(places[t].system);
47537
48685
  const plottedJumps = /* @__PURE__ */ new Map();
47538
48686
  const unfinished = /* @__PURE__ */ new Set();
47539
48687
  const failed = /* @__PURE__ */ new Map();
@@ -47627,6 +48775,8 @@ function registerRouteTools(server, deps) {
47627
48775
  engineer: node2?.engineer ?? null,
47628
48776
  kind: node2 ? node2.kind : "other",
47629
48777
  purpose: node2 ? node2.purpose : null,
48778
+ ...node2?.body ? { body: node2.body } : {},
48779
+ ...node2?.notes ? { notes: node2.notes } : {},
47630
48780
  meets: allChosen.filter((c) => c.option === t).map((c) => c.group),
47631
48781
  jumps: plottedJumps.get(key(f, t)) ?? l.jumps,
47632
48782
  leg_min: min2(seconds),
@@ -47662,18 +48812,26 @@ function registerRouteTools(server, deps) {
47662
48812
  let saved = false;
47663
48813
  if (a.save_to_plan && a.plan_id) {
47664
48814
  try {
47665
- await deps.plans.setRoute(a.plan_id, {
47666
- computed_at: new Date(now()).toISOString(),
47667
- from: startPlace.system,
47668
- stops: [
47669
- ...solved.order.map(optionLabel),
47670
- ...returnPlace ? [
47671
- `return to ${returnPlace.system}${returnPlace.station ? ` \u2014 ${returnPlace.station}` : ""}`
47672
- ] : []
47673
- ],
47674
- total_min: total,
47675
- plotted: a.plot && allPlotted
47676
- });
48815
+ await deps.plans.setRoute(
48816
+ a.plan_id,
48817
+ {
48818
+ computed_at: new Date(now()).toISOString(),
48819
+ from: startPlace.system,
48820
+ stops: [
48821
+ ...solved.order.map(optionLabel),
48822
+ ...returnPlace ? [
48823
+ `return to ${returnPlace.system}${returnPlace.station ? ` \u2014 ${returnPlace.station}` : ""}`
48824
+ ] : []
48825
+ ],
48826
+ total_min: total,
48827
+ plotted: a.plot && allPlotted,
48828
+ stop_indexes: [
48829
+ ...solved.order.map((i) => [...new Set(nodes[i].planStops)]),
48830
+ ...returnPlace ? [[]] : []
48831
+ ]
48832
+ },
48833
+ { stops: plan?.stops }
48834
+ );
47677
48835
  saved = true;
47678
48836
  } catch (err) {
47679
48837
  notes.add(`The route was not saved to plan "${a.plan_id}": ${reason(err)}`);
@@ -47703,7 +48861,7 @@ function registerRouteTools(server, deps) {
47703
48861
 
47704
48862
  // src/tools/plans.ts
47705
48863
  import { randomUUID as randomUUID2 } from "crypto";
47706
- import { mkdir as mkdir6, readFile as readFile7, rename as rename5, stat as stat7, unlink as unlink2, writeFile as writeFile5 } from "fs/promises";
48864
+ import { mkdir as mkdir7, readFile as readFile7, rename as rename5, stat as stat7, unlink as unlink3, writeFile as writeFile5 } from "fs/promises";
47707
48865
  import path13 from "path";
47708
48866
  var MAX_ECHOED_PLAN = 100 * 1024;
47709
48867
  function registerPlanTools(server, deps) {
@@ -47743,7 +48901,7 @@ ${tail}`);
47743
48901
  idempotentHint: true,
47744
48902
  openWorldHint: false
47745
48903
  },
47746
- description: "List saved build plans with item counts and progress."
48904
+ description: "List saved plans with item counts, progress and next_stop (the first stop still to do). A plan is a ship build or a route, or both."
47747
48905
  },
47748
48906
  wrapWarn(async () => plans.list(), warnings)
47749
48907
  );
@@ -47757,10 +48915,13 @@ ${tail}`);
47757
48915
  idempotentHint: true,
47758
48916
  openWorldHint: false
47759
48917
  },
47760
- description: "Get a saved plan by id.",
48918
+ description: "Get a saved plan (a build or a route) by id. next_stop is the first stop still to do, in the saved route's order when there is one, else in the order of stops.",
47761
48919
  inputSchema: { id: external_exports.string() }
47762
48920
  },
47763
- wrapWarn(async ({ id }) => plans.get(id), warnings)
48921
+ wrapWarn(async ({ id }) => {
48922
+ const plan = await plans.get(id);
48923
+ return { ...plan, next_stop: nextStop(plan) };
48924
+ }, warnings)
47764
48925
  );
47765
48926
  server.registerTool(
47766
48927
  "plan_upsert",
@@ -47772,7 +48933,7 @@ ${tail}`);
47772
48933
  idempotentHint: true,
47773
48934
  openWorldHint: false
47774
48935
  },
47775
- description: "Create or replace a build plan. Items are things to buy, engineer, unlock, gather or trade, plus refits (moving modules between slots, storing modules, or a fitting decision); stops are the places to visit (optionally with label, kind and after rules); route is the saved visiting order. Replacing keeps created_at.",
48936
+ description: "Create or replace a plan. A plan is a ship build, a route, or both: for example system X, planet Y for bios, then planet Z, then station A to hand in. Items are things to buy, engineer, unlock, gather or trade, plus refits (moving modules between slots, storing modules, or a fitting decision). Stops are the places to visit, each with a system and optionally a station or body (planet or moon), a label, a kind, a status (todo, done, skipped), notes and after rules. Without a saved route, the order of stops is the visiting order; route is the order route_stops saved. Replacing keeps created_at.",
47776
48937
  inputSchema: { plan: PlanInputSchema }
47777
48938
  },
47778
48939
  wrapWarn(
@@ -47821,6 +48982,54 @@ ${tail}`);
47821
48982
  warnings
47822
48983
  )
47823
48984
  );
48985
+ server.registerTool(
48986
+ "plan_update_stop",
48987
+ {
48988
+ annotations: {
48989
+ title: "Update Plan Stop",
48990
+ readOnlyHint: false,
48991
+ destructiveHint: false,
48992
+ idempotentHint: true,
48993
+ openWorldHint: false
48994
+ },
48995
+ description: "Set the status (todo, done, skipped) and/or notes of one stop in a plan. Name the stop by label (its label, else its station, body or system) or by index in the plan's stops, from 0; give exactly one. Mark a stop done when the player has finished there; returns the plan with the new next_stop.",
48996
+ inputSchema: {
48997
+ plan_id: external_exports.string(),
48998
+ label: external_exports.string().min(1).optional(),
48999
+ index: external_exports.number().int().min(0).optional(),
49000
+ status: StopStatus.optional(),
49001
+ notes: external_exports.string().optional()
49002
+ }
49003
+ },
49004
+ wrapWarn(
49005
+ async (a) => {
49006
+ if (a.label === void 0 === (a.index === void 0)) {
49007
+ throw new UserError("Give exactly one of label or index to name the stop");
49008
+ }
49009
+ if (a.status === void 0 && a.notes === void 0) {
49010
+ throw new UserError("Give status or notes to update");
49011
+ }
49012
+ const which = a.label !== void 0 ? `"${a.label}"` : String(a.index);
49013
+ try {
49014
+ const plan = await plans.updateStop(
49015
+ a.plan_id,
49016
+ { label: a.label, index: a.index },
49017
+ { status: a.status, notes: a.notes }
49018
+ );
49019
+ return { ...plan, next_stop: nextStop(plan) };
49020
+ } catch (err) {
49021
+ const changes = [
49022
+ ...a.status !== void 0 ? [`status: ${a.status}`] : [],
49023
+ ...a.notes !== void 0 ? ["notes changed"] : []
49024
+ ].join(", ");
49025
+ throw new UserError(
49026
+ `Stop ${which} in plan "${a.plan_id}" was not updated (${changes}). ${reason(err)}`
49027
+ );
49028
+ }
49029
+ },
49030
+ warnings
49031
+ )
49032
+ );
47824
49033
  server.registerTool(
47825
49034
  "plan_delete",
47826
49035
  {
@@ -47871,13 +49080,13 @@ ${tail}`);
47871
49080
  if (!a.overwrite && await exists(target)) {
47872
49081
  throw new UserError(`${target} exists; pass overwrite: true to replace it`);
47873
49082
  }
47874
- await mkdir6(path13.dirname(target), { recursive: true });
49083
+ await mkdir7(path13.dirname(target), { recursive: true });
47875
49084
  const tmp = `${target}.${process.pid}.${randomUUID2()}.tmp`;
47876
49085
  try {
47877
49086
  await writeFile5(tmp, text);
47878
49087
  await rename5(tmp, target);
47879
49088
  } catch (err) {
47880
- await unlink2(tmp).catch(() => void 0);
49089
+ await unlink3(tmp).catch(() => void 0);
47881
49090
  throw err;
47882
49091
  }
47883
49092
  });
@@ -47985,35 +49194,35 @@ var TOP = 5;
47985
49194
  var NO_CATALOG = 'Module names need the game data catalog. Call any catalog tool (for example catalog_module) or cache_refresh with source "catalog".';
47986
49195
  var cap = (s) => s[0].toUpperCase() + s.slice(1);
47987
49196
  var titleCase = (symbol2) => symbol2.split("_").filter(Boolean).map(cap).join(" ");
47988
- var str4 = (v) => typeof v === "string" ? v : void 0;
47989
- var num5 = (v) => typeof v === "number" ? v : 0;
49197
+ var str5 = (v) => typeof v === "string" ? v : void 0;
49198
+ var num6 = (v) => typeof v === "number" ? v : 0;
47990
49199
  var list = (v) => Array.isArray(v) ? v : [];
47991
- var same3 = (a, b) => a.toLowerCase() === b.toLowerCase();
49200
+ var same5 = (a, b) => a.toLowerCase() === b.toLowerCase();
47992
49201
  var byText = (a, b) => a.localeCompare(b);
47993
49202
  function vsMean(price, mean) {
47994
49203
  return mean > 0 ? Math.round((price - mean) / mean * 100) : null;
47995
49204
  }
47996
49205
  function marketRows(file2, commodity) {
47997
49206
  const rows = list(file2.data.Items).map((i) => ({
47998
- name: str4(i.Name_Localised) ?? str4(i.Name) ?? "?",
47999
- category: str4(i.Category_Localised) ?? str4(i.Category) ?? "",
49207
+ name: str5(i.Name_Localised) ?? str5(i.Name) ?? "?",
49208
+ category: str5(i.Category_Localised) ?? str5(i.Category) ?? "",
48000
49209
  i
48001
49210
  })).filter((r) => !commodity || r.name.toLowerCase().includes(commodity.toLowerCase())).sort((a, b) => byText(a.category, b.category) || byText(a.name, b.name));
48002
- const sells = rows.filter((r) => num5(r.i.Stock) > 0 && num5(r.i.BuyPrice) > 0).map((r) => ({
49211
+ const sells = rows.filter((r) => num6(r.i.Stock) > 0 && num6(r.i.BuyPrice) > 0).map((r) => ({
48003
49212
  name: r.name,
48004
49213
  category: r.category,
48005
- buy_price: num5(r.i.BuyPrice),
48006
- stock: num5(r.i.Stock),
48007
- mean_price: num5(r.i.MeanPrice),
48008
- vs_mean_pct: vsMean(num5(r.i.BuyPrice), num5(r.i.MeanPrice))
49214
+ buy_price: num6(r.i.BuyPrice),
49215
+ stock: num6(r.i.Stock),
49216
+ mean_price: num6(r.i.MeanPrice),
49217
+ vs_mean_pct: vsMean(num6(r.i.BuyPrice), num6(r.i.MeanPrice))
48009
49218
  }));
48010
49219
  const buys = rows.filter((r) => marketDemand(r.i) > 0).map((r) => ({
48011
49220
  name: r.name,
48012
49221
  category: r.category,
48013
- sell_price: num5(r.i.SellPrice),
49222
+ sell_price: num6(r.i.SellPrice),
48014
49223
  demand: marketDemand(r.i),
48015
- mean_price: num5(r.i.MeanPrice),
48016
- vs_mean_pct: vsMean(num5(r.i.SellPrice), num5(r.i.MeanPrice))
49224
+ mean_price: num6(r.i.MeanPrice),
49225
+ vs_mean_pct: vsMean(num6(r.i.SellPrice), num6(r.i.MeanPrice))
48017
49226
  }));
48018
49227
  return { sells, buys };
48019
49228
  }
@@ -48025,7 +49234,7 @@ async function modules(file2, catalog, notes) {
48025
49234
  const cat = await catalog?.peek().catch(() => null);
48026
49235
  if (!cat) notes.push(NO_CATALOG);
48027
49236
  return list(file2.data.Items).flatMap((i) => {
48028
- const symbol2 = str4(i.Name);
49237
+ const symbol2 = str5(i.Name);
48029
49238
  if (!symbol2) return [];
48030
49239
  const found = cat?.moduleBySymbol(symbol2);
48031
49240
  return [
@@ -48035,7 +49244,7 @@ async function modules(file2, catalog, notes) {
48035
49244
  class: found?.class ?? null,
48036
49245
  rating: found?.rating ?? null,
48037
49246
  group: found?.group ?? null,
48038
- price: num5(i.BuyPrice)
49247
+ price: num6(i.BuyPrice)
48039
49248
  }
48040
49249
  ];
48041
49250
  });
@@ -48064,8 +49273,8 @@ function groupModules(mods) {
48064
49273
  }
48065
49274
  function ships(file2) {
48066
49275
  return list(file2.data.PriceList).map((s) => ({
48067
- name: str4(s.ShipType_Localised) ?? titleCase(str4(s.ShipType) ?? "?"),
48068
- price: num5(s.ShipPrice)
49276
+ name: str5(s.ShipType_Localised) ?? titleCase(str5(s.ShipType) ?? "?"),
49277
+ price: num6(s.ShipPrice)
48069
49278
  })).sort((a, b) => a.price - b.price);
48070
49279
  }
48071
49280
  function registerStationTools(server, deps) {
@@ -48109,7 +49318,7 @@ function registerStationTools(server, deps) {
48109
49318
  notes.push(`No ${kind2} data. Open the ${kind2} screen at a station first.`);
48110
49319
  continue;
48111
49320
  }
48112
- if (docked && !same3(file2.station, docked)) {
49321
+ if (docked && !same5(file2.station, docked)) {
48113
49322
  notes.push(
48114
49323
  `${label} last opened at ${file2.station} (${file2.system}), not the station where you are docked.`
48115
49324
  );
@@ -48757,6 +49966,7 @@ function registerSettingsTools(server, deps) {
48757
49966
  const files = (await existing(localDir, [
48758
49967
  "plans.json",
48759
49968
  "preferences.json",
49969
+ "activity.json",
48760
49970
  "secrets.json",
48761
49971
  "secrets.enc"
48762
49972
  ])).filter((n) => path14.resolve(localDir, n) !== own2);
@@ -48805,6 +50015,13 @@ function createServer3(deps) {
48805
50015
  warnings: deps.planWarnings ?? deps.syncWarnings
48806
50016
  });
48807
50017
  if (deps.prefs) registerPrefsTools(server, { prefs: deps.prefs, warnings: deps.syncWarnings });
50018
+ if (deps.activity) {
50019
+ registerActivityTools(server, {
50020
+ activity: deps.activity,
50021
+ journal: deps.journal,
50022
+ warnings: deps.syncWarnings
50023
+ });
50024
+ }
48808
50025
  if (deps.settingsPage && deps.settings && deps.secrets) {
48809
50026
  registerSettingsTools(server, {
48810
50027
  page: deps.settingsPage,