Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 61 additions & 18 deletions main_logic/activity/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
Snapshot consumer
-----------------

Only the proactive-chat code path calls ``get_snapshot()``. It runs on
the order of seconds (not milliseconds), so the small per-call cost of
running the state-machine classifier is irrelevant.
The proactive-chat code path calls ``get_snapshot()`` with enrichment
enabled. Rule-only consumers can call ``get_snapshot(include_enrichment=False)``
to reuse the same classifier without starting the activity-guess LLM loop.
Both paths run on the order of seconds (not milliseconds), so the small
per-call cost of running the state-machine classifier is irrelevant.
"""

from __future__ import annotations
Expand Down Expand Up @@ -455,7 +457,13 @@ def push_external_system_signal(

# ── snapshot ────────────────────────────────────────────────

async def get_snapshot(self, *, now: float | None = None) -> ActivitySnapshot:
async def get_snapshot(
self,
*,
now: float | None = None,
include_enrichment: bool = True,
tick_followups: bool = True,
) -> ActivitySnapshot:
"""Pull system signals and emit a fresh snapshot.

Async because it ensures the system collector has been started
Expand All @@ -466,8 +474,16 @@ async def get_snapshot(self, *, now: float | None = None) -> ActivitySnapshot:
the resolved state is ``private``, in which case enrichment
is suppressed (LLM input + cached output both bypassed) so the
user's secret context never reaches the model.

Set ``include_enrichment=False`` for rule-only consumers that
need state / active-window / idle signals but do not consume the
emotion-tier enrichment cache or background activity-guess loop.
Set ``tick_followups=False`` for read-only pollers that must not
advance break-reminder / anti-slack pending state.
"""
await self._ensure_collector_started()
await self._ensure_collector_started(
start_activity_guess_loop=include_enrichment
)
self._refresh_prefs()

ts = now if now is not None else time.time()
Expand All @@ -484,7 +500,8 @@ async def get_snapshot(self, *, now: float | None = None) -> ActivitySnapshot:
# building pending fields. Done after sm.get_snapshot so we have
# the resolved state (focused_work / leisure / etc) to drive
# accumulator and transition logic.
self._tick_break_reminders(snap, now=ts)
if tick_followups:
self._tick_break_reminders(snap, now=ts)
if snap.state == 'private':
# Privacy lockdown — explicitly empty enrichment fields rather
# than splicing in caches built from earlier (non-private)
Expand All @@ -502,6 +519,19 @@ async def get_snapshot(self, *, now: float | None = None) -> ActivitySnapshot:
work_break_pending=None,
anti_slack_pending=None,
)
if not include_enrichment:
return dc_replace(
snap,
activity_scores={},
activity_guess='',
open_threads=[],
work_break_pending=(
self._build_work_break_pending() if tick_followups else None
),
anti_slack_pending=(
self._build_anti_slack_pending() if tick_followups else None
),
)
# Patch in emotion-tier enrichment caches. ``snap`` is a frozen
# dataclass; ``replace`` returns a new instance without mutating
# the original. Callers always get a self-consistent snapshot.
Expand All @@ -510,8 +540,12 @@ async def get_snapshot(self, *, now: float | None = None) -> ActivitySnapshot:
activity_scores=dict(self._activity_scores_cache),
activity_guess=self._activity_guess_cache,
open_threads=list(self._open_threads_cache),
work_break_pending=self._build_work_break_pending(),
anti_slack_pending=self._build_anti_slack_pending(),
work_break_pending=(
self._build_work_break_pending() if tick_followups else None
),
anti_slack_pending=(
self._build_anti_slack_pending() if tick_followups else None
),
)

def get_snapshot_sync(self, *, now: float | None = None) -> ActivitySnapshot:
Expand Down Expand Up @@ -1186,20 +1220,29 @@ def _snapshot_signals_for_llm(snap: ActivitySnapshot) -> dict:

# ── internals ──────────────────────────────────────────────

async def _ensure_collector_started(self) -> None:
if self._collector_started:
return
await self._collector.start()
self._collector_started = True
async def _ensure_collector_started(
self,
*,
start_activity_guess_loop: bool = True,
) -> None:
started_collector = False
if not self._collector_started:
await self._collector.start()
self._collector_started = True
started_collector = True
# Spin up the activity_guess background loop on first snapshot
# request. The loop self-throttles (state-signature dedup +
# anti-thrash interval), so starting it eagerly is cheap.
if self._activity_guess_loop_task is None:
started_guess_loop = False
if start_activity_guess_loop and self._activity_guess_loop_task is None:
self._activity_guess_loop_task = asyncio.create_task(
self._activity_guess_loop(),
name=f'activity_guess_loop_{self.lanlan_name}',
)
logger.info(
'[%s] UserActivityTracker started (shared system collector + guess loop)',
self.lanlan_name,
)
started_guess_loop = True
if not started_collector and not started_guess_loop:
return
features = 'shared system collector'
if started_guess_loop:
features += ' + guess loop'
logger.info('[%s] UserActivityTracker started (%s)', self.lanlan_name, features)
228 changes: 228 additions & 0 deletions plugin/plugins/deskpet/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
"""
桌面宠物插件 (DeskPet)

