@robbeverhelst/do 1.282.0 → 1.284.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.
Files changed (2) hide show
  1. package/dist/do.js +695 -16
  2. package/package.json +2 -1
package/dist/do.js CHANGED
@@ -1038,7 +1038,7 @@ function flagBool(flags, name) {
1038
1038
 
1039
1039
  // src/commands.ts
1040
1040
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1041
- import { basename, dirname as dirname2, join as join3 } from "path";
1041
+ import { basename as basename2, dirname as dirname2, join as join4 } from "path";
1042
1042
 
1043
1043
  // ../../node_modules/drizzle-orm/entity.js
1044
1044
  var entityKind = Symbol.for("drizzle:entityKind");
@@ -4191,6 +4191,7 @@ var contacts = pgTable("contacts", {
4191
4191
  tenantId: text("tenant_id").notNull().references(() => tenants.id),
4192
4192
  kind: contactKind("kind").notNull().default("person"),
4193
4193
  email: text("email"),
4194
+ emailInvalidAt: timestamp("email_invalid_at", { withTimezone: true }),
4194
4195
  phone: text("phone"),
4195
4196
  company: text("company"),
4196
4197
  website: text("website"),
@@ -5821,6 +5822,7 @@ var journalImportItems = pgTable("journal_import_items", {
5821
5822
  body: text("body"),
5822
5823
  rating: doublePrecision("rating"),
5823
5824
  ratingQuestion: text("rating_question"),
5825
+ sleep: doublePrecision("sleep"),
5824
5826
  highlight: text("highlight"),
5825
5827
  milestoneCandidate: boolean("milestone_candidate").notNull().default(false),
5826
5828
  health: boolean("health").notNull().default(false),
@@ -22235,7 +22237,14 @@ var setHealthLabAnalyteInputSchema = object({
22235
22237
 
22236
22238
  // ../../packages/schema/src/contracts/files.ts
22237
22239
  var FILE_TEXT_MAX = 1e6;
22238
- var FILE_SOURCES = ["upload", "chat", "doccle", "doccle_zip", "scan"];
22240
+ var FILE_SOURCES = [
22241
+ "upload",
22242
+ "chat",
22243
+ "doccle",
22244
+ "doccle_zip",
22245
+ "scan",
22246
+ "game_save"
22247
+ ];
22239
22248
  var fileSourceSchema = union([
22240
22249
  _enum2(FILE_SOURCES),
22241
22250
  string2().regex(/^gmail:[A-Za-z0-9_-]{1,200}$/, "gmail:<message id>")
@@ -22466,6 +22475,7 @@ var journalImportItemInputSchema = object({
22466
22475
  body: string2().trim().min(1).max(20000).nullish(),
22467
22476
  rating: number2().min(1).max(10).nullish(),
22468
22477
  ratingQuestion: string2().trim().min(1).max(80).nullish(),
22478
+ sleep: number2().min(0).max(24).nullish(),
22469
22479
  highlight: string2().trim().min(1).max(200).nullish(),
22470
22480
  milestoneCandidate: boolean3().default(false),
22471
22481
  health: boolean3().default(false),
@@ -22494,6 +22504,7 @@ var journalImportItemSchema = object({
22494
22504
  body: string2().nullable(),
22495
22505
  rating: number2().nullable(),
22496
22506
  ratingQuestion: string2().nullable(),
22507
+ sleep: number2().nullable().default(null),
22497
22508
  highlight: string2().nullable(),
22498
22509
  milestoneCandidate: boolean3(),
22499
22510
  health: boolean3(),
@@ -22518,6 +22529,7 @@ var journalImportOverviewSchema = object({
22518
22529
  ratings: number2().int(),
22519
22530
  unclearRatings: array(string2()),
22520
22531
  milestoneCandidates: number2().int(),
22532
+ sleep: number2().int().default(0),
22521
22533
  health: number2().int(),
22522
22534
  links: number2().int()
22523
22535
  }),
@@ -23540,7 +23552,8 @@ var foodWeekSchema = object({
23540
23552
  points: array(object({ day: civilDate, value: number2() })),
23541
23553
  trend: array(object({ day: civilDate, value: number2() })),
23542
23554
  latest: number2().nullable(),
23543
- perWeek: number2().nullable()
23555
+ perWeek: number2().nullable(),
23556
+ source: metricSourceSchema.nullable()
23544
23557
  }),
23545
23558
  numbers: object({
23546
23559
  completeDays: number2().int(),
@@ -24144,7 +24157,8 @@ var contactSchema = createSelectSchema(contacts, {
24144
24157
  lastContactedAt: exports_coerce.date().nullable(),
24145
24158
  userId: string2().nullable().default(null),
24146
24159
  rhythmDays: number2().int().nullable().default(null),
24147
- rhythmSnoozedUntil: string2().nullable().default(null)
24160
+ rhythmSnoozedUntil: string2().nullable().default(null),
24161
+ emailInvalidAt: exports_coerce.date().nullable().default(null)
24148
24162
  });
24149
24163
  var contactImportantDateSchema = object({
24150
24164
  id: ulidSchema,
@@ -24188,7 +24202,7 @@ var createContactInputSchema = createInsertSchema(contacts, {
24188
24202
  lastContactedAt: exports_coerce.date().nullish(),
24189
24203
  rhythmDays: contactPeopleInputShape.rhythmDays,
24190
24204
  rhythmSnoozedUntil: contactPeopleInputShape.rhythmSnoozedUntil
24191
- }).omit({ tenantId: true, keepInTouchRuleId: true, userId: true }).extend({
24205
+ }).omit({ tenantId: true, keepInTouchRuleId: true, userId: true, emailInvalidAt: true }).extend({
24192
24206
  title: string2().min(1),
24193
24207
  parentId: ulidSchema.nullish(),
24194
24208
  importantDates: array(contactImportantDateInputSchema).optional(),
@@ -24205,7 +24219,7 @@ var updateContactInputSchema = createUpdateSchema(contacts, {
24205
24219
  lastContactedAt: exports_coerce.date().nullish(),
24206
24220
  rhythmDays: contactPeopleInputShape.rhythmDays,
24207
24221
  rhythmSnoozedUntil: contactPeopleInputShape.rhythmSnoozedUntil
24208
- }).omit({ id: true, tenantId: true, keepInTouchRuleId: true, userId: true }).extend({
24222
+ }).omit({ id: true, tenantId: true, keepInTouchRuleId: true, userId: true, emailInvalidAt: true }).extend({
24209
24223
  title: string2().min(1).optional(),
24210
24224
  parentId: ulidSchema.nullish(),
24211
24225
  importantDates: array(contactImportantDateInputSchema).optional(),
@@ -25296,6 +25310,7 @@ var runQuestSchema = object({
25296
25310
  status: runQuestStatusSchema.default("active"),
25297
25311
  objective: text5.nullish(),
25298
25312
  hint: shortText.nullish(),
25313
+ reward: shortText.nullish(),
25299
25314
  place: shortText.nullish(),
25300
25315
  steps: array(object({
25301
25316
  text: shortText.pipe(string2().min(1)),
@@ -25327,7 +25342,7 @@ var runChoiceSchema = object({
25327
25342
  var runHomeSchema = object({
25328
25343
  ref: ref.nullish(),
25329
25344
  name,
25330
- role: _enum2(["home", "collector", "storage", "planned"]).default("home"),
25345
+ role: _enum2(["home", "collector", "storage", "planned", "later"]).default("home"),
25331
25346
  place: shortText.nullish(),
25332
25347
  upgrades: array(object({ name, done: boolean3().default(false) }).strict()).max(40).optional(),
25333
25348
  steward: shortText.nullish(),
@@ -25394,6 +25409,35 @@ var runInstallSchema = object({
25394
25409
  lastBackup: isoDate.nullish(),
25395
25410
  notes: text5.nullish()
25396
25411
  }).strict();
25412
+ var SAVE_SCREENSHOT_MAX_BASE64 = 1500000;
25413
+ var runSaveFields = {
25414
+ game: _enum2(["skyrim"]).default("skyrim"),
25415
+ fileName: shortText.nullish(),
25416
+ saveNumber: number2().int().min(0).max(1e9),
25417
+ playerName: name,
25418
+ level: number2().int().min(0).max(1e4),
25419
+ location: shortText,
25420
+ race: shortText.nullish(),
25421
+ sex: _enum2(["male", "female"]).nullish(),
25422
+ playTimeMinutes: number2().int().min(0).max(1e4 * 60).nullish(),
25423
+ savedAt: string2().datetime({ offset: true }).nullish(),
25424
+ gameDate: object({
25425
+ text: shortText.pipe(string2().min(1)),
25426
+ hour: number2().min(0).max(24).nullish(),
25427
+ daysPassed: number2().min(0).max(1e6).nullish()
25428
+ }).strict().nullish(),
25429
+ plugins: array(shortText).max(600).optional(),
25430
+ stats: record(shortText, number2().int()).refine((r) => Object.keys(r).length <= 300, "at most 300 stats").optional()
25431
+ };
25432
+ var runSaveSchema = object({
25433
+ ...runSaveFields,
25434
+ screenshotFileId: string2().min(1).max(64).nullish(),
25435
+ uploadedAt: string2().datetime({ offset: true })
25436
+ }).strict();
25437
+ var uploadRunSaveHeaderInputSchema = object({
25438
+ ...runSaveFields,
25439
+ screenshot: string2().max(SAVE_SCREENSHOT_MAX_BASE64).regex(/^[A-Za-z0-9+/]+={0,2}$/, "must be base64").nullish()
25440
+ }).strict();
25397
25441
  var runFacetSchema = object({
25398
25442
  theme: runThemeSchema.default("generic"),
25399
25443
  edition: shortText.nullish(),
@@ -25414,6 +25458,7 @@ var runFacetSchema = object({
25414
25458
  achievements: runAchievementsSchema.nullish(),
25415
25459
  rareFinds: runRareFindsSchema.nullish(),
25416
25460
  install: runInstallSchema.nullish(),
25461
+ save: runSaveSchema.nullish(),
25417
25462
  money: object({ goal: shortText.nullish(), purpose: shortText.nullish() }).strict().nullish(),
25418
25463
  links: array(object({ label: name, url: string2().url().max(2000) }).strict()).max(50).optional()
25419
25464
  }).strict();
@@ -25441,6 +25486,7 @@ var runValueSectionValues = [
25441
25486
  "achievements",
25442
25487
  "rareFinds",
25443
25488
  "install",
25489
+ "save",
25444
25490
  "money"
25445
25491
  ];
25446
25492
  var runValueSectionSchema = _enum2(runValueSectionValues);
@@ -25544,6 +25590,12 @@ var runChangesResultSchema = object({
25544
25590
  sessionId: string2(),
25545
25591
  batch: runChangeBatchSchema
25546
25592
  });
25593
+ var runSaveHeaderResultSchema = object({
25594
+ run: runDetailSchema,
25595
+ unchanged: boolean3(),
25596
+ sessionId: string2().nullable(),
25597
+ batch: runChangeBatchSchema.nullable()
25598
+ });
25547
25599
  var createRunInputSchema = object({
25548
25600
  id: ulidSchema,
25549
25601
  gameId: ulidSchema,
@@ -26011,7 +26063,8 @@ var animalHandoverTripSchema = object({
26011
26063
  houseNote: string2().nullable(),
26012
26064
  holidayMode: animalHolidayModeSchema,
26013
26065
  sharedUntil: string2().nullable(),
26014
- agentNote: string2().nullable()
26066
+ agentNote: string2().nullable(),
26067
+ calendar: string2().nullable()
26015
26068
  });
26016
26069
  var animalHandoverAnimalSchema = object({
26017
26070
  id: ulidSchema,
@@ -26028,6 +26081,7 @@ var animalHandoverAnimalSchema = object({
26028
26081
  var animalHandoverContactSchema = object({
26029
26082
  id: ulidSchema,
26030
26083
  title: string2(),
26084
+ kind: _enum2(["vet", "member"]),
26031
26085
  label: string2().nullable(),
26032
26086
  phone: string2().nullable()
26033
26087
  });
@@ -26042,6 +26096,7 @@ var animalHandoverSchema = object({
26042
26096
  contacts: array(animalHandoverContactSchema),
26043
26097
  supplies: array(animalHandoverSupplySchema),
26044
26098
  habits: array(animalHandoverHabitSchema),
26099
+ sensors: boolean3(),
26045
26100
  generatedAt: exports_coerce.date()
26046
26101
  });
26047
26102
  var animalHolidayModeInputSchema = animalHolidayModeSchema.partial().extend({ periodId: ulidSchema }).strict().refine((v) => Object.keys(v).length > 1, { message: "Turn at least one switch." });
@@ -28315,7 +28370,7 @@ function parseCapture2(input, now) {
28315
28370
 
28316
28371
  // src/meta.ts
28317
28372
  var CLI_NAME = "do";
28318
- var VERSION = "1.282.0";
28373
+ var VERSION = "1.284.0";
28319
28374
  var API_PREFIX = "/api/v1";
28320
28375
  var DEFAULT_API_URL = "http://localhost:3001";
28321
28376
 
@@ -29799,6 +29854,41 @@ var HEALTH = /\b(symptoms?|stomach|nausea|headaches?|migraine|doctor|GP|medicati
29799
29854
  var NOISE = /\b(PR monitor|GitHub daily|deploy(?:ment)? watch\w*|cron|heartbeat|sales prospects?)\b/i;
29800
29855
  var MILESTONE = /\b(first|went live|is live|win:|moved in|arrived|wedding|engaged)\b/i;
29801
29856
  var PR_REF = /\bPR\s*#\d+/gi;
29857
+ var SLEPT = /\bslept\s*~?\s*(\d{1,2})\s*h(?:\s*(\d{1,2}))?/i;
29858
+ var SLEEP_HOURS = /\bsleep\s*:?\s*~?\s*(\d{1,2}(?:\.\d+)?)\s*h\b/i;
29859
+ var atClock = (word) => [
29860
+ new RegExp(`\\b${word}\\b\\D{0,12}?(\\d{1,2})[:.h](\\d{2})`, "i"),
29861
+ new RegExp(`(\\d{1,2})[:.h](\\d{2})\\D{0,6}?\\b${word}\\b`, "i")
29862
+ ];
29863
+ var BED_AT = atClock("(?:bed|asleep|lights out)");
29864
+ var WOKE_AT = atClock("(?:woke(?: up)?|wake(?: up)?|awake|got up|up at)");
29865
+ function sleepOf(raw) {
29866
+ const lines = raw.map(plain);
29867
+ for (const line of lines) {
29868
+ const slept = SLEPT.exec(line);
29869
+ if (slept)
29870
+ return Number(slept[1]) + Number(slept[2] ?? 0) / 60;
29871
+ const hours = SLEEP_HOURS.exec(line);
29872
+ if (hours)
29873
+ return Number(hours[1]);
29874
+ }
29875
+ const clock = (patterns) => {
29876
+ for (const line of lines) {
29877
+ for (const re of patterns) {
29878
+ const m = re.exec(line);
29879
+ if (m)
29880
+ return Number(m[1]) * 60 + Number(m[2]);
29881
+ }
29882
+ }
29883
+ return null;
29884
+ };
29885
+ const bed = clock(BED_AT);
29886
+ const woke = clock(WOKE_AT);
29887
+ if (bed === null || woke === null)
29888
+ return null;
29889
+ const minutes = (woke - bed + 24 * 60) % (24 * 60);
29890
+ return minutes >= 120 && minutes <= 16 * 60 ? Math.round(minutes / 60 * 100) / 100 : null;
29891
+ }
29802
29892
  var TASK_DONE = /^\s*[-*]\s*\[x\]/i;
29803
29893
  function section(text, heading) {
29804
29894
  const lines = text.split(/\r?\n/);
@@ -29864,6 +29954,7 @@ function parseDailyNote(path, day, text) {
29864
29954
  }
29865
29955
  const milestoneLine = all.find((l) => MILESTONE.test(l) && !HEALTH.test(l) && !NOISE.test(l) && plain(l).length > 0);
29866
29956
  const highlight = milestoneLine ? firstSentence(plain(milestoneLine), 80) : null;
29957
+ const sleep = sleepOf(all);
29867
29958
  const prs = (text.match(PR_REF) ?? []).length;
29868
29959
  const tasks = all.filter((l) => TASK_DONE.test(l)).length;
29869
29960
  return {
@@ -29876,6 +29967,7 @@ function parseDailyNote(path, day, text) {
29876
29967
  body,
29877
29968
  rating,
29878
29969
  ratingQuestion,
29970
+ ...sleep !== null ? { sleep } : {},
29879
29971
  highlight,
29880
29972
  milestoneCandidate: Boolean(highlight),
29881
29973
  health,
@@ -33305,6 +33397,34 @@ var COMMAND_SPECS = [
33305
33397
  returns: "RunChangesResult",
33306
33398
  notes: "POST /api/v1/runs/{id}/changes. Ops: set|merge on a one-value section (character, arc, nextSteps, achievements, install, \u2026), upsert|remove on a list section matched by name (quests, choices, homes, people, mounts, uniques, orderOfPlay, team, checklists, links), tick on a checklist. The batch joins the last session if it was active in the last 6 hours, else opens a new one. A change that leaves the run invalid writes nothing."
33307
33399
  },
33400
+ {
33401
+ path: ["runs", "save-header"],
33402
+ summary: "Read a Skyrim save (read-only) and send its header and screenshot to a run.",
33403
+ args: [
33404
+ { name: "id", required: true, description: "Run ULID." },
33405
+ {
33406
+ name: "save",
33407
+ required: true,
33408
+ description: "A .ess save, or the Saves folder (its newest save)."
33409
+ }
33410
+ ],
33411
+ flags: [
33412
+ {
33413
+ name: "watch",
33414
+ type: "boolean",
33415
+ description: "Keep watching the Saves folder; send each new save once the game has written it."
33416
+ },
33417
+ {
33418
+ name: "interval",
33419
+ type: "string",
33420
+ description: "With --watch: seconds between looks (default 10)."
33421
+ },
33422
+ { name: "dry-run", type: "boolean", description: "Print what the save says; send nothing." }
33423
+ ],
33424
+ scopes: ["collections:write"],
33425
+ returns: "RunSaveHeaderResult",
33426
+ notes: "POST /api/v1/runs/{id}/save-header. The save sync (run tracker phase 4): name, level, location, race, play time, the in-game date, plugins and General Stats become the run's `save` section and its character's level, as one update with a receipt and Undo; the screenshot is a file on the run (the last one is dropped). The same save twice changes nothing. Needs a member's key (the screenshot is stored as theirs). The save itself is only read. See docs/agents/skyrim-save-sync.md."
33427
+ },
33308
33428
  {
33309
33429
  path: ["runs", "undo"],
33310
33430
  summary: "Undo a run's latest chat update (or the one given).",
@@ -35288,6 +35408,534 @@ function renderEvents(data, mode) {
35288
35408
  `);
35289
35409
  }
35290
35410
 
35411
+ // src/save-sync.ts
35412
+ import { readFile, stat as stat2 } from "fs/promises";
35413
+ import { basename } from "path";
35414
+
35415
+ // ../../packages/skyrim-save/src/body.ts
35416
+ import { inflateSync } from "zlib";
35417
+
35418
+ // ../../packages/skyrim-save/src/reader.ts
35419
+ class SaveFormatError extends Error {
35420
+ name = "SaveFormatError";
35421
+ }
35422
+
35423
+ class Reader {
35424
+ bytes;
35425
+ view;
35426
+ offset;
35427
+ constructor(bytes, offset = 0) {
35428
+ this.bytes = bytes;
35429
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
35430
+ this.offset = offset;
35431
+ }
35432
+ get remaining() {
35433
+ return this.bytes.byteLength - this.offset;
35434
+ }
35435
+ need(n, what) {
35436
+ if (n < 0 || this.offset + n > this.bytes.byteLength) {
35437
+ throw new SaveFormatError(`the save ends before its ${what} (at byte ${this.offset})`);
35438
+ }
35439
+ const at = this.offset;
35440
+ this.offset += n;
35441
+ return at;
35442
+ }
35443
+ u8(what) {
35444
+ return this.view.getUint8(this.need(1, what));
35445
+ }
35446
+ u16(what) {
35447
+ return this.view.getUint16(this.need(2, what), true);
35448
+ }
35449
+ u32(what) {
35450
+ return this.view.getUint32(this.need(4, what), true);
35451
+ }
35452
+ f32(what) {
35453
+ return this.view.getFloat32(this.need(4, what), true);
35454
+ }
35455
+ u64(what) {
35456
+ return this.view.getBigUint64(this.need(8, what), true);
35457
+ }
35458
+ take(n, what) {
35459
+ const at = this.need(n, what);
35460
+ return this.bytes.subarray(at, at + n);
35461
+ }
35462
+ wstring(what) {
35463
+ const len = this.u16(what);
35464
+ return decodeLatin1(this.take(len, what));
35465
+ }
35466
+ }
35467
+ function decodeLatin1(bytes) {
35468
+ let out = "";
35469
+ for (const b of bytes)
35470
+ out += String.fromCharCode(b);
35471
+ return out;
35472
+ }
35473
+
35474
+ // ../../packages/skyrim-save/src/lz4.ts
35475
+ function lz4DecompressBlock(src, size) {
35476
+ const out = new Uint8Array(size);
35477
+ let i = 0;
35478
+ let o = 0;
35479
+ while (i < src.length) {
35480
+ const token = src[i++];
35481
+ let literals = token >> 4;
35482
+ if (literals === 15) {
35483
+ let b;
35484
+ do {
35485
+ b = byteAt(src, i++);
35486
+ literals += b;
35487
+ } while (b === 255);
35488
+ }
35489
+ if (i + literals > src.length || o + literals > size) {
35490
+ throw new SaveFormatError("the save's body is cut short (LZ4 literals)");
35491
+ }
35492
+ out.set(src.subarray(i, i + literals), o);
35493
+ i += literals;
35494
+ o += literals;
35495
+ if (i >= src.length)
35496
+ break;
35497
+ const offset = byteAt(src, i) | byteAt(src, i + 1) << 8;
35498
+ i += 2;
35499
+ if (offset === 0 || offset > o)
35500
+ throw new SaveFormatError("a bad LZ4 match in the save's body");
35501
+ let length = (token & 15) + 4;
35502
+ if ((token & 15) === 15) {
35503
+ let b;
35504
+ do {
35505
+ b = byteAt(src, i++);
35506
+ length += b;
35507
+ } while (b === 255);
35508
+ }
35509
+ if (o + length > size)
35510
+ throw new SaveFormatError("the save's body is longer than it says");
35511
+ for (let k = 0;k < length; k++, o++)
35512
+ out[o] = out[o - offset];
35513
+ }
35514
+ if (o !== size)
35515
+ throw new SaveFormatError("the save's body is shorter than it says");
35516
+ return out;
35517
+ }
35518
+ function byteAt(src, i) {
35519
+ const b = src[i];
35520
+ if (b === undefined)
35521
+ throw new SaveFormatError("the save's body is cut short (LZ4)");
35522
+ return b;
35523
+ }
35524
+
35525
+ // ../../packages/skyrim-save/src/body.ts
35526
+ var SAVE_STAT_CATEGORIES = [
35527
+ "general",
35528
+ "quest",
35529
+ "combat",
35530
+ "magic",
35531
+ "crafting",
35532
+ "crime",
35533
+ "dlc"
35534
+ ];
35535
+ var TAMRIEL_MONTHS = [
35536
+ "Morning Star",
35537
+ "Sun's Dawn",
35538
+ "First Seed",
35539
+ "Rain's Hand",
35540
+ "Second Seed",
35541
+ "Midyear",
35542
+ "Sun's Height",
35543
+ "Last Seed",
35544
+ "Hearthfire",
35545
+ "Frostfall",
35546
+ "Sun's Dusk",
35547
+ "Evening Star"
35548
+ ];
35549
+ var CLOCK_GLOBALS = {
35550
+ year: 53,
35551
+ month: 54,
35552
+ day: 55,
35553
+ hour: 56,
35554
+ daysPassed: 57
35555
+ };
35556
+ var MISC_STATS = 0;
35557
+ var GLOBAL_VARIABLES = 3;
35558
+ var LIGHT_PLUGINS_FROM = 78;
35559
+ function decompressBody(bytes, header) {
35560
+ if (header.compression === "none")
35561
+ return bytes.subarray(header.bodyOffset);
35562
+ const r = new Reader(bytes, header.bodyOffset);
35563
+ const uncompressed = r.u32("uncompressed size");
35564
+ const compressed = r.u32("compressed size");
35565
+ if (uncompressed > 512 * 1024 * 1024)
35566
+ throw new SaveFormatError("the save's body is too big");
35567
+ const block = r.take(compressed, "compressed body");
35568
+ if (header.compression === "lz4")
35569
+ return lz4DecompressBlock(block, uncompressed);
35570
+ const out = new Uint8Array(inflateSync(block));
35571
+ if (out.byteLength !== uncompressed) {
35572
+ throw new SaveFormatError("the save's body is not the size it says");
35573
+ }
35574
+ return out;
35575
+ }
35576
+ function parseSaveBody(bytes, header) {
35577
+ const body = decompressBody(bytes, header);
35578
+ const r = new Reader(body);
35579
+ const formVersion = r.u8("form version");
35580
+ const pluginInfoSize = r.u32("plugin info size");
35581
+ const pluginsEnd = r.offset + pluginInfoSize;
35582
+ const plugins = [];
35583
+ const pluginCount = r.u8("plugin count");
35584
+ for (let i = 0;i < pluginCount; i++)
35585
+ plugins.push(r.wstring("plugin name"));
35586
+ const lightPlugins = [];
35587
+ if (header.version >= 12 && formVersion >= LIGHT_PLUGINS_FROM) {
35588
+ const lightCount = r.u16("light plugin count");
35589
+ for (let i = 0;i < lightCount; i++)
35590
+ lightPlugins.push(r.wstring("light plugin name"));
35591
+ }
35592
+ if (r.offset !== pluginsEnd) {
35593
+ throw new SaveFormatError("the save's plugin list is not the size it says");
35594
+ }
35595
+ const formIdArrayAt = r.u32("form id array offset");
35596
+ r.u32("unknown table offset");
35597
+ const globals1At = r.u32("global data table 1 offset");
35598
+ r.u32("global data table 2 offset");
35599
+ r.u32("change forms offset");
35600
+ r.u32("global data table 3 offset");
35601
+ const globals1Count = r.u32("global data table 1 count");
35602
+ const base = header.bodyOffset;
35603
+ const formIds = readFormIdArray(body, formIdArrayAt - base);
35604
+ const { globals, stats } = readGlobalData(body, globals1At - base, globals1Count, formIds);
35605
+ return { formVersion, plugins, lightPlugins, gameTime: clockFrom(globals), stats };
35606
+ }
35607
+ function readFormIdArray(body, at) {
35608
+ const r = new Reader(body, at);
35609
+ const count = r.u32("form id count");
35610
+ if (count * 4 > r.remaining)
35611
+ throw new SaveFormatError("the save's form id array is cut short");
35612
+ const out = new Uint32Array(count);
35613
+ for (let i = 0;i < count; i++)
35614
+ out[i] = r.u32("form id");
35615
+ return out;
35616
+ }
35617
+ function readGlobalData(body, at, count, formIds) {
35618
+ const r = new Reader(body, at);
35619
+ const globals = new Map;
35620
+ const stats = [];
35621
+ for (let i = 0;i < count; i++) {
35622
+ const type = r.u32("global data type");
35623
+ const length = r.u32("global data length");
35624
+ const g = new Reader(r.take(length, "global data"));
35625
+ if (type === MISC_STATS) {
35626
+ const n = g.u32("stat count");
35627
+ for (let k = 0;k < n; k++) {
35628
+ const name = g.wstring("stat name");
35629
+ const category = SAVE_STAT_CATEGORIES[g.u8("stat category")] ?? "general";
35630
+ const value = g.u32("stat value") | 0;
35631
+ if (value !== 0)
35632
+ stats.push({ name, category, value });
35633
+ }
35634
+ } else if (type === GLOBAL_VARIABLES) {
35635
+ const n = vsval(g);
35636
+ for (let k = 0;k < n; k++) {
35637
+ const id = refId(g, formIds);
35638
+ globals.set(id, g.f32("global value"));
35639
+ }
35640
+ }
35641
+ }
35642
+ return { globals, stats };
35643
+ }
35644
+ function clockFrom(globals) {
35645
+ const get = (id) => globals.get(id);
35646
+ const year = get(CLOCK_GLOBALS.year);
35647
+ const month = get(CLOCK_GLOBALS.month);
35648
+ const day = get(CLOCK_GLOBALS.day);
35649
+ const hour = get(CLOCK_GLOBALS.hour);
35650
+ const daysPassed = get(CLOCK_GLOBALS.daysPassed);
35651
+ if (year === undefined || month === undefined || day === undefined || hour === undefined) {
35652
+ return null;
35653
+ }
35654
+ return {
35655
+ year: Math.round(year),
35656
+ month: Math.round(month),
35657
+ day: Math.round(day),
35658
+ hour,
35659
+ daysPassed: daysPassed ?? 0
35660
+ };
35661
+ }
35662
+ function vsval(r) {
35663
+ const first = r.u8("vsval");
35664
+ const size = first & 3;
35665
+ if (size === 0)
35666
+ return first >> 2;
35667
+ if (size === 1)
35668
+ return (first | r.u8("vsval") << 8) >> 2;
35669
+ const rest = r.u16("vsval");
35670
+ return (first | rest << 8) >>> 2;
35671
+ }
35672
+ function refId(r, formIds) {
35673
+ const b0 = r.u8("ref id");
35674
+ const b1 = r.u8("ref id");
35675
+ const b2 = r.u8("ref id");
35676
+ const value = (b0 & 63) << 16 | b1 << 8 | b2;
35677
+ const type = b0 >> 6;
35678
+ if (type === 1)
35679
+ return value;
35680
+ if (type === 2)
35681
+ return (4278190080 | value) >>> 0;
35682
+ if (type === 0)
35683
+ return value === 0 ? 0 : formIds[value - 1] ?? 0;
35684
+ return 0;
35685
+ }
35686
+ function formatGameDate(time) {
35687
+ const month = TAMRIEL_MONTHS[time.month] ?? `month ${time.month + 1}`;
35688
+ return `${ordinal(time.day)} of ${month}, 4E ${time.year}`;
35689
+ }
35690
+ function ordinal(n) {
35691
+ const tens = n % 100;
35692
+ if (tens >= 11 && tens <= 13)
35693
+ return `${n}th`;
35694
+ const suffix = n % 10 === 1 ? "st" : n % 10 === 2 ? "nd" : n % 10 === 3 ? "rd" : "th";
35695
+ return `${n}${suffix}`;
35696
+ }
35697
+ // ../../packages/skyrim-save/src/header.ts
35698
+ var SAVE_MAGIC = "TESV_SAVEGAME";
35699
+ var SE_VERSION = 12;
35700
+ var FILETIME_UNIX_EPOCH = 116444736000000000n;
35701
+ function parseSaveHeader(bytes) {
35702
+ const r = new Reader(bytes);
35703
+ const magic = String.fromCharCode(...r.take(SAVE_MAGIC.length, "magic"));
35704
+ if (magic !== SAVE_MAGIC)
35705
+ throw new SaveFormatError("not a Skyrim save (no TESV_SAVEGAME magic)");
35706
+ const headerSize = r.u32("header size");
35707
+ const headerStart = r.offset;
35708
+ const version = r.u32("version");
35709
+ if (version < 7 || version > 64)
35710
+ throw new SaveFormatError(`unknown save version ${version}`);
35711
+ const saveNumber = r.u32("save number");
35712
+ const playerName = r.wstring("player name");
35713
+ const level = r.u32("level");
35714
+ const location = r.wstring("location");
35715
+ const gameDate = r.wstring("game date");
35716
+ const raceEditorId = r.wstring("race");
35717
+ const sexCode = r.u16("sex");
35718
+ const current = r.f32("experience");
35719
+ const toNextLevel = r.f32("experience to next level");
35720
+ const filetime = r.u64("save time");
35721
+ const width = r.u32("screenshot width");
35722
+ const height = r.u32("screenshot height");
35723
+ let compression = "none";
35724
+ if (version >= SE_VERSION) {
35725
+ const code = r.u16("compression");
35726
+ compression = code === 1 ? "zlib" : code === 2 ? "lz4" : "none";
35727
+ }
35728
+ if (r.offset - headerStart !== headerSize) {
35729
+ throw new SaveFormatError(`the header says ${headerSize} bytes but its fields take ${r.offset - headerStart}`);
35730
+ }
35731
+ if (width > 4096 || height > 4096) {
35732
+ throw new SaveFormatError(`a ${width}\xD7${height} screenshot is not a save's`);
35733
+ }
35734
+ const bytesPerPixel = version >= SE_VERSION ? 4 : 3;
35735
+ const pixels = r.take(width * height * bytesPerPixel, "screenshot");
35736
+ return {
35737
+ version,
35738
+ saveNumber,
35739
+ playerName,
35740
+ level,
35741
+ location,
35742
+ gameDate,
35743
+ playTimeMinutes: playTimeMinutes(gameDate),
35744
+ raceEditorId,
35745
+ race: raceName(raceEditorId),
35746
+ sex: sexCode === 1 ? "female" : "male",
35747
+ experience: { current, toNextLevel },
35748
+ savedAt: filetimeToIso(filetime),
35749
+ screenshot: { width, height, bytesPerPixel, pixels },
35750
+ compression,
35751
+ bodyOffset: r.offset
35752
+ };
35753
+ }
35754
+ function playTimeMinutes(gameDate) {
35755
+ const m = /^(\d+)\.(\d{1,2})\.(\d{1,2})$/.exec(gameDate.trim());
35756
+ if (!m)
35757
+ return null;
35758
+ return Number(m[1]) * 60 + Number(m[2]);
35759
+ }
35760
+ function raceName(editorId) {
35761
+ const base = editorId.replace(/Vampire$/, "").replace(/Race$/, "");
35762
+ return base.replace(/([a-z])([A-Z])/g, "$1 $2") || editorId;
35763
+ }
35764
+ function filetimeToIso(filetime) {
35765
+ if (filetime <= FILETIME_UNIX_EPOCH)
35766
+ return null;
35767
+ const ms = Number((filetime - FILETIME_UNIX_EPOCH) / 10000n);
35768
+ const date = new Date(ms);
35769
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
35770
+ }
35771
+ // ../../packages/skyrim-save/src/png.ts
35772
+ import { deflateSync } from "zlib";
35773
+ var SIGNATURE = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
35774
+ function screenshotToPng(shot) {
35775
+ const { width, height, bytesPerPixel, pixels } = shot;
35776
+ if (width === 0 || height === 0)
35777
+ throw new RangeError("the save has no screenshot");
35778
+ const stride = width * bytesPerPixel;
35779
+ const raw = new Uint8Array((stride + 1) * height);
35780
+ for (let y = 0;y < height; y++) {
35781
+ raw.set(pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
35782
+ }
35783
+ const ihdr = new Uint8Array(13);
35784
+ const v = new DataView(ihdr.buffer);
35785
+ v.setUint32(0, width);
35786
+ v.setUint32(4, height);
35787
+ ihdr[8] = 8;
35788
+ ihdr[9] = bytesPerPixel === 4 ? 6 : 2;
35789
+ return concat([
35790
+ SIGNATURE,
35791
+ chunk("IHDR", ihdr),
35792
+ chunk("IDAT", new Uint8Array(deflateSync(raw))),
35793
+ chunk("IEND", new Uint8Array(0))
35794
+ ]);
35795
+ }
35796
+ function chunk(type, data) {
35797
+ const out = new Uint8Array(12 + data.byteLength);
35798
+ const v = new DataView(out.buffer);
35799
+ v.setUint32(0, data.byteLength);
35800
+ for (let i = 0;i < 4; i++)
35801
+ out[4 + i] = type.charCodeAt(i);
35802
+ out.set(data, 8);
35803
+ v.setUint32(8 + data.byteLength, crc32(out.subarray(4, 8 + data.byteLength)));
35804
+ return out;
35805
+ }
35806
+ var table = null;
35807
+ function crc32(bytes) {
35808
+ if (!table) {
35809
+ table = new Uint32Array(256);
35810
+ for (let n = 0;n < 256; n++) {
35811
+ let c = n;
35812
+ for (let k = 0;k < 8; k++)
35813
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
35814
+ table[n] = c >>> 0;
35815
+ }
35816
+ }
35817
+ let crc = 4294967295;
35818
+ for (const b of bytes)
35819
+ crc = table[(crc ^ b) & 255] ^ crc >>> 8;
35820
+ return (crc ^ 4294967295) >>> 0;
35821
+ }
35822
+ function concat(parts) {
35823
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
35824
+ let at = 0;
35825
+ for (const p of parts) {
35826
+ out.set(p, at);
35827
+ at += p.byteLength;
35828
+ }
35829
+ return out;
35830
+ }
35831
+ // ../../packages/skyrim-save/src/save.ts
35832
+ function readSave(bytes) {
35833
+ const header = parseSaveHeader(bytes);
35834
+ let body = null;
35835
+ let bodyError = null;
35836
+ try {
35837
+ body = parseSaveBody(bytes, header);
35838
+ } catch (err) {
35839
+ if (!(err instanceof SaveFormatError))
35840
+ throw err;
35841
+ bodyError = err.message;
35842
+ }
35843
+ const shot = header.screenshot;
35844
+ return {
35845
+ header,
35846
+ body,
35847
+ bodyError,
35848
+ summary: summarize(header, body),
35849
+ screenshotPng: shot.width > 0 && shot.height > 0 ? screenshotToPng(shot) : null
35850
+ };
35851
+ }
35852
+ function summarize(header, body) {
35853
+ const time = body?.gameTime ?? null;
35854
+ return {
35855
+ version: header.version,
35856
+ saveNumber: header.saveNumber,
35857
+ playerName: header.playerName,
35858
+ level: header.level,
35859
+ location: header.location,
35860
+ race: header.race,
35861
+ sex: header.sex,
35862
+ playTimeMinutes: header.playTimeMinutes,
35863
+ savedAt: header.savedAt,
35864
+ gameDate: time ? {
35865
+ text: formatGameDate(time),
35866
+ hour: Math.round(time.hour * 100) / 100,
35867
+ daysPassed: Math.round(time.daysPassed * 100) / 100
35868
+ } : null,
35869
+ plugins: body?.plugins ?? [],
35870
+ lightPlugins: body?.lightPlugins ?? [],
35871
+ stats: Object.fromEntries((body?.stats ?? []).map((s) => [s.name, s.value]))
35872
+ };
35873
+ }
35874
+ // ../../packages/skyrim-save/src/newest.ts
35875
+ import { readdir, stat } from "fs/promises";
35876
+ import { join as join3 } from "path";
35877
+ async function newestSave(dir) {
35878
+ let best = null;
35879
+ for (const name of await readdir(dir)) {
35880
+ if (!name.toLowerCase().endsWith(".ess"))
35881
+ continue;
35882
+ const path = join3(dir, name);
35883
+ const info = await stat(path);
35884
+ if (!info.isFile())
35885
+ continue;
35886
+ if (!best || info.mtimeMs > best.at)
35887
+ best = { path, at: info.mtimeMs };
35888
+ }
35889
+ return best?.path ?? null;
35890
+ }
35891
+ // src/save-sync.ts
35892
+ async function resolveSave(path) {
35893
+ const info = await stat2(path).catch(() => null);
35894
+ if (!info)
35895
+ throw usageError(`no such file or folder: ${path}`);
35896
+ if (!info.isDirectory())
35897
+ return path;
35898
+ const newest = await newestSave(path);
35899
+ if (!newest)
35900
+ throw usageError(`no .ess save in ${path}`);
35901
+ return newest;
35902
+ }
35903
+ async function saveUpload(path) {
35904
+ const save = readSave(new Uint8Array(await readFile(path)));
35905
+ const { version: _version, lightPlugins, plugins, ...summary } = save.summary;
35906
+ return uploadRunSaveHeaderInputSchema.parse({
35907
+ ...summary,
35908
+ fileName: basename(path),
35909
+ plugins: [...plugins, ...lightPlugins].slice(0, 600),
35910
+ screenshot: save.screenshotPng ? Buffer.from(save.screenshotPng).toString("base64") : null
35911
+ });
35912
+ }
35913
+ async function watchSaves(opts) {
35914
+ const interval = opts.intervalMs ?? 1e4;
35915
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
35916
+ let seen = null;
35917
+ let sent = null;
35918
+ while (!opts.signal?.aborted) {
35919
+ try {
35920
+ const path = await newestSave(opts.dir);
35921
+ if (path) {
35922
+ const info = await stat2(path);
35923
+ const mark = `${path}|${info.mtimeMs}|${info.size}`;
35924
+ if (mark === seen && mark !== sent) {
35925
+ await opts.onSave(path);
35926
+ sent = mark;
35927
+ }
35928
+ seen = mark;
35929
+ }
35930
+ } catch (err) {
35931
+ opts.onError?.(err);
35932
+ }
35933
+ if (opts.signal?.aborted)
35934
+ break;
35935
+ await sleep(interval);
35936
+ }
35937
+ }
35938
+
35291
35939
  // src/today.ts
35292
35940
  var OPEN_STATUSES = new Set(["todo", "in_progress"]);
35293
35941
  function isTaskLike(value) {
@@ -35864,7 +36512,7 @@ var HANDLERS = {
35864
36512
  });
35865
36513
  if (out !== undefined) {
35866
36514
  for (const file of data.files) {
35867
- const target = join3(out, file.path);
36515
+ const target = join4(out, file.path);
35868
36516
  mkdirSync2(dirname2(target), { recursive: true });
35869
36517
  writeFileSync2(target, file.markdown);
35870
36518
  }
@@ -36050,7 +36698,7 @@ var HANDLERS = {
36050
36698
  } catch (cause) {
36051
36699
  throw usageError(`can't read ${path}: ${cause instanceof Error ? cause.message : cause}`);
36052
36700
  }
36053
- const name = flagString(flags, "title") ?? flagString(flags, "name") ?? basename(path);
36701
+ const name = flagString(flags, "title") ?? flagString(flags, "name") ?? basename2(path);
36054
36702
  const form = new FormData;
36055
36703
  form.append("nodeId", node);
36056
36704
  const fields = {
@@ -36077,7 +36725,7 @@ var HANDLERS = {
36077
36725
  "files download": async ({ args, flags, requireClient }) => {
36078
36726
  const id = requireArg(args, 0, "file id");
36079
36727
  const file = await requireClient().getBytes(`/files/${id}/download`);
36080
- const out = flagString(flags, "out") ?? basename(file.name ?? id);
36728
+ const out = flagString(flags, "out") ?? basename2(file.name ?? id);
36081
36729
  writeFileSync2(out, file.bytes);
36082
36730
  return {
36083
36731
  data: { id, saved: out, size: file.bytes.byteLength, type: file.contentType },
@@ -36144,7 +36792,7 @@ var HANDLERS = {
36144
36792
  if (value !== undefined)
36145
36793
  form.append(key, value);
36146
36794
  }
36147
- const name = basename(path);
36795
+ const name = basename2(path);
36148
36796
  const type = flagString(flags, "type") ?? Bun.file(name).type;
36149
36797
  form.append("file", new File([Uint8Array.from(bytes)], name, { type }), name);
36150
36798
  }
@@ -37501,7 +38149,7 @@ var HANDLERS = {
37501
38149
  const since = flagString(flags, "since");
37502
38150
  if (since !== undefined)
37503
38151
  form.append("since", since);
37504
- const name = basename(path);
38152
+ const name = basename2(path);
37505
38153
  form.append("file", new File([Uint8Array.from(bytes)], name, { type: "text/csv" }), name);
37506
38154
  const route = flagBool(flags, "commit") ? "/money/imports" : "/money/imports/preview";
37507
38155
  return { data: await requireClient().upload(route, form), render: renderRecord };
@@ -37705,6 +38353,37 @@ var HANDLERS = {
37705
38353
  const input = parseInput(applyRunChangesInputSchema, body);
37706
38354
  return { data: await requireClient().post(`/runs/${id}/changes`, input), render: renderRecord };
37707
38355
  },
38356
+ "runs save-header": async ({ args, flags, io, requireClient }) => {
38357
+ const id = requireArg(args, 0, "run id");
38358
+ const target = requireArg(args, 1, "save file or Saves folder");
38359
+ const dryRun = flagBool(flags, "dry-run");
38360
+ const send = async (path) => {
38361
+ const input = await saveUpload(path);
38362
+ if (dryRun)
38363
+ return { ...input, screenshot: input.screenshot ? "(png)" : null };
38364
+ return requireClient().post(`/runs/${id}/save-header`, input);
38365
+ };
38366
+ if (!flagBool(flags, "watch")) {
38367
+ return { data: await send(await resolveSave(target)), render: renderRecord };
38368
+ }
38369
+ const seconds = Number(flagString(flags, "interval") ?? "10");
38370
+ if (!Number.isFinite(seconds) || seconds < 1)
38371
+ throw usageError("--interval is seconds, 1 or more");
38372
+ io.stderr(`watching ${target} for new saves (every ${seconds}s); Ctrl-C stops
38373
+ `);
38374
+ await watchSaves({
38375
+ dir: target,
38376
+ intervalMs: seconds * 1000,
38377
+ onSave: async (path) => {
38378
+ const result = await send(path);
38379
+ io.stderr(`${result.unchanged ? "already had" : dryRun ? "read" : "sent"} ${basename2(path)}
38380
+ `);
38381
+ },
38382
+ onError: (err) => io.stderr(`save sync: ${err.message}
38383
+ `)
38384
+ });
38385
+ return { data: null, render: () => "" };
38386
+ },
37708
38387
  "runs undo": async ({ args, flags, requireClient }) => {
37709
38388
  const id = requireArg(args, 0, "run id");
37710
38389
  const batch = flagString(flags, "batch");
@@ -37894,7 +38573,7 @@ var HANDLERS = {
37894
38573
  } catch (cause) {
37895
38574
  throw usageError(`can't read ${path}: ${cause instanceof Error ? cause.message : cause}`);
37896
38575
  }
37897
- const name = basename(path);
38576
+ const name = basename2(path);
37898
38577
  const form = new FormData;
37899
38578
  const textFile = flagString(flags, "text-file");
37900
38579
  if (textFile)
@@ -38101,7 +38780,7 @@ var HANDLERS = {
38101
38780
  form.append("sourceName", fields.sourceName);
38102
38781
  if (fields.sourceEmail)
38103
38782
  form.append("sourceEmail", fields.sourceEmail);
38104
- const name = basename(path);
38783
+ const name = basename2(path);
38105
38784
  form.append("file", new File([Uint8Array.from(bytes)], name, { type: "text/plain" }), name);
38106
38785
  return { data: await requireClient().upload("/family-tree", form), render: renderRecord };
38107
38786
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robbeverhelst/do",
3
- "version": "1.282.0",
3
+ "version": "1.284.0",
4
4
  "private": false,
5
5
  "description": "do — the agent-drivable CLI over the public /api/v1 surface",
6
6
  "homepage": "https://github.com/robbeverhelst/do/tree/main/apps/cli",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "devDependencies": {
35
35
  "@do/schema": "workspace:*",
36
+ "@do/skyrim-save": "workspace:*",
36
37
  "@types/bun": "^1.4.3",
37
38
  "@types/node": "^25.9.3",
38
39
  "ulidx": "^2.4.1",