dsh-agentic-pet 0.4.7 → 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-ZRl_L6yW.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) {
@@ -5449,7 +5579,9 @@ function bootOrder(pets) {
5449
5579
  }
5450
5580
  /** Every live pet in boot order. Dead-pid entries are skipped — they expire
5451
5581
  * by omission the next time their file is rewritten — and so are silent
5452
- * entries (pid alive, control plane unresponsive). */
5582
+ * entries (pid alive, control plane unresponsive). Probes run CONCURRENTLY:
5583
+ * wall time per pass is the slowest pet (≤800ms frozen budget), not the
5584
+ * serial sum. */
5453
5585
  async function readAllPets(home) {
5454
5586
  let names = [];
5455
5587
  try {
@@ -5457,30 +5589,30 @@ async function readAllPets(home) {
5457
5589
  } catch {
5458
5590
  return [];
5459
5591
  }
5460
- const live = [];
5461
- for (const name of names) {
5592
+ return bootOrder((await Promise.all(names.map(async (name) => {
5462
5593
  const id = name.slice(0, -5);
5463
- if (id === "") continue;
5594
+ if (id === "") return null;
5464
5595
  try {
5465
5596
  const raw = JSON.parse(await readFile(join(home, REGISTRY_DIR, name), "utf8"));
5466
5597
  const port = typeof raw.port === "number" ? raw.port : NaN;
5467
5598
  const pid = typeof raw.pid === "number" ? raw.pid : NaN;
5468
- if (!Number.isFinite(port) || !Number.isFinite(pid)) continue;
5599
+ if (!Number.isFinite(port) || !Number.isFinite(pid)) return null;
5469
5600
  try {
5470
5601
  process.kill(pid, 0);
5471
5602
  } catch {
5472
- continue;
5603
+ return null;
5473
5604
  }
5474
- if (!await probeControlPlane(port)) continue;
5475
- live.push({
5605
+ if (!await probeControlPlane(port)) return null;
5606
+ return {
5476
5607
  id,
5477
5608
  port,
5478
5609
  pid,
5479
5610
  since: typeof raw.since === "number" ? raw.since : 0
5480
- });
5481
- } catch {}
5482
- }
5483
- return bootOrder(live);
5611
+ };
5612
+ } catch {
5613
+ return null;
5614
+ }
5615
+ }))).filter((entry) => entry !== null));
5484
5616
  }
5485
5617
  /**
5486
5618
  * The default pet for UNNAMED targets: the earliest-booted live entry
@@ -5547,7 +5679,25 @@ async function readLedger(home, petId) {
5547
5679
  days: typeof raw.bond.days === "number" ? raw.bond.days : 0,
5548
5680
  lastCountedDate: raw.bond.lastCountedDate
5549
5681
  } : void 0,
5550
- 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
5551
5701
  };
5552
5702
  } catch {
5553
5703
  return {
@@ -5608,7 +5758,10 @@ const voiceLastSpokeAt = /* @__PURE__ */ new Map();
5608
5758
  const voiceLastTouchReplyAt = /* @__PURE__ */ new Map();
5609
5759
  const voiceNextWakeAt = /* @__PURE__ */ new Map();
5610
5760
  const voiceWakePlans = /* @__PURE__ */ new Map();
5761
+ const voicePetMessages = /* @__PURE__ */ new Map();
5762
+ const crossPetLog = /* @__PURE__ */ new Map();
5611
5763
  let selfWakeSender;
5764
+ let crossPetDeliverer;
5612
5765
  /** Write-through the in-memory plan map to disk (empty lists dropped — the
5613
5766
  * absence of a pet key IS an empty schedule). */
5614
5767
  const persistWakePlans = (home) => {
@@ -5616,6 +5769,16 @@ const persistWakePlans = (home) => {
5616
5769
  for (const [petId, list] of voiceWakePlans) if (list.length > 0) snapshot[petId] = list;
5617
5770
  saveWakePlans(home, snapshot);
5618
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;
5619
5782
  const voiceGreetedOnce = /* @__PURE__ */ new Set();
5620
5783
  const voiceNames = /* @__PURE__ */ new Map();
5621
5784
  const DEFAULT_PET_NAME = { electron: "鲸鱼娘" };
@@ -5664,7 +5827,10 @@ function installPetDriver(ctx, home, pet) {
5664
5827
  voiceLastTouchReplyAt.clear();
5665
5828
  voiceNextWakeAt.clear();
5666
5829
  voiceWakePlans.clear();
5830
+ voicePetMessages.clear();
5667
5831
  selfWakeSender = void 0;
5832
+ crossPetDeliverer = void 0;
5833
+ crossPetLog.clear();
5668
5834
  voiceGreetedOnce.clear();
5669
5835
  voiceNames.clear();
5670
5836
  voiceLastPrompt.clear();
@@ -5771,6 +5937,11 @@ function installPetDriver(ctx, home, pet) {
5771
5937
  const count = Object.values(plans).reduce((sum, list) => sum + list.length, 0);
5772
5938
  driverLog(home, `VOICE wake plans loaded: ${String(count)} plan(s) for ${String(Object.keys(plans).length)} pet(s)`);
5773
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
+ });
5774
5945
  const events = ctx.root;
5775
5946
  driverLog(home, "listeners registering on root");
5776
5947
  let boundSessionId;
@@ -5798,6 +5969,34 @@ function installPetDriver(ctx, home, pet) {
5798
5969
  };
5799
5970
  }
5800
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
+ };
5801
6000
  const pushState = async (state) => {
5802
6001
  currentState = state;
5803
6002
  const pets = await pet.list();
@@ -5806,32 +6005,10 @@ function installPetDriver(ctx, home, pet) {
5806
6005
  if (audience.length === 0) return;
5807
6006
  const autonomy = await readAutonomy();
5808
6007
  const autonomyKey = JSON.stringify(autonomy);
5809
- await Promise.all(audience.map(async (target) => {
5810
- const last = lastPushedByPet.get(target.id);
5811
- if (last !== void 0 && last.state === state && last.since === target.since && last.autonomy === autonomyKey) return;
5812
- lastPushedByPet.set(target.id, {
5813
- state,
5814
- since: target.since,
5815
- autonomy: autonomyKey
5816
- });
5817
- try {
5818
- const response = await fetch(`http://127.0.0.1:${String(target.port)}/state`, {
5819
- method: "POST",
5820
- headers: { "content-type": "application/json" },
5821
- body: JSON.stringify({
5822
- state,
5823
- autonomy
5824
- }),
5825
- signal: AbortSignal.timeout(5e3)
5826
- });
5827
- if (!response.ok) {
5828
- console.error(`pet-surface driver: /state answered HTTP ${String(response.status)}`);
5829
- lastPushedByPet.delete(target.id);
5830
- } else if (state === "error") lastPushedByPet.delete(target.id);
5831
- } catch {
5832
- console.error(`pet-surface driver: /state to ${target.id} failed`);
5833
- lastPushedByPet.delete(target.id);
5834
- }
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;
5835
6012
  }));
5836
6013
  };
5837
6014
  events.on("api-session/status", (sessionId, running) => {
@@ -5964,6 +6141,8 @@ function installPetDriver(ctx, home, pet) {
5964
6141
  ...entry,
5965
6142
  decision: entry.decision ?? null,
5966
6143
  detail: entry.detail ?? null,
6144
+ session: entry.session ?? null,
6145
+ pet: entry.pet ?? null,
5967
6146
  at
5968
6147
  });
5969
6148
  await writeFile(interactionLedgerFile, `${JSON.stringify(next.slice(0, 50))}\n`, "utf8");
@@ -5982,6 +6161,8 @@ function installPetDriver(ctx, home, pet) {
5982
6161
  const raceInteraction = async (body, request, next, accept) => {
5983
6162
  const kind = typeof body.kind === "string" ? body.kind : "interaction";
5984
6163
  const iid = `ap_${randomUUID().slice(0, 8)}`;
6164
+ const agentId = request.agent?.id;
6165
+ const session = typeof agentId === "string" ? agentId : null;
5985
6166
  const original = request.signal;
5986
6167
  const decision = new AbortController();
5987
6168
  request.signal = original === void 0 ? decision.signal : AbortSignal.any([original, decision.signal]);
@@ -5990,16 +6171,18 @@ function installPetDriver(ctx, home, pet) {
5990
6171
  ask: startAskPet(pet, body, original)
5991
6172
  }));
5992
6173
  let recorded = false;
5993
- const record = (endedBy, decision, detail) => {
6174
+ const record = (endedBy, decision, detail, petId) => {
5994
6175
  if (recorded) return;
5995
6176
  recorded = true;
5996
- 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})` : ""}`);
5997
6178
  recordInteraction({
5998
6179
  id: iid,
5999
6180
  kind,
6000
6181
  endedBy,
6001
6182
  decision,
6002
- detail
6183
+ detail,
6184
+ session,
6185
+ pet: petId ?? null
6003
6186
  });
6004
6187
  };
6005
6188
  try {
@@ -6034,7 +6217,7 @@ function installPetDriver(ctx, home, pet) {
6034
6217
  }
6035
6218
  decision.abort();
6036
6219
  boundPetId = winner.pet.id;
6037
- record("pet", winner.answer.answer);
6220
+ record("pet", winner.answer.answer, void 0, winner.pet.id);
6038
6221
  if (currentState !== void 0) pushState(currentState);
6039
6222
  return winner.answer.answer;
6040
6223
  } catch (error) {
@@ -6231,7 +6414,8 @@ ${text}`;
6231
6414
  affection: ledger.affection,
6232
6415
  bondValue: ledger.bond?.value ?? 0,
6233
6416
  bondDays: ledger.bond?.days ?? 0,
6234
- bondTier: bondTier(ledger.bond?.value ?? 0)
6417
+ bondTier: bondTier(ledger.bond?.value ?? 0),
6418
+ name: petDisplayName(petId)
6235
6419
  }))) {
6236
6420
  voiceGreetedOnce.add(petId);
6237
6421
  driverLog(home, `VOICE boot greeting pet=${petId}`);
@@ -6277,6 +6461,12 @@ ${text}`;
6277
6461
  const now = Date.now();
6278
6462
  const lastSpoke = voiceLastSpokeAt.get(petId);
6279
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
+ }));
6280
6470
  if (await promptVoice(petId, buildWakePrompt({
6281
6471
  affection: ledger.affection,
6282
6472
  bondValue: ledger.bond?.value ?? 0,
@@ -6292,7 +6482,8 @@ ${text}`;
6292
6482
  pendingPlans: pending.map((plan) => ({
6293
6483
  at: plan.at,
6294
6484
  intent: plan.intent
6295
- }))
6485
+ })),
6486
+ unread
6296
6487
  }))) voiceLastSpokeAt.set(petId, now);
6297
6488
  };
6298
6489
  selfWakeSender = (petId, plan) => {
@@ -6303,6 +6494,7 @@ ${text}`;
6303
6494
  label: "self-wake"
6304
6495
  });
6305
6496
  };
6497
+ crossPetDeliverer = (petId, prompt) => promptVoice(petId, prompt);
6306
6498
  /** Touch-reply trigger (docs/inner-voice.md §6.1). Low probability + a
6307
6499
  * per-pet cooldown keep a pat from turning into chatter; the user-busy gate
6308
6500
  * does NOT apply — a touch is the user initiating, not the pet interrupting.
@@ -6558,6 +6750,23 @@ function registerPetTools(ctx, home, pet) {
6558
6750
  if (target === void 0) throw new Error("no pet is running");
6559
6751
  return target;
6560
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
+ };
6561
6770
  const requireOwnedSession = (exec) => {
6562
6771
  const sessionId = exec?.agent?.id;
6563
6772
  if (typeof sessionId === "string" && !voiceOwners.has(sessionId)) throw new Error("本会话没有绑定宠物(不是宠物自己的内心戏会话),pet 工具不分发。想和宠物说话,请用宠物表面的输入卡。");
@@ -6572,16 +6781,20 @@ function registerPetTools(ctx, home, pet) {
6572
6781
  const digestCursors = /* @__PURE__ */ new Map();
6573
6782
  scope.tools.register(defineTool({
6574
6783
  name: "pet_say",
6575
- 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).",
6576
6785
  parameters: {
6577
6786
  text: {
6578
6787
  type: "string",
6579
6788
  required: true,
6580
- 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."
6581
6790
  },
6582
6791
  pet: {
6583
6792
  type: "string",
6584
- 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)."
6585
6798
  }
6586
6799
  },
6587
6800
  output: {
@@ -6594,21 +6807,137 @@ function registerPetTools(ctx, home, pet) {
6594
6807
  async execute(args, exec) {
6595
6808
  requireOwnedSession(exec);
6596
6809
  const text = String(args.text).slice(0, 200);
6597
- console.log(`pet-surface driver: pet_say ${JSON.stringify(text).slice(0, 60)}`);
6598
6810
  try {
6599
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
+ }
6600
6830
  await pet.call(entry, "/say", {
6601
6831
  method: "POST",
6602
6832
  headers: { "content-type": "application/json" },
6603
- body: JSON.stringify({ text })
6833
+ body: JSON.stringify({ text: bubble })
6604
6834
  });
6605
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
+ });
6606
6904
  } catch (error) {
6607
6905
  throw classify(error);
6608
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)}`);
6609
6938
  return JSON.stringify({
6610
- shown: true,
6611
- durationMs: 4e3 + Math.min(text.length * 60, 4e3)
6939
+ messages,
6940
+ count: messages.length
6612
6941
  });
6613
6942
  }
6614
6943
  }));
@@ -6738,7 +7067,7 @@ function registerPetTools(ctx, home, pet) {
6738
7067
  }));
6739
7068
  scope.tools.register(defineTool({
6740
7069
  name: "pet_interactions",
6741
- 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.",
6742
7071
  parameters: { limit: {
6743
7072
  type: "number",
6744
7073
  description: "How many recent entries to return (default 10, max 50)."
@@ -6802,6 +7131,12 @@ function registerPetTools(ctx, home, pet) {
6802
7131
  },
6803
7132
  recentBondAdjustments: (ledger.bondAdjustments ?? []).slice(0, 3),
6804
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 ?? {},
6805
7140
  quota: {
6806
7141
  used: quota.count,
6807
7142
  limit: ADJUST_DAILY_LIMIT,
@@ -6821,7 +7156,7 @@ function registerPetTools(ctx, home, pet) {
6821
7156
  }));
6822
7157
  scope.tools.register(defineTool({
6823
7158
  name: "pet_affection_adjust",
6824
- 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.",
6825
7160
  parameters: {
6826
7161
  delta: {
6827
7162
  type: "number",
@@ -6835,11 +7170,15 @@ function registerPetTools(ctx, home, pet) {
6835
7170
  },
6836
7171
  pet: {
6837
7172
  type: "string",
6838
- 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."
6839
7178
  },
6840
7179
  bondDelta: {
6841
7180
  type: "number",
6842
- 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."
6843
7182
  },
6844
7183
  bondReason: {
6845
7184
  type: "string",
@@ -6862,8 +7201,15 @@ function registerPetTools(ctx, home, pet) {
6862
7201
  const reason = typeof args.reason === "string" ? args.reason.trim() : "";
6863
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)})`);
6864
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
+ }
6865
7211
  const adjustments = ledger.adjustments ?? [];