对标 RunCat365:每 3 秒采样 CPU → 公式算速度 → 状态上报。
只在 CPU 首次越过高阈值 / 首次回落时出声吐槽。
无状态机、无LLM、无Web UI —— 极简原则。
"""

from __future__ import annotations

import asyncio
import random
import time
from typing import Any

import psutil

from plugin.sdk.plugin import (
NekoPluginBase,
Ok,
lifecycle,
neko_plugin,
plugin_entry,
timer_interval,
)

# ── 常量(对标 RunCat 的硬编码设计,不暴露为配置) ──

_SAMPLE_INTERVAL_S = 3 # 对标 RunCat cpuTimer 的 3s
_PSUTIL_BLOCK_S = 0.5 # psutil 阻塞采样时长
_EMA_ALPHA = 0.3 # EMA 平滑系数
_STRESS_THRESHOLD_PCT = 80.0 # 吐槽触发阈值(%)
_COOLDOWN_S = 300 # 两次吐槽之间最短间隔(秒)
_CONFIRM_COUNT = 2 # 连续确认次数(防抖)

# ── 吐槽台词 ──(同 RunCat 的角色表现,这里用文字代替视觉动画)

_PHRASES_STRESSED = [
"CPU 好高喵!{}%,风扇在尖叫了 🔥",
"主人开太多程序了!CPU 已经 {}% 了!",
"{}%!再这样我要变成烤猫了 🔥🐱",
"电脑在冒烟...CPU {}%...救命 🆘",
"我的毛要焦了!CPU {}%!快关几个窗口!",
"{}% CPU!这不是演习!🔥",
"呜呜风扇好吵...CPU {}%了...",
]

_PHRASES_RELAXED = [
"呼...CPU 回到 {}% 了,得救了 🧊",
"终于凉快下来了,{}% 才正常嘛~",
"{}%!风扇安静了,舒服~ 😌",
"危机解除!CPU {}%,又可以摸鱼了 🐟",
]


def _pick(phrases: list[str], cpu: float) -> str:
return random.choice(phrases).format(int(cpu))


# ── RunCat 核心公式 ──

def runcat_speed_ms(cpu_percent: float) -> float:
"""RunCat365 的动画间隔公式。

interval = 200 / clamp(cpu/5, 1, 20)

CPU 0% → 200ms(最慢)
CPU 50% → 20ms
CPU 100% → 10ms(最快)
"""
intensity = max(1.0, min(20.0, cpu_percent / 5.0))
return 200.0 / intensity


# ── 插件主类 ──

@neko_plugin
class DeskPetPlugin(NekoPluginBase):
"""极简 CPU 宠物 — 对标 RunCat365 的核心循环。"""

def __init__(self, ctx: Any):
super().__init__(ctx)
self.logger = ctx.logger

# CPU 状态
self._smooth_cpu: float = 0.0
self._smooth_mem: float = 0.0
self._initialized: bool = False

# 吐槽控制
self._was_stressed: bool = False # 上一个周期是否处于高负载
self._last_phrase_at: float = 0.0 # 上次吐槽的 epoch 时间
self._stress_count: int = 0 # 连续高负载次数(防抖)
self._relax_count: int = 0 # 连续正常次数(防抖)

# ── 生命周期 ──

@lifecycle(id="startup")
async def startup(self, **_):
self.logger.info(
"DeskPet started — interval=%ss alpha=%s threshold=%s%% cooldown=%ss",
_SAMPLE_INTERVAL_S, _EMA_ALPHA, _STRESS_THRESHOLD_PCT, _COOLDOWN_S,
)
return Ok({"status": "running"})

@lifecycle(id="shutdown")
async def shutdown(self, **_):
self.logger.info("DeskPet stopped")
return Ok({"status": "stopped"})

@lifecycle(id="freeze")
async def freeze(self, **_):
self.logger.debug("DeskPet frozen")
return Ok({"status": "frozen"})

@lifecycle(id="unfreeze")
async def unfreeze(self, **_):
self.logger.debug("DeskPet unfrozen")
return Ok({"status": "running"})

# ── 核心轮询(对标 RunCat 的 ObserveCPUTick) ──

@timer_interval(
id="cpu_tick",
seconds=_SAMPLE_INTERVAL_S,
name="CPU采样",
description="周期性采样 CPU 使用率并更新状态",
auto_start=True,
)
async def on_cpu_tick(self, **_):
# 1. 采样 — 阻塞调用扔进线程池,避免卡住事件循环
raw_cpu, raw_mem = await asyncio.to_thread(
lambda: (
psutil.cpu_percent(interval=_PSUTIL_BLOCK_S),
psutil.virtual_memory().percent,
)
)

# 2. EMA 平滑
if not self._initialized:
self._smooth_cpu = float(raw_cpu)
self._smooth_mem = float(raw_mem)
self._initialized = True
else:
self._smooth_cpu = _EMA_ALPHA * raw_cpu + (1 - _EMA_ALPHA) * self._smooth_cpu
self._smooth_mem = _EMA_ALPHA * raw_mem + (1 - _EMA_ALPHA) * self._smooth_mem

# 3. RunCat 公式
speed_ms = runcat_speed_ms(self._smooth_cpu)

# 4. 状态上报(每 tick 都上报,无副作用)
self.report_status({
"cpu": round(self._smooth_cpu, 1),
"memory": round(self._smooth_mem, 1),
"cpu_raw": round(raw_cpu, 1),
"memory_raw": round(raw_mem, 1),
"speed_ms": round(speed_ms, 1),
"stressed": self._smooth_cpu >= _STRESS_THRESHOLD_PCT,
})

# 5. 只在关键时刻出声(防抖 + 冷却)
await self._maybe_speak(self._smooth_cpu)

# ── 吐槽逻辑 ──

async def _maybe_speak(self, cpu: float) -> None:
"""对标 RunCat 的视觉动画——我们用文字替代。

