dsh-agentic-pet 0.3.2 → 0.4.1

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.
@@ -1,9 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { existsSync } from "node:fs";
4
- import { appendFile, readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
- import { join } from "node:path";
6
+ import { dirname, join } from "node:path";
7
7
  import { Context, Service } from "@deepseek-ai/cordis";
8
8
  //#region ../../../vendor/cosmokit/src/misc.ts
9
9
  /** Return true when a value is `null` or `undefined`. */
@@ -4608,23 +4608,182 @@ function formatAvailableActions(available, cap = 24) {
4608
4608
  return rest > 0 ? `${shown} …(+${String(rest)} more — pet_state {actions:true} lists all)` : shown;
4609
4609
  }
4610
4610
  //#endregion
4611
- //#region lib/types/inner-voice.js
4611
+ //#region lib/types/petdata.js
4612
4612
  /**
4613
- * The inner voice (docs/inner-voice.md): the pet's self-initiated expression
4614
- * loop. This module owns the pure, testable half — config parsing with
4615
- * defaults and clamps, the durable petId→sessionId store, and the wake/touch
4616
- * prompt builders. The driver (driver.ts) owns the wiring: timers, gates,
4617
- * SessionController calls, and the per-pet pending locks.
4618
- * @module dsh-agentic-pet/inner-voice
4613
+ * The per-pet data store: everything the DRIVER persists about a pet lives
4614
+ * under `~/.dsh/whale598/<petId>/` — affection ledger, display name, event
4615
+ * history, tool-call history. One directory per pet, small files, single
4616
+ * writer (the driver), JSONL for append-only streams with bounded retention.
4617
+ *
4618
+ * Legacy layouts (`pet-affection.d/<id>.json`, the single legacy electron
4619
+ * book, names inside pet-settings.json) are READ as fallbacks and migrate to
4620
+ * the new layout on the next write — lazy migration, no data-loss window.
4621
+ * @module dsh-agentic-pet/petdata
4619
4622
  */
4623
+ /** The consolidated per-pet data root under the dsh home. */
4624
+ const WHALE_DIR = "whale598";
4625
+ function petDataDir(home, petId) {
4626
+ return join(home, WHALE_DIR, petId);
4627
+ }
4628
+ /** New-layout path first; legacy layouts as read fallbacks (per-pet legacy
4629
+ * dir → the single legacy electron book). The ledger SHAPE is the driver's
4630
+ * (TouchLedger); this module only owns paths and raw IO. */
4631
+ function affectionPaths(home, petId) {
4632
+ return {
4633
+ current: join(petDataDir(home, petId), "pet-affection.json"),
4634
+ legacy: [join(home, "pet-affection.d", `${petId}.json`), ...petId === "electron" ? [join(home, "pet-affection.json")] : []]
4635
+ };
4636
+ }
4637
+ /** Read the affection ledger raw: the current file wins; legacy files answer
4638
+ * only while the current one is absent (first write migrates the full
4639
+ * ledger). Returns null when no layout holds data. */
4640
+ async function readAffectionRaw(home, petId) {
4641
+ const { current, legacy } = affectionPaths(home, petId);
4642
+ for (const file of [current, ...legacy]) try {
4643
+ const parsed = JSON.parse(await readFile(file, "utf8"));
4644
+ if (parsed !== null && typeof parsed === "object" && Object.keys(parsed).length > 0) return parsed;
4645
+ } catch {}
4646
+ return null;
4647
+ }
4648
+ async function writeAffectionRaw(home, petId, ledger) {
4649
+ const dir = petDataDir(home, petId);
4650
+ await mkdir(dir, { recursive: true });
4651
+ await writeFile(join(dir, "pet-affection.json"), `${JSON.stringify(ledger)}\n`, "utf8");
4652
+ }
4653
+ function petNameFile(home, petId) {
4654
+ return join(petDataDir(home, petId), "pet-name.json");
4655
+ }
4656
+ /** The user's custom display name: new-layout file → legacy
4657
+ * pet-settings.json `names` map → undefined (caller applies the built-in
4658
+ * default). */
4659
+ async function readPetName(home, petId) {
4660
+ try {
4661
+ const parsed = JSON.parse(await readFile(petNameFile(home, petId), "utf8"));
4662
+ if (typeof parsed.name === "string" && parsed.name.trim() !== "") return parsed.name.trim();
4663
+ } catch {}
4664
+ try {
4665
+ const settings = JSON.parse(await readFile(join(home, "pet-settings.json"), "utf8"));
4666
+ const label = (typeof settings.names === "object" && settings.names !== null ? settings.names : {})[petId];
4667
+ if (typeof label === "string" && label.trim() !== "") return label.trim();
4668
+ } catch {}
4669
+ }
4670
+ /** Persist a custom name; `null` reverts to the built-in default (drops the
4671
+ * override file). Also strips the petId key from the legacy pet-settings
4672
+ * names map so the old layout cannot resurrect a cleared name. */
4673
+ async function writePetName(home, petId, name) {
4674
+ await mkdir(petDataDir(home, petId), { recursive: true });
4675
+ const file = petNameFile(home, petId);
4676
+ if (name === null || name.trim() === "") await rm(file, { force: true });
4677
+ else await writeFile(file, `${JSON.stringify({ name: name.trim() })}\n`, "utf8");
4678
+ try {
4679
+ const settingsPath = join(home, "pet-settings.json");
4680
+ const settings = JSON.parse(await readFile(settingsPath, "utf8"));
4681
+ if (typeof settings.names === "object" && settings.names !== null && petId in settings.names) {
4682
+ Reflect.deleteProperty(settings.names, petId);
4683
+ if (Object.keys(settings.names).length === 0) delete settings.names;
4684
+ await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
4685
+ }
4686
+ } catch {}
4687
+ }
4688
+ const JSONL_MAX_LINES = 600;
4689
+ const JSONL_KEEP_LINES = 500;
4690
+ function eventsFile(home, petId) {
4691
+ return join(petDataDir(home, petId), "events.jsonl");
4692
+ }
4693
+ /** Append one record; bounded retention trims the file when it outgrows the
4694
+ * cap (best-effort — a failed trim never loses the append). */
4695
+ async function appendJsonl(file, entry) {
4696
+ await mkdir(dirname(file), { recursive: true });
4697
+ await appendFile(file, `${JSON.stringify(entry)}\n`, "utf8");
4698
+ try {
4699
+ const lines = (await readFile(file, "utf8")).split("\n").filter((line) => line.trim() !== "");
4700
+ if (lines.length > JSONL_MAX_LINES) await writeFile(file, `${lines.slice(lines.length - JSONL_KEEP_LINES).join("\n")}\n`, "utf8");
4701
+ } catch {}
4702
+ }
4703
+ /** The last `limit` records (oldest last); malformed lines degrade to
4704
+ * `{raw}` instead of dropping the file. */
4705
+ async function readJsonl(file, limit) {
4706
+ try {
4707
+ return (await readFile(file, "utf8")).split("\n").filter((line) => line.trim() !== "").slice(-limit).map((line) => {
4708
+ try {
4709
+ return JSON.parse(line);
4710
+ } catch {
4711
+ return { raw: line };
4712
+ }
4713
+ });
4714
+ } catch {
4715
+ return [];
4716
+ }
4717
+ }
4718
+ const GLOBAL_STATE_FILES = [
4719
+ "pet-settings.json",
4720
+ "pet-inner-voice.json",
4721
+ "pet-interactions.json",
4722
+ "pet-world.json",
4723
+ "pet-window.json"
4724
+ ];
4725
+ function globalPaths(home, name) {
4726
+ return {
4727
+ current: join(home, WHALE_DIR, name),
4728
+ legacy: join(home, name)
4729
+ };
4730
+ }
4731
+ function globalFilePath(home, name) {
4732
+ return globalPaths(home, name).current;
4733
+ }
4734
+ /** Rename legacy → current for every global file that hasn't moved yet.
4735
+ * Best-effort: a failed rename leaves the legacy copy in place and the read
4736
+ * fallback still answers. Called once at driver install. */
4737
+ async function migrateGlobals(home) {
4738
+ await mkdir(join(home, WHALE_DIR), { recursive: true });
4739
+ for (const name of GLOBAL_STATE_FILES) {
4740
+ const { current, legacy } = globalPaths(home, name);
4741
+ try {
4742
+ if (existsSync(current) || !existsSync(legacy)) continue;
4743
+ await rename(legacy, current);
4744
+ } catch {}
4745
+ }
4746
+ }
4747
+ /** Read a global state file: current → legacy → {} (callers' defensive
4748
+ * field checks treat {} exactly like a missing file). */
4749
+ async function readGlobalJson(home, name) {
4750
+ const { current, legacy } = globalPaths(home, name);
4751
+ for (const file of [current, legacy]) try {
4752
+ const parsed = JSON.parse(await readFile(file, "utf8"));
4753
+ if (parsed && typeof parsed === "object") return parsed;
4754
+ } catch {}
4755
+ return {};
4756
+ }
4757
+ /** Write a global state file (current location only; pretty-printed — these
4758
+ * files are user-inspectable and hand-editable). */
4759
+ async function writeGlobalJson(home, name, obj) {
4760
+ const { current } = globalPaths(home, name);
4761
+ await mkdir(dirname(current), { recursive: true });
4762
+ await writeFile(current, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
4763
+ }
4764
+ //#endregion
4765
+ //#region lib/types/inner-voice.js
4620
4766
  const VOICE_SESSION_PREFIX = "whale598-";
4767
+ /**
4768
+ * Saved-setting migration for preset id renames: pet-settings.json holds the
4769
+ * user's 会话模式 choice by id, and a rename orphans that saved value (every
4770
+ * ensure would first try a dead id and fall back). Map the legacy ids onto
4771
+ * their successors; unknown ids pass through untouched.
4772
+ */
4773
+ const LEGACY_PRESET_IDS = {
4774
+ "pet-inner-voice": "whale598",
4775
+ "pet": "whale598-standard"
4776
+ };
4777
+ function migratePreset(preset) {
4778
+ return LEGACY_PRESET_IDS[preset] ?? preset;
4779
+ }
4621
4780
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
4622
4781
  const finite01 = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? clamp(value, 0, 1) : fallback;
4623
4782
  /** Read `innerVoice` from pet-settings.json; anything missing or mistyped
4624
4783
  * folds to its default (defensive parse, `readAutonomy` precedent). */
4625
4784
  async function readInnerVoiceConfig(home) {
4626
4785
  try {
4627
- const raw = JSON.parse(await readFile(join(home, "pet-settings.json"), "utf8"));
4786
+ const raw = await readGlobalJson(home, "pet-settings.json");
4628
4787
  const v = typeof raw.innerVoice === "object" && raw.innerVoice !== null ? raw.innerVoice : {};
4629
4788
  const legacyMinutes = typeof v.touchReplyCooldownMinutes === "number" && Number.isFinite(v.touchReplyCooldownMinutes) ? v.touchReplyCooldownMinutes : void 0;
4630
4789
  const secondsRaw = typeof v.touchReplyCooldownSeconds === "number" && Number.isFinite(v.touchReplyCooldownSeconds) ? v.touchReplyCooldownSeconds : legacyMinutes !== void 0 ? legacyMinutes * 60 : void 0;
@@ -4634,7 +4793,7 @@ async function readInnerVoiceConfig(home) {
4634
4793
  }
4635
4794
  return {
4636
4795
  enabled: v.enabled !== false,
4637
- preset: typeof v.preset === "string" && v.preset !== "" ? v.preset : "pet-inner-voice",
4796
+ preset: migratePreset(typeof v.preset === "string" && v.preset !== "" ? v.preset : "whale598"),
4638
4797
  wakeIntervalMinutes: typeof v.wakeIntervalMinutes === "number" && Number.isFinite(v.wakeIntervalMinutes) ? clamp(Math.round(v.wakeIntervalMinutes), 1, 60) : 2,
4639
4798
  speakProbability: finite01(v.speakProbability, .5),
4640
4799
  touchReplyProbability: finite01(v.touchReplyProbability, .3),
@@ -4644,7 +4803,7 @@ async function readInnerVoiceConfig(home) {
4644
4803
  } catch {
4645
4804
  return {
4646
4805
  enabled: true,
4647
- preset: "pet-inner-voice",
4806
+ preset: "whale598",
4648
4807
  wakeIntervalMinutes: 2,
4649
4808
  speakProbability: .5,
4650
4809
  touchReplyProbability: .3,
@@ -4653,9 +4812,6 @@ async function readInnerVoiceConfig(home) {
4653
4812
  };
4654
4813
  }
4655
4814
  }
4656
- function innerVoiceStorePath(home) {
4657
- return join(home, "pet-inner-voice.json");
4658
- }
4659
4815
  /** Defensive extraction of the persisted generation counters (petId → n). */
4660
4816
  function generationsOf(raw) {
4661
4817
  const out = {};
@@ -4666,7 +4822,7 @@ function generationsOf(raw) {
4666
4822
  }
4667
4823
  async function loadInnerVoiceStore(home) {
4668
4824
  try {
4669
- const raw = JSON.parse(await readFile(innerVoiceStorePath(home), "utf8"));
4825
+ const raw = await readGlobalJson(home, "pet-inner-voice.json");
4670
4826
  const sessions = {};
4671
4827
  for (const [petId, entry] of Object.entries(typeof raw.sessions === "object" && raw.sessions !== null ? raw.sessions : {})) {
4672
4828
  const record = entry;
@@ -4688,7 +4844,7 @@ async function loadInnerVoiceStore(home) {
4688
4844
  }
4689
4845
  async function saveInnerVoiceStore(home, store) {
4690
4846
  try {
4691
- await writeFile(innerVoiceStorePath(home), `${JSON.stringify(store)}\n`, "utf8");
4847
+ await writeGlobalJson(home, "pet-inner-voice.json", store);
4692
4848
  } catch (error) {
4693
4849
  console.error(`pet-surface inner-voice: could not write the store: ${error instanceof Error ? error.message : String(error)}`);
4694
4850
  }
@@ -4731,10 +4887,44 @@ function mintVoiceSessionId(petId, generation, isArchived) {
4731
4887
  function buildGreetPrompt(facts) {
4732
4888
  return [
4733
4889
  "[启动问好]",
4734
- `用户刚打开 DeepSeek,你的世界刚亮起来(好感 ${String(facts.affection)},${moodOf(facts.affection)})。`,
4890
+ `用户刚打开 DeepSeek,你的世界刚亮起来(好感 ${String(facts.affection)},${moodOf(facts.affection)};羁绊 ${facts.bondTier},一起过了 ${String(facts.bondDays)} 天)。`,
4735
4891
  "主动打个招呼(pet_say)——按当下的时辰自然问好,一两句就好,别问问题等回答。"
4736
4892
  ].join(" ");
4737
4893
  }
4894
+ /** B 层 · 羁绊档位:days-with-interaction → tier name. Only grows, never
4895
+ * decays; the names are the display contract (settings page keeps its own
4896
+ * copy of the thresholds). */
4897
+ const BOND_TIERS = [
4898
+ {
4899
+ since: 0,
4900
+ name: "陌生"
4901
+ },
4902
+ {
4903
+ since: 5,
4904
+ name: "认识"
4905
+ },
4906
+ {
4907
+ since: 15,
4908
+ name: "熟了"
4909
+ },
4910
+ {
4911
+ since: 40,
4912
+ name: "亲近"
4913
+ },
4914
+ {
4915
+ since: 90,
4916
+ name: "离不开"
4917
+ },
4918
+ {
4919
+ since: 180,
4920
+ name: "只有你"
4921
+ }
4922
+ ];
4923
+ function bondTier(days) {
4924
+ let tier = BOND_TIERS[0]?.name ?? "陌生";
4925
+ for (const t of BOND_TIERS) if (days >= t.since) tier = t.name;
4926
+ return tier;
4927
+ }
4738
4928
  /** Shared mood vocabulary (pet_state computes the same from the ledger). */
4739
4929
  function moodOf(affection) {
4740
4930
  return affection >= 80 ? "happy" : affection >= 40 ? "content" : "neutral";
@@ -4752,7 +4942,7 @@ function buildWakePrompt(facts) {
4752
4942
  const user = facts.userActive ? `用户最近正在会话中工作(${String(facts.minutesSinceUserActivity ?? 0)} 分钟内有动静)` : "用户最近没有活动";
4753
4943
  return [
4754
4944
  "[自主唤醒]",
4755
- `好感 ${String(facts.affection)}(${moodOf(facts.affection)});${lastSpoke};${user}。`,
4945
+ `好感 ${String(facts.affection)}(${moodOf(facts.affection)});羁绊 ${facts.bondTier}(一起过了 ${String(facts.bondDays)} 天);${lastSpoke};${user}。`,
4756
4946
  "醒来想到什么就说一句(pet_say),想配动作就 pet_do;也可以只是安静待着(两者都不调,正文写「…」)。"
4757
4947
  ].join(" ");
4758
4948
  }
@@ -4776,14 +4966,16 @@ function buildChatPrompt(text) {
4776
4966
  * The pet registry: a directory of per-pet control manifests, replacing the
4777
4967
  * last-writer-wins single files whose races caused repeated swap incidents
4778
4968
  * (a dying carrier's late write burying a live pet's registration). Each pet
4779
- * implementation owns its own file under pet-http.d/ and re-asserts it on a
4969
+ * implementation owns its own file under whale598/pet-http.d/ (legacy
4970
+ * pet-http.d/ still scanned) and re-asserts it on a
4780
4971
  * cadence; entries whose publisher pid is dead expire by omission. The
4781
4972
  * driver reads the whole table and routes per the multi-pet design: the
4782
4973
  * session's bound pet if alive, else the default pet (first alive entry),
4783
4974
  * plus an all-notify broadcast mode for interactions.
4784
4975
  * @module dsh-agentic-pet/registry
4785
4976
  */
4786
- const REGISTRY_DIR = "pet-http.d";
4977
+ const REGISTRY_DIR = `${WHALE_DIR}/pet-http.d`;
4978
+ const LEGACY_REGISTRY_DIR = "pet-http.d";
4787
4979
  /**
4788
4980
  * A live pet is one whose control plane ANSWERS, not merely one whose pid
4789
4981
  * exists — and not merely one whose port accepts TCP: a frozen pet (stuck
@@ -4818,27 +5010,37 @@ async function probeControlPlane(port) {
4818
5010
  * they expire by omission the next time their file is rewritten — and so are
4819
5011
  * silent entries (pid alive, control plane unresponsive). */
4820
5012
  async function readLivePets(home) {
5013
+ const candidates = /* @__PURE__ */ new Map();
5014
+ for (const dir of [join(home, REGISTRY_DIR), join(home, LEGACY_REGISTRY_DIR)]) {
5015
+ let names = [];
5016
+ try {
5017
+ names = (await readdir(dir)).filter((name) => name.endsWith(".json"));
5018
+ } catch {
5019
+ continue;
5020
+ }
5021
+ for (const name of names) {
5022
+ const id = name.slice(0, -5);
5023
+ if (id !== "" && !candidates.has(id)) candidates.set(id, join(dir, name));
5024
+ }
5025
+ }
4821
5026
  const live = [];
4822
- try {
4823
- const files = (await readdir(join(home, REGISTRY_DIR))).filter((name) => name.endsWith(".json"));
4824
- for (const file of files) try {
4825
- const raw = JSON.parse(await readFile(join(home, REGISTRY_DIR, file), "utf8"));
4826
- const port = typeof raw.port === "number" ? raw.port : NaN;
4827
- const pid = typeof raw.pid === "number" ? raw.pid : NaN;
4828
- if (!Number.isFinite(port) || !Number.isFinite(pid)) continue;
4829
- try {
4830
- process.kill(pid, 0);
4831
- } catch {
4832
- continue;
4833
- }
4834
- if (!await probeControlPlane(port)) continue;
4835
- live.push({
4836
- id: file.slice(0, -5),
4837
- port,
4838
- pid,
4839
- since: typeof raw.since === "number" ? raw.since : 0
4840
- });
4841
- } catch {}
5027
+ for (const [id, file] of candidates) try {
5028
+ const raw = JSON.parse(await readFile(file, "utf8"));
5029
+ const port = typeof raw.port === "number" ? raw.port : NaN;
5030
+ const pid = typeof raw.pid === "number" ? raw.pid : NaN;
5031
+ if (!Number.isFinite(port) || !Number.isFinite(pid)) continue;
5032
+ try {
5033
+ process.kill(pid, 0);
5034
+ } catch {
5035
+ continue;
5036
+ }
5037
+ if (!await probeControlPlane(port)) continue;
5038
+ live.push({
5039
+ id,
5040
+ port,
5041
+ pid,
5042
+ since: typeof raw.since === "number" ? raw.since : 0
5043
+ });
4842
5044
  } catch {}
4843
5045
  return live;
4844
5046
  }
@@ -4920,15 +5122,32 @@ const EMPTY_LEDGER = {
4920
5122
  lastTouchAt: void 0,
4921
5123
  recent: []
4922
5124
  };
4923
- async function readLedger(ledgerFile) {
5125
+ const ADJUST_MAX_DELTA = 3;
5126
+ const ADJUST_COOLDOWN_MS = 5 * 6e4;
5127
+ const ADJUST_DAILY_LIMIT = 8;
5128
+ const ADJUST_DAILY_SUM = 10;
5129
+ async function readLedger(home, petId) {
4924
5130
  try {
4925
- const raw = JSON.parse(await readFile(ledgerFile, "utf8"));
5131
+ const raw = await readAffectionRaw(home, petId);
5132
+ if (raw === null) return {
5133
+ ...EMPTY_LEDGER,
5134
+ byPart: {},
5135
+ recent: []
5136
+ };
4926
5137
  return {
4927
5138
  affection: typeof raw.affection === "number" ? raw.affection : 0,
4928
5139
  total: typeof raw.total === "number" ? raw.total : 0,
4929
5140
  byPart: raw.byPart ?? {},
4930
5141
  lastTouchAt: raw.lastTouchAt,
4931
- recent: raw.recent ?? []
5142
+ recent: raw.recent ?? [],
5143
+ adjustments: Array.isArray(raw.adjustments) ? raw.adjustments.filter((a) => typeof a === "object" && a !== null && typeof a.delta === "number" && typeof a.reason === "string" && typeof a.at === "number") : void 0,
5144
+ quota: typeof raw.quota === "object" && raw.quota !== null ? raw.quota : void 0,
5145
+ bond: typeof raw.bond === "object" && raw.bond !== null && typeof raw.bond.lastCountedDate === "string" ? {
5146
+ value: typeof raw.bond.value === "number" ? raw.bond.value : 0,
5147
+ days: typeof raw.bond.days === "number" ? raw.bond.days : 0,
5148
+ lastCountedDate: raw.bond.lastCountedDate
5149
+ } : void 0,
5150
+ bondAdjustments: Array.isArray(raw.bondAdjustments) ? raw.bondAdjustments.filter((a) => typeof a === "object" && a !== null && typeof a.delta === "number" && typeof a.reason === "string" && typeof a.at === "number") : void 0
4932
5151
  };
4933
5152
  } catch {
4934
5153
  return {
@@ -4938,12 +5157,6 @@ async function readLedger(ledgerFile) {
4938
5157
  };
4939
5158
  }
4940
5159
  }
4941
- /** The per-pet ledger file. Legacy deployments' single book is folded under
4942
- * the electron id so pre-registry affection history survives the upgrade. */
4943
- function ledgerFileFor(home, petId) {
4944
- if (petId === "electron" && !existsSync(join(home, "pet-affection.d"))) return join(home, "pet-affection.json");
4945
- return join(home, "pet-affection.d", `${petId}.json`);
4946
- }
4947
5160
  /** Local-time log stamp: the driver log is the user's primary debugging
4948
5161
  * tool, so timestamps show the USER's wall clock, not UTC (toISOString's Z
4949
5162
  * form read 8h off and cost a confusion round). Sortable, zero-padded. */
@@ -4953,7 +5166,7 @@ const logStamp = () => {
4953
5166
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
4954
5167
  };
4955
5168
  const driverLog = (home, line) => {
4956
- appendFile(join(home, "pet-driver.log"), `${logStamp()} ${line}
5169
+ appendFile(join(home, WHALE_DIR, "pet-driver.log"), `${logStamp()} ${line}
4957
5170
  `, "utf8").catch(() => {});
4958
5171
  };
4959
5172
  /** The one /events read both consumers share (§6 v4): the ledger loop
@@ -4995,6 +5208,23 @@ const voiceLastSpokeAt = /* @__PURE__ */ new Map();
4995
5208
  const voiceLastTouchReplyAt = /* @__PURE__ */ new Map();
4996
5209
  const voiceNextWakeAt = /* @__PURE__ */ new Map();
4997
5210
  const voiceGreetedOnce = /* @__PURE__ */ new Set();
5211
+ const voiceNames = /* @__PURE__ */ new Map();
5212
+ const DEFAULT_PET_NAME = { electron: "鲸鱼娘" };
5213
+ /** B-layer bond day counting: the FIRST interaction of a calendar day
5214
+ * (touch / user chat / model adjustment) marks that day as shared. Called
5215
+ * at the interaction points, never on reads — reading the ledger does not
5216
+ * make a day meaningful. Mutates in place; the caller persists. Module-level
5217
+ * because both the driver closures AND the model tools (registerPetTools)
5218
+ * count bond days. */
5219
+ const countBondDay = (ledger) => {
5220
+ const today = (/* @__PURE__ */ new Date()).toDateString();
5221
+ if (ledger.bond?.lastCountedDate === today) return;
5222
+ ledger.bond = {
5223
+ value: ledger.bond?.value ?? 0,
5224
+ days: (ledger.bond?.days ?? 0) + 1,
5225
+ lastCountedDate: today
5226
+ };
5227
+ };
4998
5228
  const voiceLastPrompt = /* @__PURE__ */ new Map();
4999
5229
  const voiceBoundSessions = /* @__PURE__ */ new Map();
5000
5230
  const voiceWakeTimer = { timer: void 0 };
@@ -5018,12 +5248,14 @@ function markVoiceSpoke(petId) {
5018
5248
  }
5019
5249
  function installPetDriver(ctx, home, pet) {
5020
5250
  driverLog(home, "installPetDriver entered");
5251
+ migrateGlobals(home);
5021
5252
  voiceOwners.clear();
5022
5253
  voicePendingUntil.clear();
5023
5254
  voiceLastSpokeAt.clear();
5024
5255
  voiceLastTouchReplyAt.clear();
5025
5256
  voiceNextWakeAt.clear();
5026
5257
  voiceGreetedOnce.clear();
5258
+ voiceNames.clear();
5027
5259
  voiceLastPrompt.clear();
5028
5260
  voiceBoundSessions.clear();
5029
5261
  if (voiceWakeTimer.timer !== void 0) clearTimeout(voiceWakeTimer.timer);
@@ -5056,7 +5288,7 @@ function installPetDriver(ctx, home, pet) {
5056
5288
  if (!latestArchived.has(entry.sessionId)) continue;
5057
5289
  const generation = (voiceStore.generations[petId] ?? 0) + 1;
5058
5290
  voiceStore.generations[petId] = generation;
5059
- delete voiceStore.sessions[petId];
5291
+ Reflect.deleteProperty(voiceStore.sessions, petId);
5060
5292
  const ownerEntry = [...voiceOwners.entries()].find(([, owner]) => owner === petId);
5061
5293
  if (ownerEntry !== void 0) voiceOwners.delete(ownerEntry[0]);
5062
5294
  voicePendingUntil.delete(petId);
@@ -5082,13 +5314,15 @@ function installPetDriver(ctx, home, pet) {
5082
5314
  }
5083
5315
  };
5084
5316
  const attachedSessions = /* @__PURE__ */ new Set();
5317
+ const petWorkspaceDir = () => join(homedir(), "whale598");
5085
5318
  const attachVoiceSession = async (sessionId) => {
5086
5319
  if (attachedSessions.has(sessionId)) return;
5087
5320
  attachedSessions.add(sessionId);
5088
5321
  try {
5089
5322
  const registry = ctx.get("workspaceRegistry");
5090
5323
  if (registry === void 0) return;
5091
- await (await registry.create(homedir()))?.attachSession?.(sessionId);
5324
+ await mkdir(petWorkspaceDir(), { recursive: true });
5325
+ await (await registry.create(petWorkspaceDir()))?.attachSession?.(sessionId);
5092
5326
  driverLog(home, `VOICE session attached to workspace (${sessionId})`);
5093
5327
  } catch (error) {
5094
5328
  attachedSessions.delete(sessionId);
@@ -5135,7 +5369,7 @@ function installPetDriver(ctx, home, pet) {
5135
5369
  */
5136
5370
  const readAutonomy = async () => {
5137
5371
  try {
5138
- const p = JSON.parse(await readFile(join(home, "pet-settings.json"), "utf8")).proactivity;
5372
+ const p = (await readGlobalJson(home, "pet-settings.json")).proactivity;
5139
5373
  const gap = typeof p?.idleAnimationGapMinutes === "number" && Number.isFinite(p.idleAnimationGapMinutes) ? Math.min(120, Math.max(1, Math.round(p.idleAnimationGapMinutes))) : 10;
5140
5374
  return {
5141
5375
  idleAnimation: p?.idleAnimation !== false,
@@ -5210,7 +5444,7 @@ function installPetDriver(ctx, home, pet) {
5210
5444
  const petId = voiceOwners.get(sessionId);
5211
5445
  voiceOwners.delete(sessionId);
5212
5446
  if (petId !== void 0) {
5213
- delete voiceStore.sessions[petId];
5447
+ Reflect.deleteProperty(voiceStore.sessions, petId);
5214
5448
  voicePendingUntil.delete(petId);
5215
5449
  saveInnerVoiceStore(home, voiceStore);
5216
5450
  driverLog(home, `VOICE session removed by user; pet=${petId} will re-create on next wake`);
@@ -5222,6 +5456,22 @@ function installPetDriver(ctx, home, pet) {
5222
5456
  boundPetId = void 0;
5223
5457
  pushState("idle");
5224
5458
  });
5459
+ const refreshNames = async () => {
5460
+ voiceNames.clear();
5461
+ try {
5462
+ for (const petId of await readdir(join(home, WHALE_DIR))) {
5463
+ const name = await readPetName(home, petId);
5464
+ if (name !== void 0) voiceNames.set(petId, name);
5465
+ }
5466
+ } catch {}
5467
+ try {
5468
+ const raw = await readGlobalJson(home, "pet-settings.json");
5469
+ if (typeof raw.names === "object" && raw.names !== null) {
5470
+ for (const [petId, label] of Object.entries(raw.names)) if (petId !== "" && typeof label === "string" && label.trim() !== "" && !voiceNames.has(petId)) voiceNames.set(petId, label.trim());
5471
+ }
5472
+ } catch {}
5473
+ };
5474
+ refreshNames();
5225
5475
  const refreshBoundSessions = async () => {
5226
5476
  const config = await readInnerVoiceConfig(home);
5227
5477
  voiceBoundSessions.clear();
@@ -5229,7 +5479,31 @@ function installPetDriver(ctx, home, pet) {
5229
5479
  };
5230
5480
  refreshBoundSessions();
5231
5481
  events.on("pet-settings/changed", () => {
5232
- refreshBoundSessions();
5482
+ const prevNames = new Map(voiceNames);
5483
+ (async () => {
5484
+ await refreshNames();
5485
+ refreshBoundSessions();
5486
+ const changedPets = new Set([...prevNames.keys(), ...voiceNames.keys()]);
5487
+ for (const petId of changedPets) {
5488
+ if (prevNames.get(petId) === voiceNames.get(petId)) continue;
5489
+ const nextName = voiceNames.get(petId);
5490
+ const label = nextName ?? DEFAULT_PET_NAME[petId] ?? petId;
5491
+ const entry = voiceStore.sessions[petId];
5492
+ if (entry !== void 0) {
5493
+ const title = `${label}的内心戏`;
5494
+ try {
5495
+ await voiceController?.rename({
5496
+ sessionId: entry.sessionId,
5497
+ title
5498
+ });
5499
+ driverLog(home, `VOICE session retitled pet=${petId} → ${title}`);
5500
+ } catch (error) {
5501
+ driverLog(home, `VOICE retitle skipped pet=${petId}: ${error instanceof Error ? error.message : String(error)}`);
5502
+ }
5503
+ }
5504
+ triggerRenameNotice(petId, nextName, label);
5505
+ }
5506
+ })();
5233
5507
  if (currentState !== void 0) pushState(currentState);
5234
5508
  });
5235
5509
  const startAskPet = (pet, body, signal) => {
@@ -5258,7 +5532,7 @@ function installPetDriver(ctx, home, pet) {
5258
5532
  abort
5259
5533
  };
5260
5534
  };
5261
- const interactionLedgerFile = join(home, "pet-interactions.json");
5535
+ const interactionLedgerFile = globalFilePath(home, "pet-interactions.json");
5262
5536
  const recordInteraction = (entry) => {
5263
5537
  const at = Date.now();
5264
5538
  (async () => {
@@ -5403,16 +5677,19 @@ function installPetDriver(ctx, home, pet) {
5403
5677
  if (minted.hops > 0) driverLog(home, `VOICE mint skipped ${String(minted.hops)} archived id(s) pet=${petId} → ${minted.sessionId}`);
5404
5678
  const sessionId = minted.sessionId;
5405
5679
  try {
5680
+ await mkdir(petWorkspaceDir(), { recursive: true });
5406
5681
  await voiceController.create({
5407
5682
  sessionId,
5408
- agentPreset: config.preset
5683
+ agentPreset: config.preset,
5684
+ cwd: petWorkspaceDir()
5409
5685
  });
5410
5686
  } catch (firstError) {
5411
5687
  driverLog(home, `VOICE preset "${config.preset}" rejected for pet=${petId}: ${firstError instanceof Error ? firstError.message : String(firstError)}; falling back`);
5412
5688
  try {
5413
5689
  await voiceController.create({
5414
5690
  sessionId,
5415
- agentPreset: "pet-inner-voice"
5691
+ agentPreset: "whale598",
5692
+ cwd: petWorkspaceDir()
5416
5693
  });
5417
5694
  } catch (secondError) {
5418
5695
  driverLog(home, `VOICE session create failed for pet=${petId}: ${secondError instanceof Error ? secondError.message : String(secondError)}`);
@@ -5420,9 +5697,10 @@ function installPetDriver(ctx, home, pet) {
5420
5697
  }
5421
5698
  }
5422
5699
  try {
5700
+ const customName = voiceNames.get(petId);
5423
5701
  await voiceController.rename({
5424
5702
  sessionId,
5425
- title: `鲸鱼娘的内心戏`
5703
+ title: `${customName ?? DEFAULT_PET_NAME[petId] ?? petId}的内心戏`
5426
5704
  });
5427
5705
  } catch {}
5428
5706
  voiceStore.sessions[petId] = {
@@ -5447,6 +5725,9 @@ function installPetDriver(ctx, home, pet) {
5447
5725
  const promptVoice = async (petId, text, opts = {}) => {
5448
5726
  const { retried = false, userDriven = false } = opts;
5449
5727
  if (latestArchived.has(voiceStore.sessions[petId]?.sessionId ?? "")) reconcileArchivedVoiceSessions();
5728
+ const customName = voiceNames.get(petId);
5729
+ if (customName !== void 0 && !text.startsWith("[名字]")) text = `[名字] 你叫「${customName}」。
5730
+ ${text}`;
5450
5731
  const sessionId = await ensureVoiceSession(petId);
5451
5732
  if (sessionId === void 0 || voiceController === void 0) return false;
5452
5733
  try {
@@ -5460,7 +5741,24 @@ function installPetDriver(ctx, home, pet) {
5460
5741
  }]
5461
5742
  }, new AbortController().signal);
5462
5743
  } catch (error) {
5463
- driverLog(home, `VOICE prompt failed pet=${petId}: ${error instanceof Error ? error.message : String(error)}`);
5744
+ const message = error instanceof Error ? error.message : String(error);
5745
+ driverLog(home, `VOICE prompt failed pet=${petId}: ${message}`);
5746
+ if (/Unknown agent preset/i.test(message) && !retried) {
5747
+ if (voiceStore.sessions[petId] !== void 0) {
5748
+ const generation = (voiceStore.generations[petId] ?? 0) + 1;
5749
+ voiceStore.generations[petId] = generation;
5750
+ Reflect.deleteProperty(voiceStore.sessions, petId);
5751
+ const ownerEntry = [...voiceOwners.entries()].find(([, owner]) => owner === petId);
5752
+ if (ownerEntry !== void 0) voiceOwners.delete(ownerEntry[0]);
5753
+ voiceLastPrompt.delete(petId);
5754
+ saveInnerVoiceStore(home, voiceStore);
5755
+ driverLog(home, `VOICE session retired pet=${petId} (recorded preset no longer exists — id renamed) — fresh -g${generation} mounts the current preset`);
5756
+ return promptVoice(petId, text, {
5757
+ retried: true,
5758
+ userDriven
5759
+ });
5760
+ }
5761
+ }
5464
5762
  return false;
5465
5763
  }
5466
5764
  voicePendingUntil.set(petId, Date.now() + 25e3);
@@ -5507,11 +5805,25 @@ function installPetDriver(ctx, home, pet) {
5507
5805
  * greeting (pet offline) releases the flag for the next sweep to retry. */
5508
5806
  const triggerGreeting = async (petId) => {
5509
5807
  if (!await voiceGateOpen(petId, false, "greeting")) return;
5510
- if (await promptVoice(petId, buildGreetPrompt({ affection: (await readLedger(ledgerFileFor(home, petId))).affection }))) {
5808
+ const ledger = await readLedger(home, petId);
5809
+ if (await promptVoice(petId, buildGreetPrompt({
5810
+ affection: ledger.affection,
5811
+ bondValue: ledger.bond?.value ?? 0,
5812
+ bondDays: ledger.bond?.days ?? 0,
5813
+ bondTier: bondTier(ledger.bond?.value ?? 0)
5814
+ }))) {
5511
5815
  voiceGreetedOnce.add(petId);
5512
5816
  driverLog(home, `VOICE boot greeting pet=${petId}`);
5513
5817
  } else voiceGreetedOnce.delete(petId);
5514
5818
  };
5819
+ /** Rename notice (user request): fired when the user's custom name actually
5820
+ * changes — she acknowledges out loud, once. The per-turn short name line
5821
+ * stays as the compaction-proof invariant; a skipped notice (pet offline /
5822
+ * turn in flight) loses nothing because that line still carries the name. */
5823
+ const triggerRenameNotice = async (petId, nextName, label) => {
5824
+ if (!await voiceGateOpen(petId, false, "rename")) return;
5825
+ if (await promptVoice(petId, nextName !== void 0 ? `[改名] 用户给你改名叫「${label}」——以后自我介绍、气泡里自称,都用这个名字。` : `[改名] 用户把你改回了「${label}」。`)) driverLog(home, `VOICE rename notice pet=${petId} → ${label}`);
5826
+ };
5515
5827
  /** Random-wake trigger (docs/inner-voice.md §6.3). The gate decides whether
5516
5828
  * this wake speaks — never the model. */
5517
5829
  const triggerWake = async (petId) => {
@@ -5523,11 +5835,14 @@ function installPetDriver(ctx, home, pet) {
5523
5835
  return;
5524
5836
  }
5525
5837
  if (!await voiceGateOpen(petId, true, "wake")) return;
5526
- const ledger = await readLedger(ledgerFileFor(home, petId));
5838
+ const ledger = await readLedger(home, petId);
5527
5839
  const now = Date.now();
5528
5840
  const lastSpoke = voiceLastSpokeAt.get(petId);
5529
5841
  if (await promptVoice(petId, buildWakePrompt({
5530
5842
  affection: ledger.affection,
5843
+ bondValue: ledger.bond?.value ?? 0,
5844
+ bondDays: ledger.bond?.days ?? 0,
5845
+ bondTier: bondTier(ledger.bond?.value ?? 0),
5531
5846
  minutesSinceLastSpoke: lastSpoke === void 0 ? void 0 : Math.round((now - lastSpoke) / 6e4),
5532
5847
  minutesSinceUserActivity: voiceUserActivityAt === void 0 ? void 0 : Math.round((now - voiceUserActivityAt) / 6e4),
5533
5848
  userActive: voiceUserActivityAt !== void 0 && now - voiceUserActivityAt < 30 * 6e4
@@ -5555,7 +5870,7 @@ function installPetDriver(ctx, home, pet) {
5555
5870
  return;
5556
5871
  }
5557
5872
  if (!await voiceGateOpen(petId, false, "touch", true)) return;
5558
- const ledger = await readLedger(ledgerFileFor(home, petId));
5873
+ const ledger = await readLedger(home, petId);
5559
5874
  if (await promptVoice(petId, buildTouchPrompt({
5560
5875
  part,
5561
5876
  touchesToday: ledger.recent.filter((entry) => entry.part === part).length + 1,
@@ -5613,12 +5928,10 @@ function installPetDriver(ctx, home, pet) {
5613
5928
  wakeSweep();
5614
5929
  const lastEventSeqs = /* @__PURE__ */ new Map();
5615
5930
  const recordTouch = async (petId, part, at) => {
5616
- const ledgerFile = ledgerFileFor(home, petId);
5617
- const ledger = await readLedger(ledgerFile);
5931
+ const ledger = await readLedger(home, petId);
5932
+ countBondDay(ledger);
5618
5933
  ledger.total += 1;
5619
5934
  ledger.byPart[part] = (ledger.byPart[part] ?? 0) + 1;
5620
- const before = ledger.affection;
5621
- ledger.affection = Math.min(100, ledger.affection + (part === "head" ? 2 : 1));
5622
5935
  ledger.lastTouchAt = at;
5623
5936
  ledger.recent.unshift({
5624
5937
  part,
@@ -5626,12 +5939,11 @@ function installPetDriver(ctx, home, pet) {
5626
5939
  });
5627
5940
  ledger.recent = ledger.recent.slice(0, 10);
5628
5941
  try {
5629
- await writeFile(ledgerFile, `${JSON.stringify(ledger)}
5630
- `, "utf8");
5942
+ await writeAffectionRaw(home, petId, ledger);
5631
5943
  } catch (error) {
5632
5944
  console.error(`pet-surface driver: could not write the ledger: ${error instanceof Error ? error.message : String(error)}`);
5633
5945
  }
5634
- if (Math.floor(ledger.affection / 10) > Math.floor(before / 10)) {
5946
+ if (Math.floor(ledger.total / 10) > Math.floor((ledger.total - 1) / 10)) {
5635
5947
  const target = (await pet.list()).find((pet) => pet.id === petId);
5636
5948
  if (target !== void 0) fetch(`http://127.0.0.1:${String(target.port)}/action`, {
5637
5949
  method: "POST",
@@ -5641,17 +5953,23 @@ function installPetDriver(ctx, home, pet) {
5641
5953
  }
5642
5954
  triggerTouchReply(petId, part);
5643
5955
  };
5644
- /** Event dispatch for ONE pet event. touch → the affection ledger;
5645
- * chat → STRAIGHT to the pet's inner-voice session, bypassing the touch
5646
- * path's probability/cooldown/user-busy gates entirely (a user reaching
5647
- * out to talk is never throttled — §6 routing constraint); unknown types
5648
- * are ignored while the cursor still advances. */
5649
5956
  const handlePetEvent = async (petId, event) => {
5957
+ appendJsonl(eventsFile(home, petId), {
5958
+ seq: event.seq,
5959
+ type: event.type,
5960
+ ...event.type === "touch" ? { part: event.part } : {},
5961
+ at: event.at
5962
+ });
5650
5963
  if (event.type === "touch") {
5651
5964
  if (event.part !== void 0) await recordTouch(petId, event.part, event.at);
5652
5965
  return;
5653
5966
  }
5654
- if (event.type === "chat" && event.text !== void 0 && event.text !== "") triggerChat(petId, event.text);
5967
+ if (event.type === "chat" && event.text !== void 0 && event.text !== "") {
5968
+ const ledger = await readLedger(home, petId);
5969
+ countBondDay(ledger);
5970
+ await writeAffectionRaw(home, petId, ledger);
5971
+ triggerChat(petId, event.text);
5972
+ }
5655
5973
  };
5656
5974
  /** Per-pet long-poll loops (§6 v4): each live pet parks ONE /events
5657
5975
  * hold=30 request; the answer — an event batch or the 30s empty one —
@@ -5714,12 +6032,13 @@ function installPetDriver(ctx, home, pet) {
5714
6032
  function registerPetTools(ctx, home, pet) {
5715
6033
  ctx.inject(["tools"], (scope) => {
5716
6034
  console.log("pet-surface driver: pet tools registered");
6035
+ const dshLoginAt = Date.now();
5717
6036
  const sleep = (ms) => new Promise((resolve) => {
5718
6037
  setTimeout(resolve, ms);
5719
6038
  });
5720
6039
  const readFocus = async () => {
5721
6040
  try {
5722
- const raw = JSON.parse(await readFile(join(home, "pet-settings.json"), "utf8"));
6041
+ const raw = await readGlobalJson(home, "pet-settings.json");
5723
6042
  return typeof raw.focus === "string" && raw.focus !== "" ? raw.focus : void 0;
5724
6043
  } catch {
5725
6044
  return;
@@ -5900,7 +6219,7 @@ function registerPetTools(ctx, home, pet) {
5900
6219
  if (event.at > (lastTouchAt ?? 0)) lastTouchAt = event.at;
5901
6220
  }
5902
6221
  digestCursors.set(entry.id, newest);
5903
- const ledger = await readLedger(ledgerFileFor(home, entry.id));
6222
+ const ledger = await readLedger(home, entry.id);
5904
6223
  const mood = moodOf(ledger.affection);
5905
6224
  return JSON.stringify({
5906
6225
  pet: entry.id,
@@ -5934,12 +6253,171 @@ function registerPetTools(ctx, home, pet) {
5934
6253
  const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.min(50, Math.max(1, Math.round(args.limit))) : 10;
5935
6254
  let list = [];
5936
6255
  try {
5937
- const raw = JSON.parse(await readFile(join(home, "pet-interactions.json"), "utf8"));
6256
+ const raw = await readGlobalJson(home, "pet-interactions.json");
5938
6257
  if (Array.isArray(raw)) list = raw;
5939
6258
  } catch {}
5940
6259
  return JSON.stringify({ recent: list.slice(0, limit) });
5941
6260
  }
5942
6261
  }));
6262
+ scope.tools.register(defineTool({
6263
+ name: "pet_affection_get",
6264
+ description: "Read the FULL affection context before adjusting it: the current value you maintain (and its mood), the touch FACTS the system records (counts by part — cause, not feeling), your recent adjustments with their reasons, the remaining daily quota, and the time facts (now / lastInteractionAt / lastLoginAt, epoch ms — the system only records and reports facts, it never moves affection or bond). Judge honestly: if the silence since lastInteractionAt has made the current value no longer true, adjust it yourself with a concrete reason.",
6265
+ parameters: { pet: {
6266
+ type: "string",
6267
+ description: "Which pet: its id from pet_state. Omit for the default pet."
6268
+ } },
6269
+ output: {
6270
+ schema: { type: "string" },
6271
+ render: (_args, value) => [{
6272
+ type: "text",
6273
+ text: value
6274
+ }]
6275
+ },
6276
+ async execute(args, exec) {
6277
+ try {
6278
+ const entry = await resolveTarget(args.pet, exec?.agent?.id);
6279
+ const ledger = await readLedger(home, entry.id);
6280
+ const now = Date.now();
6281
+ const lastInteractionAt = Math.max(ledger.lastTouchAt ?? 0, ledger.adjustments?.[0]?.at ?? 0);
6282
+ const today = (/* @__PURE__ */ new Date()).toDateString();
6283
+ const quota = ledger.quota?.date === today ? ledger.quota : {
6284
+ date: today,
6285
+ count: 0,
6286
+ sum: 0
6287
+ };
6288
+ return JSON.stringify({
6289
+ pet: entry.id,
6290
+ affection: ledger.affection,
6291
+ mood: moodOf(ledger.affection),
6292
+ touch: {
6293
+ total: ledger.total,
6294
+ byPart: ledger.byPart,
6295
+ lastTouchAt: ledger.lastTouchAt ?? null
6296
+ },
6297
+ bond: {
6298
+ value: ledger.bond?.value ?? 0,
6299
+ days: ledger.bond?.days ?? 0,
6300
+ tier: bondTier(ledger.bond?.value ?? 0)
6301
+ },
6302
+ recentBondAdjustments: (ledger.bondAdjustments ?? []).slice(0, 3),
6303
+ recentAdjustments: (ledger.adjustments ?? []).slice(0, 5),
6304
+ quota: {
6305
+ used: quota.count,
6306
+ limit: ADJUST_DAILY_LIMIT,
6307
+ sum: quota.sum,
6308
+ sumLimit: ADJUST_DAILY_SUM
6309
+ },
6310
+ facts: {
6311
+ now,
6312
+ lastInteractionAt: lastInteractionAt > 0 ? lastInteractionAt : null,
6313
+ lastLoginAt: dshLoginAt
6314
+ }
6315
+ });
6316
+ } catch (error) {
6317
+ throw classify(error);
6318
+ }
6319
+ }
6320
+ }));
6321
+ scope.tools.register(defineTool({
6322
+ name: "pet_affection_adjust",
6323
+ description: "Adjust the affection YOU maintain toward the user: delta (-3..+3 integer) plus a SPECIFIC reason citing what happened. Touch counts are facts the system records separately — this tool is your judgment layer, and the driver never moves the value on a touch. Limits: at least 5 minutes between adjustments, 8 per day, |daily sum| <= 10 — violations are REJECTED with the remaining quota, not clamped. Judge honestly: this number is your read of the relationship, not a score to farm.",
6324
+ parameters: {
6325
+ delta: {
6326
+ type: "number",
6327
+ required: true,
6328
+ description: "The change: a NON-ZERO integer between -3 and +3."
6329
+ },
6330
+ reason: {
6331
+ type: "string",
6332
+ required: true,
6333
+ description: "WHY this delta — cite the concrete interaction. Empty or generic reasons are rejected."
6334
+ },
6335
+ pet: {
6336
+ type: "string",
6337
+ description: "Which pet: its id from pet_state. Omit for the default pet."
6338
+ },
6339
+ bondDelta: {
6340
+ type: "number",
6341
+ description: "OPTIONAL change to the long-term bond: a NON-ZERO integer between -1 and +1. The slowest variable — the smallest steps."
6342
+ },
6343
+ bondReason: {
6344
+ type: "string",
6345
+ description: "Required when bondDelta is given: the concrete event that moved the bond."
6346
+ }
6347
+ },
6348
+ output: {
6349
+ schema: { type: "string" },
6350
+ render: (_args, value) => [{
6351
+ type: "text",
6352
+ text: value
6353
+ }]
6354
+ },
6355
+ async execute(args, exec) {
6356
+ try {
6357
+ const entry = await resolveTarget(args.pet, exec?.agent?.id);
6358
+ const ledger = await readLedger(home, entry.id);
6359
+ const now = Date.now();
6360
+ const delta = typeof args.delta === "number" && Number.isInteger(args.delta) ? args.delta : NaN;
6361
+ const reason = typeof args.reason === "string" ? args.reason.trim() : "";
6362
+ if (Number.isNaN(delta) || delta === 0 || Math.abs(delta) > ADJUST_MAX_DELTA) throw new Error(`delta 必须是 -${String(ADJUST_MAX_DELTA)}..+${String(ADJUST_MAX_DELTA)} 的非零整数(收到 ${String(args.delta)})`);
6363
+ if (reason === "") throw new Error("reason 必填:没有理由的调整不予记录");
6364
+ const adjustments = ledger.adjustments ?? [];
6365
+ const cooldownLeft = (adjustments.find((a) => !a.system)?.at ?? 0) + ADJUST_COOLDOWN_MS - now;
6366
+ if (cooldownLeft > 0) throw new Error(`距上次调整还有 ${String(Math.ceil(cooldownLeft / 1e3))} 秒冷却`);
6367
+ const today = (/* @__PURE__ */ new Date()).toDateString();
6368
+ const quota = ledger.quota?.date === today ? ledger.quota : {
6369
+ date: today,
6370
+ count: 0,
6371
+ sum: 0
6372
+ };
6373
+ if (quota.count >= ADJUST_DAILY_LIMIT) throw new Error(`今日调整次数已用完(${String(ADJUST_DAILY_LIMIT)})——明天再判断,或者相信当前的关系状态`);
6374
+ if (Math.abs(quota.sum + delta) > ADJUST_DAILY_SUM) throw new Error(`今日净变动将超过 ±${String(ADJUST_DAILY_SUM)}(当前 ${String(quota.sum)})——剩下的留给明天`);
6375
+ countBondDay(ledger);
6376
+ const bondDelta = typeof args.bondDelta === "number" && Number.isInteger(args.bondDelta) ? args.bondDelta : 0;
6377
+ const bondReason = typeof args.bondReason === "string" ? args.bondReason.trim() : "";
6378
+ if (bondDelta !== 0) {
6379
+ if (Math.abs(bondDelta) > 1) throw new Error("bondDelta 每次至多 ±1:羁绊是最慢的变量,不接受大步幅");
6380
+ if (bondReason === "") throw new Error("bondReason 必填:羁绊的增减要有具体的事由");
6381
+ }
6382
+ ledger.affection = Math.min(100, Math.max(0, ledger.affection + delta));
6383
+ quota.count += 1;
6384
+ quota.sum += delta;
6385
+ ledger.quota = quota;
6386
+ ledger.adjustments = [{
6387
+ delta,
6388
+ reason,
6389
+ at: now
6390
+ }, ...adjustments].slice(0, 20);
6391
+ if (bondDelta !== 0) {
6392
+ ledger.bond = {
6393
+ value: Math.max(0, (ledger.bond?.value ?? 0) + bondDelta),
6394
+ days: ledger.bond?.days ?? 0,
6395
+ lastCountedDate: ledger.bond?.lastCountedDate ?? (/* @__PURE__ */ new Date()).toDateString()
6396
+ };
6397
+ ledger.bondAdjustments = [{
6398
+ delta: bondDelta,
6399
+ reason: bondReason,
6400
+ at: now
6401
+ }, ...ledger.bondAdjustments ?? []].slice(0, 10);
6402
+ }
6403
+ await writeAffectionRaw(home, entry.id, ledger);
6404
+ return JSON.stringify({
6405
+ pet: entry.id,
6406
+ affection: ledger.affection,
6407
+ mood: moodOf(ledger.affection),
6408
+ applied: delta,
6409
+ quota: {
6410
+ used: quota.count,
6411
+ limit: ADJUST_DAILY_LIMIT,
6412
+ sum: quota.sum,
6413
+ sumLimit: ADJUST_DAILY_SUM
6414
+ }
6415
+ });
6416
+ } catch (error) {
6417
+ throw classify(error);
6418
+ }
6419
+ }
6420
+ }));
5943
6421
  scope.tools.register(defineTool({
5944
6422
  name: "pet_state",
5945
6423
  description: "Read the desktop pets' current state: every running pet with its id, what it is doing, which animation is playing, where it sits on screen (location), where it may roam (world, when the pet supports one), its visibility, plus each pet's affection book. Use it when the user refers to a pet, to pick a target for pet_say/pet_do, or to react to a pet.",
@@ -5969,18 +6447,28 @@ function registerPetTools(ctx, home, pet) {
5969
6447
  ]);
5970
6448
  const ledgers = {};
5971
6449
  for (const id of knownIds) {
5972
- const ledger = await readLedger(ledgerFileFor(home, id));
6450
+ const ledger = await readLedger(home, id);
5973
6451
  const mood = moodOf(ledger.affection);
6452
+ const tier = bondTier(ledger.bond?.value ?? 0);
6453
+ const bond = {
6454
+ value: ledger.bond?.value ?? 0,
6455
+ days: ledger.bond?.days ?? 0,
6456
+ tier
6457
+ };
5974
6458
  ledgers[id] = {
5975
6459
  ...ledger,
5976
- mood
6460
+ mood,
6461
+ bond
5977
6462
  };
5978
6463
  }
5979
6464
  const petsOut = states.map((state) => {
5980
6465
  const actions = actionsById.get(state.id);
5981
- return actions === void 0 ? state : {
5982
- ...state,
5983
- actions
6466
+ return {
6467
+ ...actions === void 0 ? state : {
6468
+ ...state,
6469
+ actions
6470
+ },
6471
+ name: voiceNames.get(state.id) ?? DEFAULT_PET_NAME[state.id] ?? state.id
5984
6472
  };
5985
6473
  });
5986
6474
  return JSON.stringify({
@@ -6255,4 +6743,4 @@ function installPetService(ctx, home) {
6255
6743
  return new PetHttpService(ctx, home);
6256
6744
  }
6257
6745
  //#endregion
6258
- export { logStamp as a, installPetDriver as i, installPetService as n, registerPetTools as o, petHome as r, createUnregisteredPetHttp as t };
6746
+ export { logStamp as a, globalFilePath as c, readJsonl as d, readPetName as f, writePetName as h, installPetDriver as i, readAffectionRaw as l, writeGlobalJson as m, installPetService as n, registerPetTools as o, writeAffectionRaw as p, petHome as r, eventsFile as s, createUnregisteredPetHttp as t, readGlobalJson as u };