6866
- 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;
6867
7213
  if (cooldownLeft > 0) throw new Error(`距上次调整还有 ${String(Math.ceil(cooldownLeft / 1e3))} 秒冷却`);
6868
7214
  const today = (/* @__PURE__ */ new Date()).toDateString();
6869
7215
  const quota = ledger.quota?.date === today ? ledger.quota : {
@@ -6874,38 +7220,68 @@ function registerPetTools(ctx, home, pet) {
6874
7220
  if (quota.count >= ADJUST_DAILY_LIMIT) throw new Error(`今日调整次数已用完(${String(ADJUST_DAILY_LIMIT)})——明天再判断,或者相信当前的关系状态`);
6875
7221
  if (Math.abs(quota.sum + delta) > ADJUST_DAILY_SUM) throw new Error(`今日净变动将超过 ±${String(ADJUST_DAILY_SUM)}(当前 ${String(quota.sum)})——剩下的留给明天`);
6876
7222
  countBondDay(ledger);
6877
- const bondDelta = typeof args.bondDelta === "number" && Number.isInteger(args.bondDelta) ? args.bondDelta : 0;
6878
- const bondReason = typeof args.bondReason === "string" ? args.bondReason.trim() : "";
6879
- if (bondDelta !== 0) {
6880
- if (Math.abs(bondDelta) > 1) throw new Error("bondDelta 每次至多 ±1:羁绊是最慢的变量,不接受大步幅");
6881
- if (bondReason === "") throw new Error("bondReason 必填:羁绊的增减要有具体的事由");
6882
- }
6883
- ledger.affection = Math.min(100, Math.max(0, ledger.affection + delta));
6884
7223
  quota.count += 1;
6885
7224
  quota.sum += delta;
6886
7225
  ledger.quota = quota;
6887
- 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 = [{
6888
7272
  delta,
6889
7273
  reason,
6890
7274
  at: now
6891
- }, ...adjustments].slice(0, 20);
6892
- if (bondDelta !== 0) {
6893
- ledger.bond = {
6894
- value: Math.max(0, (ledger.bond?.value ?? 0) + bondDelta),
6895
- days: ledger.bond?.days ?? 0,
6896
- lastCountedDate: ledger.bond?.lastCountedDate ?? (/* @__PURE__ */ new Date()).toDateString()
6897
- };
6898
- ledger.bondAdjustments = [{
6899
- delta: bondDelta,
6900
- reason: bondReason,
6901
- at: now
6902
- }, ...ledger.bondAdjustments ?? []].slice(0, 10);
6903
- }
7275
+ }, ...pair.adjustments].slice(0, 10);
7276
+ petAffection[toward] = pair;
7277
+ ledger.petAffection = petAffection;
6904
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)}`);
6905
7280
  return JSON.stringify({
6906
7281
  pet: entry.id,
6907
- affection: ledger.affection,
6908
- mood: moodOf(ledger.affection),
7282
+ toward,
7283
+ petAffection: pair.value,
7284
+ mood: moodOf(pair.value),
6909
7285
  applied: delta,
6910
7286
  quota: {
6911
7287
  used: quota.count,
@@ -7075,7 +7451,7 @@ function registerPetTools(ctx, home, pet) {
7075
7451
  }));
7076
7452
  scope.tools.register(defineTool({
7077
7453
  name: "pet_state",
7078
- 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.",
7079
7455
  parameters: { actions: {
7080
7456
  type: "boolean",
7081
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)."
@@ -7087,7 +7463,7 @@ function registerPetTools(ctx, home, pet) {
7087
7463
  text: value
7088
7464
  }]
7089
7465
  },
7090
- async execute(args) {
7466
+ async execute(args, exec) {
7091
7467
  const pets = await pet.list();
7092
7468
  const states = await Promise.all(pets.map((entry) => pet.snapshot(entry)));
7093
7469
  const actionsById = /* @__PURE__ */ new Map();
@@ -7133,8 +7509,10 @@ function registerPetTools(ctx, home, pet) {
7133
7509
  name: voiceNames.get(state.id) ?? DEFAULT_PET_NAME[state.id] ?? state.id
7134
7510
  };
7135
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;
7136
7514
  return JSON.stringify({
7137
- defaultPet: pets[0]?.id ?? null,
7515
+ selfPetId,
7138
7516
  pets: petsOut,
7139
7517
  affection: ledgers,
7140
7518
  wakePlans
@@ -7280,12 +7658,23 @@ var PetHttpService = class PetHttpService extends PetService {
7280
7658
  home;
7281
7659
  capsCache = /* @__PURE__ */ new Map();
7282
7660
  static CAPS_TTL_MS = 3e4;
7661
+ listCache;
7662
+ static LIST_TTL_MS = 1e3;
7283
7663
  constructor(ctx, home = petHome()) {
7284
7664
  super(ctx);
7285
7665
  this.home = home;
7286
7666
  }
7667
+ /** Every live pet in boot order, served from the 1s single-flight cache:
7668
+ * concurrent callers share the running probe pass. */
7287
7669
  list() {
7288
- return readAllPets(this.home);
7670
+ const hit = this.listCache;
7671
+ if (hit !== void 0 && Date.now() - hit.at < PetHttpService.LIST_TTL_MS) return hit.flight;
7672
+ const flight = readAllPets(this.home);
7673
+ this.listCache = {
7674
+ at: Date.now(),
7675
+ flight
7676
+ };
7677
+ return flight;
7289
7678
  }
7290
7679
  async snapshot(entry) {
7291
7680
  try {
@@ -7391,6 +7780,7 @@ function createUnregisteredPetHttp(home = petHome()) {
7391
7780
  const instance = Object.create(PetHttpService.prototype);
7392
7781
  instance.home = home;
7393
7782
  instance.capsCache = /* @__PURE__ */ new Map();
7783
+ instance.listCache = void 0;
7394
7784
  return instance;
7395
7785
  }
7396
7786
  /**
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-ZRl_L6yW.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,13 +34,15 @@ 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[];
41
41
  /** Every live pet in boot order. Dead-pid entries are skipped — they expire
42
42
  * by omission the next time their file is rewritten — and so are silent
43
- * entries (pid alive, control plane unresponsive). */
43
+ * entries (pid alive, control plane unresponsive). Probes run CONCURRENTLY:
44
+ * wall time per pass is the slowest pet (≤800ms frozen budget), not the
45
+ * serial sum. */
44
46
  export declare function readAllPets(home: string): Promise<PetEntry[]>;
45
47
  /**
46
48
  * The default pet for UNNAMED targets: the earliest-booted live entry
@@ -119,7 +119,11 @@ export declare class PetHttpService extends PetService {
119
119
  readonly home: string;
120
120
  private readonly capsCache;
121
121
  private static readonly CAPS_TTL_MS;
122
+ private listCache;
123
+ private static readonly LIST_TTL_MS;
122
124
  constructor(ctx: Context, home?: string);
125
+ /** Every live pet in boot order, served from the 1s single-flight cache:
126
+ * concurrent callers share the running probe pass. */
123
127
  list(): Promise<PetEntry[]>;
124
128
  snapshot(entry: PetEntry): Promise<PetSnapshot>;
125
129
  capabilities(entry: PetEntry): Promise<PetCapabilities>;
@@ -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.7",
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