只在 CPU 首次突破阈值(连续确认)或首次回落时出声。
"""
now = time.time()

# --- 进入高负载 ---
if cpu >= _STRESS_THRESHOLD_PCT:
self._relax_count = 0
self._stress_count += 1

if (
self._stress_count >= _CONFIRM_COUNT
and not self._was_stressed
and (now - self._last_phrase_at) >= _COOLDOWN_S
):
phrase = _pick(_PHRASES_STRESSED, cpu)
self.ctx.push_message(
source="deskpet",
ai_behavior="respond",
visibility=[],
parts=[{"type": "text", "text": phrase}],
metadata={"cpu": round(cpu, 1), "mood": "stressed"},
)
self._was_stressed = True
self._last_phrase_at = now
self.logger.info("DeskPet speak(stressed): cpu=%.1f%%", cpu)
return

# --- 恢复正常 ---
self._stress_count = 0
self._relax_count += 1

if self._relax_count >= _CONFIRM_COUNT and self._was_stressed:
phrase = _pick(_PHRASES_RELAXED, cpu)
self.ctx.push_message(
source="deskpet",
ai_behavior="respond",
visibility=[],
parts=[{"type": "text", "text": phrase}],
metadata={"cpu": round(cpu, 1), "mood": "relaxed"},
)
self._was_stressed = False
self._last_phrase_at = now
self.logger.info("DeskPet speak(relaxed): cpu=%.1f%%", cpu)

# ── 手动查询入口 ──

@plugin_entry(
id="check_cpu",
name="查询CPU状态",
description="查询当前CPU使用率和宠物状态",
llm_result_fields=["cpu", "memory", "stressed", "speed_ms"],
)
async def check_cpu(self, **_):
speed_ms = runcat_speed_ms(self._smooth_cpu)
return Ok({
"cpu": round(self._smooth_cpu, 1),
"memory": round(self._smooth_mem, 1),
"stressed": self._smooth_cpu >= _STRESS_THRESHOLD_PCT,
"speed_ms": round(speed_ms, 1),
})
29 changes: 29 additions & 0 deletions plugin/plugins/deskpet/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[plugin]
id = "deskpet"
name = "桌面宠物"
description = "轻量 CPU 响应式桌面宠物 — 采样CPU占用率,高负载时出声吐槽。对标 RunCat365。"
short_description = "CPU一高就会吐槽的桌面猫"
keywords = ["deskpet", "CPU", "monitor", "pet", "猫", "桌面宠物", "系统监控"]
version = "0.1.0"
type = "plugin"
entry = "plugin.plugins.deskpet:DeskPetPlugin"

[plugin.author]
name = "N.E.K.O Team"

[plugin.sdk]
recommended = ">=0.1.0,<0.2.0"
supported = ">=0.1.0,<0.3.0"

[plugin.store]
enabled = false

[plugin.ui]
enabled = false

[plugin_runtime]
enabled = true
auto_start = true

[plugin_state]
backend = "memory"
Loading
Loading