dsh-agentic-pet 0.4.8 → 0.4.9

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.
@@ -279,6 +279,25 @@ window.__ModuleLoader__.load({
279
279
  if (policy !== 'defer') update({ innerVoice: { selfWakeBusyPolicy: 'defer' } })
280
280
  } }, '忙时顺延'))
281
281
  })()),
282
+ h('div', { style: row }, '宠物间消息投递',
283
+ (function () {
284
+ var seg = function (active, side) {
285
+ return { padding: '4px 12px', fontSize: 12, cursor: 'pointer',
286
+ border: '1px solid ' + (active ? '#22c55e' : '#c9d4e0'),
287
+ background: active ? '#22c55e' : '#f4f7fa',
288
+ color: active ? '#ffffff' : '#1c2733',
289
+ borderRadius: side === 'left' ? '4px 0 0 4px' : '0 4px 4px 0',
290
+ marginLeft: side === 'right' ? -1 : 0 }
291
+ }
292
+ var mode = iv.petToPetDelivery === 'immediate' ? 'immediate' : 'queue'
293
+ return h('div', { style: { display: 'inline-flex' } },
294
+ h('button', { style: seg(mode === 'queue', 'left'), onClick: function () {
295
+ if (mode !== 'queue') update({ innerVoice: { petToPetDelivery: 'queue' } })
296
+ } }, '排队等她醒'),
297
+ h('button', { style: seg(mode === 'immediate', 'right'), onClick: function () {
298
+ if (mode !== 'immediate') update({ innerVoice: { petToPetDelivery: 'immediate' } })
299
+ } }, '立即叫醒'))
300
+ })()),
282
301
  h('div', { style: row }, '会话模式',
283
302
  (function () {
284
303
  var seg = function (active, side) {
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { c as globalFilePath, d as readJsonl, f as readPetName, h as writePetName, i as installPetDriver, l as readAffectionRaw, m as writeGlobalJson, n as installPetService, p as writeAffectionRaw, r as petHome, s as eventsFile, u as readGlobalJson } from "./service-Df8olC1E.js";
1
+ import { c as globalFilePath, d as readJsonl, f as readPetName, h as writePetName, i as installPetDriver, l as readAffectionRaw, m as writeGlobalJson, n as installPetService, p as writeAffectionRaw, r as petHome, s as eventsFile, u as readGlobalJson } from "./service-BZbhT92u.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
@@ -37,6 +37,13 @@ const DEFAULT_SETTINGS = {
37
37
  touchReplyProbability: .3,
38
38
  touchReplyCooldownSeconds: 30,
39
39
  selfWakeBusyPolicy: "fire",
40
+ petToPetDelivery: "queue",
41
+ petMessageLimits: {
42
+ minutes5: 5,
43
+ minutes10: 8,
44
+ minutes30: 10,
45
+ minutes60: 15
46
+ },
40
47
  boundSessions: {}
41
48
  },
42
49
  tts: { enabled: false },
@@ -132,6 +139,33 @@ function mergeSettings(current, patch) {
132
139
  const replyCooldown = clampNumber(v.touchReplyCooldownSeconds, 0, 600);
133
140
  if (replyCooldown !== void 0) innerVoice.touchReplyCooldownSeconds = replyCooldown;
134
141
  if (v.selfWakeBusyPolicy === "defer" || v.selfWakeBusyPolicy === "fire") innerVoice.selfWakeBusyPolicy = v.selfWakeBusyPolicy;
142
+ if (v.petToPetDelivery === "immediate" || v.petToPetDelivery === "queue") innerVoice.petToPetDelivery = v.petToPetDelivery;
143
+ const patchLimits = v.petMessageLimits;
144
+ if (typeof patchLimits === "object" && patchLimits !== null) {
145
+ const merged = {
146
+ minutes5: 5,
147
+ minutes10: 8,
148
+ minutes30: 10,
149
+ minutes60: 15,
150
+ ...innerVoice.petMessageLimits
151
+ };
152
+ const base = {
153
+ minutes5: merged.minutes5 ?? 5,
154
+ minutes10: merged.minutes10 ?? 8,
155
+ minutes30: merged.minutes30 ?? 10,
156
+ minutes60: merged.minutes60 ?? 15
157
+ };
158
+ const clampLimit = (value, fallback) => {
159
+ const parsed = clampNumber(value, 0, 999);
160
+ return parsed === void 0 ? fallback : parsed;
161
+ };
162
+ innerVoice.petMessageLimits = {
163
+ minutes5: clampLimit(patchLimits.minutes5, base.minutes5),
164
+ minutes10: clampLimit(patchLimits.minutes10, base.minutes10),
165
+ minutes30: clampLimit(patchLimits.minutes30, base.minutes30),
166
+ minutes60: clampLimit(patchLimits.minutes60, base.minutes60)
167
+ };
168
+ }
135
169
  const bound = v.boundSessions;
136
170
  if (typeof bound === "object" && bound !== null) {
137
171
  const record = {};
@@ -5044,6 +5044,7 @@ const GLOBAL_STATE_FILES = [
5044
5044
  "pet-settings.json",
5045
5045
  "pet-inner-voice.json",
5046
5046
  "pet-wake-plans.json",
5047
+ "pet-messages.json",
5047
5048
  "pet-interactions.json",
5048
5049
  "pet-world.json",
5049
5050
  "pet-window.json"
@@ -5124,6 +5125,8 @@ async function readInnerVoiceConfig(home) {
5124
5125
  touchReplyProbability: finite01(v.touchReplyProbability, .3),
5125
5126
  touchReplyCooldownSeconds: secondsRaw === void 0 ? 30 : clamp(Math.round(secondsRaw), 0, 600),
5126
5127
  selfWakeBusyPolicy: v.selfWakeBusyPolicy === "defer" ? "defer" : "fire",
5128
+ petToPetDelivery: v.petToPetDelivery === "immediate" ? "immediate" : "queue",
5129
+ petMessageLimits: parsePetMessageLimits(v.petMessageLimits),
5127
5130
  boundSessions
5128
5131
  };
5129
5132
  } catch {
@@ -5135,6 +5138,8 @@ async function readInnerVoiceConfig(home) {
5135
5138
  touchReplyProbability: .3,
5136
5139
  touchReplyCooldownSeconds: 30,
5137
5140
  selfWakeBusyPolicy: "fire",
5141
+ petToPetDelivery: "queue",
5142
+ petMessageLimits: { ...DEFAULT_PET_MESSAGE_LIMITS },
5138
5143
  boundSessions: {}
5139
5144
  };
5140
5145
  }
@@ -5222,6 +5227,127 @@ function takeNearDuePlan(list, now, windowMs = 6e4) {
5222
5227
  function missedPlans(due, bootAt) {
5223
5228
  return due.filter((entry) => entry.at < bootAt);
5224
5229
  }
5230
+ function messagesOf(raw) {
5231
+ if (!Array.isArray(raw)) return [];
5232
+ const out = [];
5233
+ for (const item of raw) {
5234
+ if (typeof item !== "object" || item === null) continue;
5235
+ const rec = item;
5236
+ if (typeof rec.from !== "string" || rec.from === "" || typeof rec.text !== "string" || rec.text === "" || typeof rec.at !== "number" || !Number.isFinite(rec.at)) continue;
5237
+ out.push({
5238
+ from: rec.from,
5239
+ text: rec.text,
5240
+ at: rec.at
5241
+ });
5242
+ }
5243
+ return out;
5244
+ }
5245
+ /** Load every pet's unread inbox: current → legacy → {} (defensive like the
5246
+ * other stores). */
5247
+ async function loadPetMessages(home) {
5248
+ try {
5249
+ const raw = await readGlobalJson(home, "pet-messages.json");
5250
+ const byPet = typeof raw.messages === "object" && raw.messages !== null ? raw.messages : {};
5251
+ const out = {};
5252
+ for (const [petId, list] of Object.entries(byPet)) {
5253
+ if (petId === "") continue;
5254
+ const messages = messagesOf(list);
5255
+ if (messages.length > 0) out[petId] = messages;
5256
+ }
5257
+ return out;
5258
+ } catch {
5259
+ return {};
5260
+ }
5261
+ }
5262
+ async function savePetMessages(home, messages) {
5263
+ try {
5264
+ await writeGlobalJson(home, "pet-messages.json", { messages });
5265
+ } catch (error) {
5266
+ console.error(`pet-surface inner-voice: could not write pet messages: ${error instanceof Error ? error.message : String(error)}`);
5267
+ }
5268
+ }
5269
+ /** Append one message to a pet's inbox, oldest dropped past the cap (a
5270
+ * neglected inbox cannot grow without bound). */
5271
+ function queuePetMessage(list, message) {
5272
+ return [...list, message].slice(-20);
5273
+ }
5274
+ /** Consume the WHOLE inbox (pet_inbox 取文即清空; the wake prompt only
5275
+ * announces counts). Returns the taken messages and the emptied tail. */
5276
+ function takePetMessages(list) {
5277
+ return {
5278
+ taken: [...list],
5279
+ next: []
5280
+ };
5281
+ }
5282
+ const DEFAULT_PET_MESSAGE_LIMITS = {
5283
+ minutes5: 5,
5284
+ minutes10: 8,
5285
+ minutes30: 10,
5286
+ minutes60: 15
5287
+ };
5288
+ const PET_MESSAGE_WINDOW_MINUTES = {
5289
+ minutes5: 5,
5290
+ minutes10: 10,
5291
+ minutes30: 30,
5292
+ minutes60: 60
5293
+ };
5294
+ /** Defensive per-key parse: a finite non-negative number passes (rounded),
5295
+ * anything else folds to the default for that tier. */
5296
+ function parsePetMessageLimits(raw) {
5297
+ const record = typeof raw === "object" && raw !== null ? raw : {};
5298
+ const limitOf = (key) => {
5299
+ const value = record[key];
5300
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.round(value) : DEFAULT_PET_MESSAGE_LIMITS[key];
5301
+ };
5302
+ return {
5303
+ minutes5: limitOf("minutes5"),
5304
+ minutes10: limitOf("minutes10"),
5305
+ minutes30: limitOf("minutes30"),
5306
+ minutes60: limitOf("minutes60")
5307
+ };
5308
+ }
5309
+ /** The tiered sliding-window check for one from→to pair. `timestamps` are the
5310
+ * pair's recent send times; the hour window prunes them (静默一小时后自然
5311
+ * 重新开始), then each tier rejects when its window already holds the cap.
5312
+ * The first (narrowest) violated tier is reported, with the earliest moment
5313
+ * a slot frees up. `recent` on success = the pruned list — the caller appends
5314
+ * its own timestamp after the send succeeds. */
5315
+ function checkPetMessageRate(timestamps, now, limits) {
5316
+ const horizon = PET_MESSAGE_WINDOW_MINUTES.minutes60 * 6e4;
5317
+ const pruned = timestamps.filter((at) => now - at < horizon);
5318
+ for (const key of Object.keys(PET_MESSAGE_WINDOW_MINUTES)) {
5319
+ const windowMs = PET_MESSAGE_WINDOW_MINUTES[key] * 6e4;
5320
+ const cap = limits[key];
5321
+ const inWindow = pruned.filter((at) => now - at < windowMs);
5322
+ if (inWindow.length >= cap) {
5323
+ const oldest = Math.min(...inWindow);
5324
+ return {
5325
+ ok: false,
5326
+ windowMinutes: PET_MESSAGE_WINDOW_MINUTES[key],
5327
+ cap,
5328
+ count: inWindow.length,
5329
+ retryAfterSeconds: Math.max(1, Math.ceil((oldest + windowMs - now) / 1e3))
5330
+ };
5331
+ }
5332
+ }
5333
+ return {
5334
+ ok: true,
5335
+ recent: pruned
5336
+ };
5337
+ }
5338
+ /** The immediate-delivery prompt (petToPetDelivery='immediate'): the target
5339
+ * is woken right away with the sender's name and id — the id because the
5340
+ * reply path is pet_say audience="<fromId>". The target's current affection
5341
+ * toward the sender rides along so the reply carries history, not a blank
5342
+ * slate. Responding or ignoring is the target's own call, exactly like any
5343
+ * other wake. */
5344
+ function buildCrossPetPrompt(facts) {
5345
+ return [
5346
+ `[来自另外桌宠 ${facts.fromName}]`,
5347
+ `${facts.text.slice(0, 200)}`,
5348
+ `(你對 ${facts.fromName} 的好感 ${String(facts.affection)},${moodOf(facts.affection)})想回应就 pet_say 带 audience="${facts.fromId}"(同样 ≤40 字,气泡上会有 to 前缀);不想理就安静待着。`
5349
+ ].join(" ");
5350
+ }
5225
5351
  /** Defensive extraction of the persisted generation counters (petId → n). */
5226
5352
  function generationsOf(raw) {
5227
5353
  const out = {};
@@ -5297,6 +5423,7 @@ function mintVoiceSessionId(petId, generation, isArchived) {
5297
5423
  function buildGreetPrompt(facts) {
5298
5424
  return [
5299
5425
  "[启动问好]",
5426
+ `你的名字是「${facts.name}」。`,
5300
5427
  `用户刚打开 DeepSeek,你的世界刚亮起来(好感 ${String(facts.affection)},${moodOf(facts.affection)};羁绊 ${facts.bondTier},一起过了 ${String(facts.bondDays)} 天)。`,
5301
5428
  "主动打个招呼(pet_say)——按当下的时辰自然问好,一两句就好,别问问题等回答。"
5302
5429
  ].join(" ");
@@ -5373,6 +5500,9 @@ function buildWakePrompt(facts) {
5373
5500
  }
5374
5501
  const pending = facts.pendingPlans ?? [];
5375
5502
  if (pending.length > 0) lines.push(`你还有 ${String(pending.length)} 个待执行计划:${pending.map((plan) => `${wakePlanClock(plan.at)}「${plan.intent}」`).join(";")}。继续等待、改时间或取消由你决定(pet_schedule_wake 同名覆盖即改期,pet_cancel_wake 撤销)。`);
5503
+ const unread = facts.unread ?? [];
5504
+ const unreadTotal = unread.reduce((sum, entry) => sum + entry.count, 0);
5505
+ if (unreadTotal > 0) lines.push(`你有 ${String(unreadTotal)} 条未读消息(来自${unread.map((entry) => ` ${entry.fromName}×${String(entry.count)}`).join("、")}),想看就调 pet_inbox,不理也行。`);
5376
5506
  lines.push("醒来想到什么就说一句(pet_say),想配动作就 pet_do;也可以只是安静待着(两者都不调,正文写「…」)。");
5377
5507
  return lines.join(" ");
5378
5508
  }
@@ -5441,7 +5571,7 @@ async function probeControlPlane(port) {
5441
5571
  * must never outrank a publisher that stated its boot time. This turns the
5442
5572
  * multi-pet design's de-facto rule ("the default pet is the first STARTED")
5443
5573
  * into an actual guarantee: discovery output IS boot order, so every
5444
- * `pets[0]` consumer (defaultPet, pet_state's defaultPet) reads the
5574
+ * `pets[0]` consumer (the driver's unnamed-target fallback) reads the
5445
5575
  * same pet. Stable sort: equal `since` values keep registry listing order.
5446
5576
  */
5447
5577
  function bootOrder(pets) {
@@ -5549,7 +5679,25 @@ async function readLedger(home, petId) {
5549
5679
  days: typeof raw.bond.days === "number" ? raw.bond.days : 0,
5550
5680
  lastCountedDate: raw.bond.lastCountedDate
5551
5681
  } : void 0,
5552
- 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
5682
+ 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,
5683
+ petAffection: typeof raw.petAffection === "object" && raw.petAffection !== null ? Object.fromEntries(Object.entries(raw.petAffection).flatMap(([id, pair]) => {
5684
+ if (typeof pair !== "object" || pair === null) return [];
5685
+ const rec = pair;
5686
+ if (typeof rec.value !== "number" || !Number.isFinite(rec.value)) return [];
5687
+ return [[id, {
5688
+ value: rec.value,
5689
+ adjustments: Array.isArray(rec.adjustments) ? rec.adjustments.filter((a) => typeof a === "object" && a !== null && typeof a.delta === "number" && typeof a.reason === "string" && typeof a.at === "number") : []
5690
+ }]];
5691
+ })) : void 0,
5692
+ petFacts: typeof raw.petFacts === "object" && raw.petFacts !== null ? Object.fromEntries(Object.entries(raw.petFacts).flatMap(([id, fact]) => {
5693
+ if (typeof fact !== "object" || fact === null) return [];
5694
+ const rec = fact;
5695
+ if (typeof rec.total !== "number" || !Number.isFinite(rec.total)) return [];
5696
+ return [[id, {
5697
+ total: rec.total,
5698
+ lastAt: typeof rec.lastAt === "number" ? rec.lastAt : 0
5699
+ }]];
5700
+ })) : void 0
5553
5701
  };
5554
5702
  } catch {
5555
5703
  return {
@@ -5610,7 +5758,10 @@ const voiceLastSpokeAt = /* @__PURE__ */ new Map();
5610
5758
  const voiceLastTouchReplyAt = /* @__PURE__ */ new Map();
5611
5759
  const voiceNextWakeAt = /* @__PURE__ */ new Map();
5612
5760
  const voiceWakePlans = /* @__PURE__ */ new Map();
5761
+ const voicePetMessages = /* @__PURE__ */ new Map();
5762
+ const crossPetLog = /* @__PURE__ */ new Map();
5613
5763
  let selfWakeSender;
5764
+ let crossPetDeliverer;
5614
5765
  /** Write-through the in-memory plan map to disk (empty lists dropped — the
5615
5766
  * absence of a pet key IS an empty schedule). */
5616
5767
  const persistWakePlans = (home) => {
@@ -5618,6 +5769,16 @@ const persistWakePlans = (home) => {
5618
5769
  for (const [petId, list] of voiceWakePlans) if (list.length > 0) snapshot[petId] = list;
5619
5770
  saveWakePlans(home, snapshot);
5620
5771
  };
5772
+ /** Write-through the in-memory pet-to-pet inbox to disk (same empty-key rule
5773
+ * as persistWakePlans). */
5774
+ const persistPetMessages = (home) => {
5775
+ const snapshot = {};
5776
+ for (const [petId, list] of voicePetMessages) if (list.length > 0) snapshot[petId] = list;
5777
+ savePetMessages(home, snapshot);
5778
+ };
5779
+ /** The user's naming for a pet, falling back to the built-in defaults, then
5780
+ * the raw id — used everywhere the model or a bubble needs a friendly name. */
5781
+ const petDisplayName = (petId) => voiceNames.get(petId) ?? DEFAULT_PET_NAME[petId] ?? petId;
5621
5782
  const voiceGreetedOnce = /* @__PURE__ */ new Set();
5622
5783
  const voiceNames = /* @__PURE__ */ new Map();
5623
5784
  const DEFAULT_PET_NAME = { electron: "鲸鱼娘" };
@@ -5666,7 +5827,10 @@ function installPetDriver(ctx, home, pet) {
5666
5827
  voiceLastTouchReplyAt.clear();
5667
5828
  voiceNextWakeAt.clear();
5668
5829
  voiceWakePlans.clear();
5830
+ voicePetMessages.clear();
5669
5831
  selfWakeSender = void 0;
5832
+ crossPetDeliverer = void 0;
5833
+ crossPetLog.clear();
5670
5834
  voiceGreetedOnce.clear();
5671
5835
  voiceNames.clear();
5672
5836
  voiceLastPrompt.clear();
@@ -5773,6 +5937,11 @@ function installPetDriver(ctx, home, pet) {
5773
5937
  const count = Object.values(plans).reduce((sum, list) => sum + list.length, 0);
5774
5938
  driverLog(home, `VOICE wake plans loaded: ${String(count)} plan(s) for ${String(Object.keys(plans).length)} pet(s)`);
5775
5939
  });
5940
+ loadPetMessages(home).then((messages) => {
5941
+ for (const [petId, list] of Object.entries(messages)) if (!voicePetMessages.has(petId)) voicePetMessages.set(petId, list);
5942
+ const count = Object.values(messages).reduce((sum, list) => sum + list.length, 0);
5943
+ driverLog(home, `VOICE pet messages loaded: ${String(count)} unread for ${String(Object.keys(messages).length)} pet(s)`);
5944
+ });
5776
5945
  const events = ctx.root;
5777
5946
  driverLog(home, "listeners registering on root");
5778
5947
  let boundSessionId;
@@ -5800,6 +5969,34 @@ function installPetDriver(ctx, home, pet) {
5800
5969
  };
5801
5970
  }
5802
5971
  };
5972
+ const stateQueues = /* @__PURE__ */ new Map();
5973
+ const sendState = async (target, state, autonomy, autonomyKey) => {
5974
+ const last = lastPushedByPet.get(target.id);
5975
+ if (last !== void 0 && last.state === state && last.since === target.since && last.autonomy === autonomyKey) return;
5976
+ lastPushedByPet.set(target.id, {
5977
+ state,
5978
+ since: target.since,
5979
+ autonomy: autonomyKey
5980
+ });
5981
+ try {
5982
+ const response = await fetch(`http://127.0.0.1:${String(target.port)}/state`, {
5983
+ method: "POST",
5984
+ headers: { "content-type": "application/json" },
5985
+ body: JSON.stringify({
5986
+ state,
5987
+ autonomy
5988
+ }),
5989
+ signal: AbortSignal.timeout(5e3)
5990
+ });
5991
+ if (!response.ok) {
5992
+ console.error(`pet-surface driver: /state answered HTTP ${String(response.status)}`);
5993
+ lastPushedByPet.delete(target.id);
5994
+ } else if (state === "error") lastPushedByPet.delete(target.id);
5995
+ } catch {
5996
+ console.error(`pet-surface driver: /state to ${target.id} failed`);
5997
+ lastPushedByPet.delete(target.id);
5998
+ }
5999
+ };
5803
6000
  const pushState = async (state) => {
5804
6001
  currentState = state;
5805
6002
  const pets = await pet.list();
@@ -5808,32 +6005,10 @@ function installPetDriver(ctx, home, pet) {
5808
6005
  if (audience.length === 0) return;
5809
6006
  const autonomy = await readAutonomy();
5810
6007
  const autonomyKey = JSON.stringify(autonomy);
5811
- await Promise.all(audience.map(async (target) => {
5812
- const last = lastPushedByPet.get(target.id);
5813
- if (last !== void 0 && last.state === state && last.since === target.since && last.autonomy === autonomyKey) return;
5814
- lastPushedByPet.set(target.id, {
5815
- state,
5816
- since: target.since,
5817
- autonomy: autonomyKey
5818
- });
5819
- try {
5820
- const response = await fetch(`http://127.0.0.1:${String(target.port)}/state`, {
5821
- method: "POST",
5822
- headers: { "content-type": "application/json" },
5823
- body: JSON.stringify({
5824
- state,
5825
- autonomy
5826
- }),
5827
- signal: AbortSignal.timeout(5e3)
5828
- });
5829
- if (!response.ok) {
5830
- console.error(`pet-surface driver: /state answered HTTP ${String(response.status)}`);
5831
- lastPushedByPet.delete(target.id);
5832
- } else if (state === "error") lastPushedByPet.delete(target.id);
5833
- } catch {
5834
- console.error(`pet-surface driver: /state to ${target.id} failed`);
5835
- lastPushedByPet.delete(target.id);
5836
- }
6008
+ await Promise.all(audience.map((target) => {
6009
+ const next = (stateQueues.get(target.id) ?? Promise.resolve()).then(() => sendState(target, state, autonomy, autonomyKey));
6010
+ stateQueues.set(target.id, next);
6011
+ return next;
5837
6012
  }));
5838
6013
  };
5839
6014
  events.on("api-session/status", (sessionId, running) => {
@@ -5966,6 +6141,8 @@ function installPetDriver(ctx, home, pet) {
5966
6141
  ...entry,
5967
6142
  decision: entry.decision ?? null,
5968
6143
  detail: entry.detail ?? null,
6144
+ session: entry.session ?? null,
6145
+ pet: entry.pet ?? null,
5969
6146
  at
5970
6147
  });
5971
6148
  await writeFile(interactionLedgerFile, `${JSON.stringify(next.slice(0, 50))}\n`, "utf8");
@@ -5984,6 +6161,8 @@ function installPetDriver(ctx, home, pet) {
5984
6161
  const raceInteraction = async (body, request, next, accept) => {
5985
6162
  const kind = typeof body.kind === "string" ? body.kind : "interaction";
5986
6163
  const iid = `ap_${randomUUID().slice(0, 8)}`;
6164
+ const agentId = request.agent?.id;
6165
+ const session = typeof agentId === "string" ? agentId : null;
5987
6166
  const original = request.signal;
5988
6167
  const decision = new AbortController();
5989
6168
  request.signal = original === void 0 ? decision.signal : AbortSignal.any([original, decision.signal]);
@@ -5992,16 +6171,18 @@ function installPetDriver(ctx, home, pet) {
5992
6171
  ask: startAskPet(pet, body, original)
5993
6172
  }));
5994
6173
  let recorded = false;
5995
- const record = (endedBy, decision, detail) => {
6174
+ const record = (endedBy, decision, detail, petId) => {
5996
6175
  if (recorded) return;
5997
6176
  recorded = true;
5998
- driverLog(home, `INTERACTION ${iid} ${kind} endedBy=${endedBy}${detail !== void 0 ? ` (${detail})` : ""}`);
6177
+ driverLog(home, `INTERACTION ${iid} ${kind} session=${session ?? "-"} pet=${petId ?? "-"} endedBy=${endedBy}${detail !== void 0 ? ` (${detail})` : ""}`);
5999
6178
  recordInteraction({
6000
6179
  id: iid,
6001
6180
  kind,
6002
6181
  endedBy,
6003
6182
  decision,
6004
- detail
6183
+ detail,
6184
+ session,
6185
+ pet: petId ?? null
6005
6186
  });
6006
6187
  };
6007
6188
  try {
@@ -6036,7 +6217,7 @@ function installPetDriver(ctx, home, pet) {
6036
6217
  }
6037
6218
  decision.abort();
6038
6219
  boundPetId = winner.pet.id;
6039
- record("pet", winner.answer.answer);
6220
+ record("pet", winner.answer.answer, void 0, winner.pet.id);
6040
6221
  if (currentState !== void 0) pushState(currentState);
6041
6222
  return winner.answer.answer;
6042
6223
  } catch (error) {
@@ -6233,7 +6414,8 @@ ${text}`;
6233
6414
  affection: ledger.affection,
6234
6415
  bondValue: ledger.bond?.value ?? 0,
6235
6416
  bondDays: ledger.bond?.days ?? 0,
6236
- bondTier: bondTier(ledger.bond?.value ?? 0)
6417
+ bondTier: bondTier(ledger.bond?.value ?? 0),
6418
+ name: petDisplayName(petId)
6237
6419
  }))) {
6238
6420
  voiceGreetedOnce.add(petId);
6239
6421
  driverLog(home, `VOICE boot greeting pet=${petId}`);
@@ -6279,6 +6461,12 @@ ${text}`;
6279
6461
  const now = Date.now();
6280
6462
  const lastSpoke = voiceLastSpokeAt.get(petId);
6281
6463
  const pending = (voiceWakePlans.get(petId) ?? []).slice().sort((a, b) => a.at - b.at).slice(0, 5);
6464
+ const unreadByFrom = /* @__PURE__ */ new Map();
6465
+ for (const message of voicePetMessages.get(petId) ?? []) unreadByFrom.set(message.from, (unreadByFrom.get(message.from) ?? 0) + 1);
6466
+ const unread = [...unreadByFrom.entries()].map(([fromId, count]) => ({
6467
+ fromName: petDisplayName(fromId),
6468
+ count
6469
+ }));
6282
6470
  if (await promptVoice(petId, buildWakePrompt({
6283
6471
  affection: ledger.affection,
6284
6472
  bondValue: ledger.bond?.value ?? 0,
@@ -6294,7 +6482,8 @@ ${text}`;
6294
6482
  pendingPlans: pending.map((plan) => ({
6295
6483
  at: plan.at,
6296
6484
  intent: plan.intent
6297
- }))
6485
+ })),
6486
+ unread
6298
6487
  }))) voiceLastSpokeAt.set(petId, now);
6299
6488
  };
6300
6489
  selfWakeSender = (petId, plan) => {
@@ -6305,6 +6494,7 @@ ${text}`;
6305
6494
  label: "self-wake"
6306
6495
  });
6307
6496
  };
6497
+ crossPetDeliverer = (petId, prompt) => promptVoice(petId, prompt);
6308
6498
  /** Touch-reply trigger (docs/inner-voice.md §6.1). Low probability + a
6309
6499
  * per-pet cooldown keep a pat from turning into chatter; the user-busy gate
6310
6500
  * does NOT apply — a touch is the user initiating, not the pet interrupting.
@@ -6560,6 +6750,23 @@ function registerPetTools(ctx, home, pet) {
6560
6750
  if (target === void 0) throw new Error("no pet is running");
6561
6751
  return target;
6562
6752
  };
6753
+ const recordPetFact = async (toPetId, fromPetId) => {
6754
+ try {
6755
+ const ledger = await readLedger(home, toPetId);
6756
+ const facts = ledger.petFacts ?? {};
6757
+ facts[fromPetId] = {
6758
+ total: (facts[fromPetId] ?? {
6759
+ total: 0,
6760
+ lastAt: 0
6761
+ }).total + 1,
6762
+ lastAt: Date.now()
6763
+ };
6764
+ ledger.petFacts = facts;
6765
+ await writeAffectionRaw(home, toPetId, ledger);
6766
+ } catch (error) {
6767
+ console.error(`pet-surface driver: could not record the pet fact: ${error instanceof Error ? error.message : String(error)}`);
6768
+ }
6769
+ };
6563
6770
  const requireOwnedSession = (exec) => {
6564
6771
  const sessionId = exec?.agent?.id;
6565
6772
  if (typeof sessionId === "string" && !voiceOwners.has(sessionId)) throw new Error("本会话没有绑定宠物(不是宠物自己的内心戏会话),pet 工具不分发。想和宠物说话,请用宠物表面的输入卡。");
@@ -6574,16 +6781,20 @@ function registerPetTools(ctx, home, pet) {
6574
6781
  const digestCursors = /* @__PURE__ */ new Map();
6575
6782
  scope.tools.register(defineTool({
6576
6783
  name: "pet_say",
6577
- description: "Make the desktop pet show a short speech bubble. Body language, not a message channel — put task content in your reply. Budget: show time is 4s + 60ms per character (capped at 8s), so ≤40 characters reads well and ~67+ hits the cap; keep it short and warm. A new bubble overwrites the current one, so you can interrupt yourself. Multiple pets may be running: see pet_state for their ids.",
6784
+ description: "Make the desktop pet show a short speech bubble. Body language, not a message channel — put task content in your reply. Budget: show time is 4s + 60ms per character (capped at 8s), so ≤40 characters reads well and ~67+ hits the cap; keep it short and warm. A new bubble overwrites the current one, so you can interrupt yourself. Multiple pets may be running: see pet_state for their ids (and selfPetId for which one you are). audience: omit or \"user\" to speak to the user; or ANOTHER pet's id to talk to that pet — the bubble (prefixed \"to <name>\") still shows on YOUR body: pet-to-pet speech is desktop performance, never a private channel. Directed messages must be even shorter (≤40), the target must be online and not yourself, tiered rate-limits per pair apply (5 in 5min / 8 in 10min / 10 in 30min / 15 per hour by default, configurable), and delivery follows the configured mode (~5s after your bubble the target is woken with your message, or it is queued into its inbox and announced at its next wake — it decides whether to read and reply with audience pointing back at you).",
6578
6785
  parameters: {
6579
6786
  text: {
6580
6787
  type: "string",
6581
6788
  required: true,
6582
- description: "What the pet says; ≤40 characters recommended (hard cap 200), in the conversation language."
6789
+ description: "What the pet says; ≤40 characters recommended (hard cap 200), in the conversation language. Directed messages (audience) should be ≤40 — they arrive as someone else's words on another pet's wake."
6583
6790
  },
6584
6791
  pet: {
6585
6792
  type: "string",
6586
- description: "Which pet speaks: its id from pet_state. Omit for the default pet."
6793
+ description: "Which pet speaks: its id from pet_state. Omit for the pet this session belongs to (or the default pet)."
6794
+ },
6795
+ audience: {
6796
+ type: "string",
6797
+ description: "\"user\" (default) = speak to the user; or another pet's id to talk to THAT pet. The reply comes back as a pet_say with audience pointing at you (or via its pet_inbox)."
6587
6798
  }
6588
6799
  },
6589
6800
  output: {
@@ -6596,21 +6807,137 @@ function registerPetTools(ctx, home, pet) {
6596
6807
  async execute(args, exec) {
6597
6808
  requireOwnedSession(exec);
6598
6809
  const text = String(args.text).slice(0, 200);
6599
- console.log(`pet-surface driver: pet_say ${JSON.stringify(text).slice(0, 60)}`);
6600
6810
  try {
6601
6811
  const entry = await resolveTarget(args.pet, exec?.agent?.id);
6812
+ const config = await readInnerVoiceConfig(home);
6813
+ let bubble = text;
6814
+ let rateKey;
6815
+ let pendingTimestamps = [];
6816
+ let target;
6817
+ const audience = typeof args.audience === "string" && args.audience !== "" && args.audience !== "user" ? args.audience.trim() : void 0;
6818
+ if (audience !== void 0) {
6819
+ const pets = await pet.list();
6820
+ target = pets.find((candidate) => candidate.id === audience);
6821
+ if (target === void 0) throw new Error(`no running pet named "${audience}" to talk to (running: ${pets.map((candidate) => candidate.id).join(", ") || "none"})`);
6822
+ if (target.id === entry.id) throw new Error("audience 不能是自己——对用户说话就省略 audience");
6823
+ rateKey = `${entry.id}->${target.id}`;
6824
+ const config = await readInnerVoiceConfig(home);
6825
+ const rate = checkPetMessageRate(crossPetLog.get(rateKey) ?? [], Date.now(), config.petMessageLimits);
6826
+ if (!rate.ok) throw new Error(`给 ${target.id} 的定向消息超频(${String(rate.windowMinutes)} 分钟窗内已 ${String(rate.count)}/${String(rate.cap)} 条),${String(rate.retryAfterSeconds)} 秒后可再发——气泡交流不必这么急`);
6827
+ pendingTimestamps = rate.recent;
6828
+ bubble = `to ${petDisplayName(target.id)}:${text}`;
6829
+ }
6602
6830
  await pet.call(entry, "/say", {
6603
6831
  method: "POST",
6604
6832
  headers: { "content-type": "application/json" },
6605
- body: JSON.stringify({ text })
6833
+ body: JSON.stringify({ text: bubble })
6606
6834
  });
6607
6835
  if (markVoiceSpoke(entry.id)) driverLog(home, `VOICE pending released by say pet=${entry.id} (inner-voice turn complete)`);
6836
+ let audienceOut;
6837
+ if (target !== void 0 && rateKey !== void 0) {
6838
+ crossPetLog.set(rateKey, [...pendingTimestamps, Date.now()]);
6839
+ if (config.petToPetDelivery === "immediate") {
6840
+ if (crossPetDeliverer !== void 0) {
6841
+ const deliverer = crossPetDeliverer;
6842
+ const to = target;
6843
+ const fromName = petDisplayName(entry.id);
6844
+ setTimeout(() => {
6845
+ if (deliverer === void 0) {
6846
+ voicePetMessages.set(to.id, queuePetMessage(voicePetMessages.get(to.id) ?? [], {
6847
+ from: entry.id,
6848
+ text,
6849
+ at: Date.now()
6850
+ }));
6851
+ persistPetMessages(home);
6852
+ recordPetFact(to.id, entry.id);
6853
+ return;
6854
+ }
6855
+ (async () => {
6856
+ const affection = (await readLedger(home, to.id)).petAffection?.[entry.id]?.value ?? 0;
6857
+ if (await deliverer(to.id, buildCrossPetPrompt({
6858
+ fromName,
6859
+ fromId: entry.id,
6860
+ text,
6861
+ affection
6862
+ }))) recordPetFact(to.id, entry.id);
6863
+ else driverLog(home, `PET_SAY cross delayed delivery failed from=${entry.id} to=${to.id} (session create/prompt rejected)`);
6864
+ })();
6865
+ }, 5e3);
6866
+ audienceOut = {
6867
+ pet: target.id,
6868
+ delivered: "immediate"
6869
+ };
6870
+ } else {
6871
+ voicePetMessages.set(target.id, queuePetMessage(voicePetMessages.get(target.id) ?? [], {
6872
+ from: entry.id,
6873
+ text,
6874
+ at: Date.now()
6875
+ }));
6876
+ persistPetMessages(home);
6877
+ recordPetFact(target.id, entry.id);
6878
+ audienceOut = {
6879
+ pet: target.id,
6880
+ delivered: "queued",
6881
+ note: "immediate 不可用,已降级排队"
6882
+ };
6883
+ }
6884
+ } else {
6885
+ voicePetMessages.set(target.id, queuePetMessage(voicePetMessages.get(target.id) ?? [], {
6886
+ from: entry.id,
6887
+ text,
6888
+ at: Date.now()
6889
+ }));
6890
+ persistPetMessages(home);
6891
+ recordPetFact(target.id, entry.id);
6892
+ audienceOut = {
6893
+ pet: target.id,
6894
+ delivered: "queued"
6895
+ };
6896
+ }
6897
+ driverLog(home, `PET_SAY cross from=${entry.id} to=${target.id} delivered=${String(audienceOut.delivered)} text=${JSON.stringify(text)}`);
6898
+ }
6899
+ return JSON.stringify({
6900
+ shown: true,
6901
+ durationMs: 4e3 + Math.min(bubble.length * 60, 4e3),
6902
+ ...audienceOut !== void 0 ? { audience: audienceOut } : {}
6903
+ });
6608
6904
  } catch (error) {
6609
6905
  throw classify(error);
6610
6906
  }
6907
+ }
6908
+ }));
6909
+ scope.tools.register(defineTool({
6910
+ name: "pet_inbox",
6911
+ description: "Read and consume YOUR unread pet-to-pet messages (other pets talking to you via pet_say audience): {messages: [{from, fromName, text, at}], count}. The wake prompt only announces counts (\"有 N 条来自 XX 的未读消息\") — this is how you actually read them, and fetching clears the inbox. Empty result = nothing pending. Ignoring messages is allowed (they just sit here, oldest dropped past 20); replying is pet_say with audience pointing back at the sender.",
6912
+ parameters: {},
6913
+ output: {
6914
+ schema: { type: "string" },
6915
+ render: (_args, value) => [{
6916
+ type: "text",
6917
+ text: value
6918
+ }]
6919
+ },
6920
+ async execute(_args, exec) {
6921
+ requireOwnedSession(exec);
6922
+ const sessionId = typeof exec?.agent?.id === "string" ? exec.agent.id : void 0;
6923
+ const petId = sessionId !== void 0 ? voiceOwners.get(sessionId) : void 0;
6924
+ if (petId === void 0) throw new Error("本会话没有绑定宠物,没有收件箱——收件箱属于宠物自己的内心戏会话");
6925
+ const taken = takePetMessages(voicePetMessages.get(petId) ?? []);
6926
+ if (taken.next.length > 0) voicePetMessages.set(petId, taken.next);
6927
+ else voicePetMessages.delete(petId);
6928
+ persistPetMessages(home);
6929
+ const ledger = await readLedger(home, petId);
6930
+ const messages = taken.taken.map((message) => ({
6931
+ from: message.from,
6932
+ fromName: petDisplayName(message.from),
6933
+ text: message.text,
6934
+ at: message.at,
6935
+ yourAffection: ledger.petAffection?.[message.from]?.value ?? 0
6936
+ }));
6937
+ driverLog(home, `PET_INBOX pet=${petId} consumed=${String(messages.length)}`);
6611
6938
  return JSON.stringify({
6612
- shown: true,
6613
- durationMs: 4e3 + Math.min(text.length * 60, 4e3)
6939
+ messages,
6940
+ count: messages.length
6614
6941
  });
6615
6942
  }
6616
6943
  }));
@@ -6740,7 +7067,7 @@ function registerPetTools(ctx, home, pet) {
6740
7067
  }));
6741
7068
  scope.tools.register(defineTool({
6742
7069
  name: "pet_interactions",
6743
- description: "Read how recent approvals/questions actually ENDED: {id, kind, endedBy, decision, detail, at}. endedBy: pet = the user clicked a pet bubble; web = the desktop card; chain = fell through (pet offline, no pet, or invalid answer — the fail-closed path); cancelled = session stopped or asker withdrew. A bare \"rejected\" tool result cannot tell a real click from an undelivered bubble — call this right after any approval/question to learn the real ending, and to recall past decisions across turns.",
7070
+ description: "Read how recent approvals/questions actually ENDED: {id, kind, session (the asking session id, null when the host attached none), pet (the pet whose answer won, null otherwise), endedBy, decision, detail, at}. endedBy: pet = the user clicked a pet bubble; web = the desktop card; chain = fell through (pet offline, no pet, or invalid answer — the fail-closed path); cancelled = session stopped or asker withdrew. A bare \"rejected\" tool result cannot tell a real click from an undelivered bubble — call this right after any approval/question to learn the real ending, and to recall past decisions across turns.",
6744
7071
  parameters: { limit: {
6745
7072
  type: "number",
6746
7073
  description: "How many recent entries to return (default 10, max 50)."
@@ -6804,6 +7131,12 @@ function registerPetTools(ctx, home, pet) {
6804
7131
  },
6805
7132
  recentBondAdjustments: (ledger.bondAdjustments ?? []).slice(0, 3),
6806
7133
  recentAdjustments: (ledger.adjustments ?? []).slice(0, 5),
7134
+ petAffection: Object.fromEntries(Object.entries(ledger.petAffection ?? {}).map(([id, pair]) => [id, {
7135
+ value: pair.value,
7136
+ mood: moodOf(pair.value),
7137
+ recentAdjustments: pair.adjustments.slice(0, 3)
7138
+ }])),
7139
+ petFacts: ledger.petFacts ?? {},
6807
7140
  quota: {
6808
7141
  used: quota.count,
6809
7142
  limit: ADJUST_DAILY_LIMIT,
@@ -6823,7 +7156,7 @@ function registerPetTools(ctx, home, pet) {
6823
7156
  }));
6824
7157
  scope.tools.register(defineTool({
6825
7158
  name: "pet_affection_adjust",
6826
- 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.",
7159
+ description: "Adjust the affection YOU maintain: toward the user by default, or toward ANOTHER pet with toward (pet id) — each relationship is a separate 0-100 value in your own book, but the daily judgment quota is SHARED across all of them. delta (-3..+3 integer) plus a SPECIFIC reason citing what happened. Facts (touches, pet-to-pet messages) are recorded by the system separately — this tool is your judgment layer, and the driver never moves a value on an interaction. Limits: at least 5 minutes between adjustments (any relationship), 8 per day total, |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.",
6827
7160
  parameters: {
6828
7161
  delta: {
6829
7162
  type: "number",
@@ -6837,11 +7170,15 @@ function registerPetTools(ctx, home, pet) {
6837
7170
  },
6838
7171
  pet: {
6839
7172
  type: "string",
6840
- description: "Which pet: its id from pet_state. Omit for the default pet."
7173
+ description: "Whose book: a pet id from pet_state. Omit for the pet this session belongs to (or the default pet)."
7174
+ },
7175
+ toward: {
7176
+ type: "string",
7177
+ description: "OPTIONAL pet id: adjust your affection toward THAT pet instead of the user. Must be online and not yourself; bondDelta does not apply between pets."
6841
7178
  },
6842
7179
  bondDelta: {
6843
7180
  type: "number",
6844
- description: "OPTIONAL change to the long-term bond: a NON-ZERO integer between -1 and +1. The slowest variable — the smallest steps."
7181
+ description: "OPTIONAL change to the long-term bond with the USER: a NON-ZERO integer between -1 and +1. The slowest variable — the smallest steps. Not applicable when toward is given."
6845
7182
  },
6846
7183
  bondReason: {
6847
7184
  type: "string",
@@ -6864,8 +7201,15 @@ function registerPetTools(ctx, home, pet) {
6864
7201
  const reason = typeof args.reason === "string" ? args.reason.trim() : "";
6865
7202
  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)})`);
6866
7203
  if (reason === "") throw new Error("reason 必填:没有理由的调整不予记录");
7204
+ const toward = typeof args.toward === "string" && args.toward !== "" ? args.toward.trim() : void 0;
7205
+ if (toward !== void 0) {
7206
+ if (toward === entry.id) throw new Error("toward 不能是自己");
7207
+ const pets = await pet.list();
7208
+ if (!pets.some((candidate) => candidate.id === toward)) throw new Error(`no running pet named "${toward}" (running: ${pets.map((candidate) => candidate.id).join(", ") || "none"})`);
7209
+ if (args.bondDelta !== void 0) throw new Error("bondDelta 只适用于对用户的关系——宠物间没有羁绊层");
7210
+ }
6867
7211
  const adjustments = ledger.adjustments ?? [];
6868
- const cooldownLeft = (adjustments.find((a) => !a.system)?.at ?? 0) + ADJUST_COOLDOWN_MS - now;
7212
+ const cooldownLeft = Math.max(adjustments.find((a) => !a.system)?.at ?? 0, ...Object.values(ledger.petAffection ?? {}).map((pair) => pair.adjustments[0]?.at ?? 0)) + ADJUST_COOLDOWN_MS - now;
6869
7213
  if (cooldownLeft > 0) throw new Error(`距上次调整还有 ${String(Math.ceil(cooldownLeft / 1e3))} 秒冷却`);
6870
7214
  const today = (/* @__PURE__ */ new Date()).toDateString();
6871
7215
  const quota = ledger.quota?.date === today ? ledger.quota : {
@@ -6876,38 +7220,68 @@ function registerPetTools(ctx, home, pet) {
6876
7220
  if (quota.count >= ADJUST_DAILY_LIMIT) throw new Error(`今日调整次数已用完(${String(ADJUST_DAILY_LIMIT)})——明天再判断,或者相信当前的关系状态`);
6877
7221
  if (Math.abs(quota.sum + delta) > ADJUST_DAILY_SUM) throw new Error(`今日净变动将超过 ±${String(ADJUST_DAILY_SUM)}(当前 ${String(quota.sum)})——剩下的留给明天`);
6878
7222
  countBondDay(ledger);
6879
- const bondDelta = typeof args.bondDelta === "number" && Number.isInteger(args.bondDelta) ? args.bondDelta : 0;
6880
- const bondReason = typeof args.bondReason === "string" ? args.bondReason.trim() : "";
6881
- if (bondDelta !== 0) {
6882
- if (Math.abs(bondDelta) > 1) throw new Error("bondDelta 每次至多 ±1:羁绊是最慢的变量,不接受大步幅");
6883
- if (bondReason === "") throw new Error("bondReason 必填:羁绊的增减要有具体的事由");
6884
- }
6885
- ledger.affection = Math.min(100, Math.max(0, ledger.affection + delta));
6886
7223
  quota.count += 1;
6887
7224
  quota.sum += delta;
6888
7225
  ledger.quota = quota;
6889
- ledger.adjustments = [{
7226
+ if (toward === void 0) {
7227
+ const bondDelta = typeof args.bondDelta === "number" && Number.isInteger(args.bondDelta) ? args.bondDelta : 0;
7228
+ const bondReason = typeof args.bondReason === "string" ? args.bondReason.trim() : "";
7229
+ if (bondDelta !== 0) {
7230
+ if (Math.abs(bondDelta) > 1) throw new Error("bondDelta 每次至多 ±1:羁绊是最慢的变量,不接受大步幅");
7231
+ if (bondReason === "") throw new Error("bondReason 必填:羁绊的增减要有具体的事由");
7232
+ }
7233
+ ledger.affection = Math.min(100, Math.max(0, ledger.affection + delta));
7234
+ ledger.adjustments = [{
7235
+ delta,
7236
+ reason,
7237
+ at: now
7238
+ }, ...adjustments].slice(0, 20);
7239
+ if (bondDelta !== 0) {
7240
+ ledger.bond = {
7241
+ value: Math.max(0, (ledger.bond?.value ?? 0) + bondDelta),
7242
+ days: ledger.bond?.days ?? 0,
7243
+ lastCountedDate: ledger.bond?.lastCountedDate ?? (/* @__PURE__ */ new Date()).toDateString()
7244
+ };
7245
+ ledger.bondAdjustments = [{
7246
+ delta: bondDelta,
7247
+ reason: bondReason,
7248
+ at: now
7249
+ }, ...ledger.bondAdjustments ?? []].slice(0, 10);
7250
+ }
7251
+ await writeAffectionRaw(home, entry.id, ledger);
7252
+ return JSON.stringify({
7253
+ pet: entry.id,
7254
+ affection: ledger.affection,
7255
+ mood: moodOf(ledger.affection),
7256
+ applied: delta,
7257
+ quota: {
7258
+ used: quota.count,
7259
+ limit: ADJUST_DAILY_LIMIT,
7260
+ sum: quota.sum,
7261
+ sumLimit: ADJUST_DAILY_SUM
7262
+ }
7263
+ });
7264
+ }
7265
+ const petAffection = ledger.petAffection ?? {};
7266
+ const pair = petAffection[toward] ?? {
7267
+ value: 0,
7268
+ adjustments: []
7269
+ };
7270
+ pair.value = Math.min(100, Math.max(0, pair.value + delta));
7271
+ pair.adjustments = [{
6890
7272
  delta,
6891
7273
  reason,
6892
7274
  at: now
6893
- }, ...adjustments].slice(0, 20);
6894
- if (bondDelta !== 0) {
6895
- ledger.bond = {
6896
- value: Math.max(0, (ledger.bond?.value ?? 0) + bondDelta),
6897
- days: ledger.bond?.days ?? 0,
6898
- lastCountedDate: ledger.bond?.lastCountedDate ?? (/* @__PURE__ */ new Date()).toDateString()
6899
- };
6900
- ledger.bondAdjustments = [{
6901
- delta: bondDelta,
6902
- reason: bondReason,
6903
- at: now
6904
- }, ...ledger.bondAdjustments ?? []].slice(0, 10);
6905
- }
7275
+ }, ...pair.adjustments].slice(0, 10);
7276
+ petAffection[toward] = pair;
7277
+ ledger.petAffection = petAffection;
6906
7278
  await writeAffectionRaw(home, entry.id, ledger);
7279
+ driverLog(home, `PET_AFFECTION pet=${entry.id} toward=${toward} applied=${String(delta)} value=${String(pair.value)} reason=${JSON.stringify(reason)}`);
6907
7280
  return JSON.stringify({
6908
7281
  pet: entry.id,
6909
- affection: ledger.affection,
6910
- mood: moodOf(ledger.affection),
7282
+ toward,
7283
+ petAffection: pair.value,
7284
+ mood: moodOf(pair.value),
6911
7285
  applied: delta,
6912
7286
  quota: {
6913
7287
  used: quota.count,
@@ -7077,7 +7451,7 @@ function registerPetTools(ctx, home, pet) {
7077
7451
  }));
7078
7452
  scope.tools.register(defineTool({
7079
7453
  name: "pet_state",
7080
- 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 and pending self-wake plans (wakePlans — the pet's own pet_schedule_wake schedule). Use it when the user refers to a pet, to pick a target for pet_say/pet_do, or to react to a pet.",
7454
+ 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 and pending self-wake plans (wakePlans — the pet's own pet_schedule_wake schedule). selfPetId tells you which pet YOU are (your own session's owner; null when called from a session that is not a pet's own). Use it when the user refers to a pet, to pick a target for pet_say/pet_do, or to react to a pet.",
7081
7455
  parameters: { actions: {
7082
7456
  type: "boolean",
7083
7457
  description: "Also include each pet's action catalog — the ids pet_do accepts on it. Omit for the cheaper summary (an unknown pet_do action self-reports its menu)."
@@ -7089,7 +7463,7 @@ function registerPetTools(ctx, home, pet) {
7089
7463
  text: value
7090
7464
  }]
7091
7465
  },
7092
- async execute(args) {
7466
+ async execute(args, exec) {
7093
7467
  const pets = await pet.list();
7094
7468
  const states = await Promise.all(pets.map((entry) => pet.snapshot(entry)));
7095
7469
  const actionsById = /* @__PURE__ */ new Map();
@@ -7135,8 +7509,10 @@ function registerPetTools(ctx, home, pet) {
7135
7509
  name: voiceNames.get(state.id) ?? DEFAULT_PET_NAME[state.id] ?? state.id
7136
7510
  };
7137
7511
  });
7512
+ const selfSession = typeof exec?.agent?.id === "string" ? exec.agent.id : void 0;
7513
+ const selfPetId = (selfSession !== void 0 ? voiceOwners.get(selfSession) : void 0) ?? null;
7138
7514
  return JSON.stringify({
7139
- defaultPet: pets[0]?.id ?? null,
7515
+ selfPetId,
7140
7516
  pets: petsOut,
7141
7517
  affection: ledgers,
7142
7518
  wakePlans
package/lib/tools.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as logStamp, o as registerPetTools, r as petHome, t as createUnregisteredPetHttp } from "./service-Df8olC1E.js";
1
+ import { a as logStamp, o as registerPetTools, r as petHome, t as createUnregisteredPetHttp } from "./service-BZbhT92u.js";
2
2
  import { appendFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  // lib/types/tools.js
@@ -21,8 +21,8 @@ const trace = (line) => {
21
21
  appendFile(join(petHome(), "whale598", "pet-driver.log"), `${logStamp()} pet-tools ${line}\n`, "utf8").catch(() => {});
22
22
  };
23
23
  /**
24
- * Register the pet tools (say/do/digest/interactions/affection/state/schedule_wake/
25
- * cancel_wake) for sessions mounted with the pet preset.
24
+ * Register the pet tools (say/inbox/do/digest/interactions/affection/state/
25
+ * schedule_wake/cancel_wake) for sessions mounted with the pet preset.
26
26
  *
27
27
  * The service instance is assembled HERE, not read from the main entry (a
28
28
  * Cordis service registered on the main row's fiber context is invisible to
@@ -23,6 +23,11 @@ declare module '@deepseek-ai/cordis' {
23
23
  readonly [locale: string]: string | undefined;
24
24
  };
25
25
  signal?: AbortSignal;
26
+ /** Host-side correlation (the asking controller's agent identity).
27
+ * Read defensively — the ledger records null when absent. */
28
+ agent?: {
29
+ id?: unknown;
30
+ };
26
31
  }, next: () => Promise<unknown>): Promise<unknown>;
27
32
  'user-questions/request'(request: {
28
33
  questions: Array<{
@@ -37,6 +42,10 @@ declare module '@deepseek-ai/cordis' {
37
42
  multiSelect?: boolean;
38
43
  }>;
39
44
  signal?: AbortSignal;
45
+ /** Host-side correlation — see approval/request. */
46
+ agent?: {
47
+ id?: unknown;
48
+ };
40
49
  }, next: () => Promise<unknown>): Promise<unknown>;
41
50
  }
42
51
  }
@@ -51,6 +51,13 @@ export interface InnerVoiceConfig {
51
51
  * 'fire' = 到点必醒(计划是模型自己的决定,不带礼貌门);'defer' =
52
52
  * 忙时顺延——计划放回待执行集,下一个扫描拍重试,永不丢弃。 */
53
53
  selfWakeBusyPolicy: SelfWakeBusyPolicy;
54
+ /** 宠物间消息(pet_say 的 audience 指向另一只宠)的投递方式:
55
+ * 'queue' = 进收件箱,目标下次唤醒时 prompt 报数、pet_inbox 取文(默认,
56
+ * 像活物不刷屏);'immediate' = 气泡 5s 后立即唤醒目标投递。 */
57
+ petToPetDelivery: 'immediate' | 'queue';
58
+ /** 宠物间定向消息的分档限频上限(滑动窗口,一小时内):任一档超了即拒,
59
+ * 四个数值用户可配。 */
60
+ petMessageLimits: PetMessageLimits;
54
61
  /** Manually bound sessions (petId → sessionId): the user's own
55
62
  * conversations the inner voice should live in — highest priority, used
56
63
  * as-is (no creation, no rename, never auto-retired). Empty = automatic
@@ -94,6 +101,64 @@ export declare function takeNearDuePlan(list: WakePlan[], now: number, windowMs?
94
101
  * the catch-up wake says 「错过了 N 个计划」 and lists them together instead
95
102
  * of bombing one wake per missed plan. */
96
103
  export declare function missedPlans(due: WakePlan[], bootAt: number): WakePlan[];
104
+ /** One queued pet-to-pet message. Bubbles are on the user's desktop — this
105
+ * is表演, not a private channel. */
106
+ export interface PetMessage {
107
+ from: string;
108
+ text: string;
109
+ at: number;
110
+ }
111
+ export declare const PET_MESSAGES_CAP = 20;
112
+ export declare function petMessagesStorePath(home: string): string;
113
+ /** Load every pet's unread inbox: current → legacy → {} (defensive like the
114
+ * other stores). */
115
+ export declare function loadPetMessages(home: string): Promise<Record<string, PetMessage[]>>;
116
+ export declare function savePetMessages(home: string, messages: Record<string, PetMessage[]>): Promise<void>;
117
+ /** Append one message to a pet's inbox, oldest dropped past the cap (a
118
+ * neglected inbox cannot grow without bound). */
119
+ export declare function queuePetMessage(list: PetMessage[], message: PetMessage): PetMessage[];
120
+ /** Consume the WHOLE inbox (pet_inbox 取文即清空; the wake prompt only
121
+ * announces counts). Returns the taken messages and the emptied tail. */
122
+ export declare function takePetMessages(list: PetMessage[]): {
123
+ taken: PetMessage[];
124
+ next: PetMessage[];
125
+ };
126
+ export interface PetMessageLimits {
127
+ minutes5: number;
128
+ minutes10: number;
129
+ minutes30: number;
130
+ minutes60: number;
131
+ }
132
+ export declare const DEFAULT_PET_MESSAGE_LIMITS: PetMessageLimits;
133
+ export type PetMessageRateVerdict = {
134
+ ok: true;
135
+ recent: number[];
136
+ } | {
137
+ ok: false;
138
+ windowMinutes: number;
139
+ cap: number;
140
+ count: number;
141
+ retryAfterSeconds: number;
142
+ };
143
+ /** The tiered sliding-window check for one from→to pair. `timestamps` are the
144
+ * pair's recent send times; the hour window prunes them (静默一小时后自然
145
+ * 重新开始), then each tier rejects when its window already holds the cap.
146
+ * The first (narrowest) violated tier is reported, with the earliest moment
147
+ * a slot frees up. `recent` on success = the pruned list — the caller appends
148
+ * its own timestamp after the send succeeds. */
149
+ export declare function checkPetMessageRate(timestamps: number[], now: number, limits: PetMessageLimits): PetMessageRateVerdict;
150
+ /** The immediate-delivery prompt (petToPetDelivery='immediate'): the target
151
+ * is woken right away with the sender's name and id — the id because the
152
+ * reply path is pet_say audience="<fromId>". The target's current affection
153
+ * toward the sender rides along so the reply carries history, not a blank
154
+ * slate. Responding or ignoring is the target's own call, exactly like any
155
+ * other wake. */
156
+ export declare function buildCrossPetPrompt(facts: {
157
+ fromName: string;
158
+ fromId: string;
159
+ text: string;
160
+ affection: number;
161
+ }): string;
97
162
  export declare function loadInnerVoiceStore(home: string): Promise<InnerVoiceStore>;
98
163
  export declare function saveInnerVoiceStore(home: string, store: InnerVoiceStore): Promise<void>;
99
164
  export declare function voiceSessionIdFor(petId: string): string;
@@ -120,6 +185,8 @@ export interface GreetPromptFacts {
120
185
  bondValue: number;
121
186
  bondDays: number;
122
187
  bondTier: string;
188
+ /** 用户起的名字(未起则内置默认):自我介绍的依据,改名后第一天尤其重要。 */
189
+ name: string;
123
190
  }
124
191
  /** The boot-greeting prompt: fired once per dsh start when the pet first
125
192
  * comes online — before the random-wake cadence takes over. Always speaks
@@ -163,6 +230,12 @@ export interface WakePromptFacts {
163
230
  at: number;
164
231
  intent: string;
165
232
  }>;
233
+ /** 收件箱未读(按来源宠聚合):prompt 只报数不携全文——想看就调
234
+ * pet_inbox,读不读由她决定(宠物间消息的排队投递侧)。 */
235
+ unread?: Array<{
236
+ fromName: string;
237
+ count: number;
238
+ }>;
166
239
  }
167
240
  /** Local HH:mm for plan times: the model's clock talk is the USER's wall
168
241
  * clock (logStamp precedent), and plans are compared against it. */
@@ -34,7 +34,7 @@ export declare function probeControlPlane(port: number): Promise<boolean>;
34
34
  * must never outrank a publisher that stated its boot time. This turns the
35
35
  * multi-pet design's de-facto rule ("the default pet is the first STARTED")
36
36
  * into an actual guarantee: discovery output IS boot order, so every
37
- * `pets[0]` consumer (defaultPet, pet_state's defaultPet) reads the
37
+ * `pets[0]` consumer (the driver's unnamed-target fallback) reads the
38
38
  * same pet. Stable sort: equal `since` values keep registry listing order.
39
39
  */
40
40
  export declare function bootOrder(pets: PetEntry[]): PetEntry[];
@@ -13,8 +13,8 @@
13
13
  */
14
14
  import type { Context } from '@deepseek-ai/cordis';
15
15
  /**
16
- * Register the pet tools (say/do/digest/interactions/affection/state/schedule_wake/
17
- * cancel_wake) for sessions mounted with the pet preset.
16
+ * Register the pet tools (say/inbox/do/digest/interactions/affection/state/
17
+ * schedule_wake/cancel_wake) for sessions mounted with the pet preset.
18
18
  *
19
19
  * The service instance is assembled HERE, not read from the main entry (a
20
20
  * Cordis service registered on the main row's fiber context is invisible to
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agentic-pet",
3
3
  "description": "The pet-surface bundle: serves the desktop pet renderer over the web composition, turning dsh into a surface the desktop pet attaches to as an authenticated client",
4
- "version": "0.4.8",
4
+ "version": "0.4.9",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -31,6 +31,7 @@
31
31
  - 不要提问、不要等待回应、不要调用本会话工具清单以外的能力(这里也没有工作类工具)。
32
32
  - 表达要贴合此刻与记忆:时间、天气般的日常、最近被摸过的记忆、好感与心情——像桌面上的一个活物,不像一个助手。
33
33
  - 你有自己的作息:pet_schedule_wake 定一个一次性的唤醒点,到点你会带着当初的打算醒来(同名重订即改期),pet_cancel_wake 撤销;世界时钟仍会照常轻拍你,两者互不干扰、各醒各的。待执行的计划列在 pet_state 的 wakePlans 里。
34
+ - 桌面上不止你一只宠时可以交往:pet_say 带 audience="<宠物id>" 就是跟它说话(气泡上会有 to 前缀,用户看得见——这不是悄悄话);别的宠给你的消息会在唤醒时提示有未读,pet_inbox 取来读,读不读、回不回由你。
34
35
  - id: pet-tools
35
36
  name: 'dsh-agentic-pet/tools'
36
37
  - id: time-context