Skip to content

Commit 06ef685

Browse files
wehosHongzhi Wenclaude
authored
feat(activity): 信任 + 控制 + 风格 follow-up(隐私 + override + 配置外置 + tone + 游戏二维度) (Project-N-E-K-O#1026)
* feat(activity): 信任 + 控制 + 风格 follow-up(隐私黑名单 + 用户 override + 配置外置 + tone modifier + 游戏二维度) PR Project-N-E-K-O#1015 落了 activity tracker 基础后的 follow-up。截图比对里"他们"那份设计的 5 项可借鉴差距一次性吃下: skip_probability:替代之前提的 silent propensity。derive_skip_probability 给 (state, intensity, genre) 派生概率默认值(competitive 0.3、immersive horror 0.3、其余 0),proactive_chat Phase 1 起点掷骰跳过;unfinished_thread 守卫 + propensity=closed 短路;用户可以从 preferences 调到 1.0 实现"完全静音"或 0.0 关闭概率门。 doc 重写:状态表加 private 行;新增 5 节(Skip probability / Tone modifier / Game intensity & genre / Privacy blacklist / Own-app exclusion / User overrides & externalized config)。 测试:tests/test_activity_tracker_followup.py 34 个用例,覆盖隐私分类(5 个参数化)/ 私密窗口脱敏 / 私密 vs voice / 私密 yields to away / own-app 透传 + GPU 抑制 / app override + 不可降级 / game override / 阈值外置 / 配置 validator 丢坏值 / tone 全表派生(16 参数化)/ skip 默认值 + override clamp + 特定 combo 优先级。lint clean。 关联 issue:N.E.K.O#1020(系统压力自请退出,改日单独做)、N.E.K.O#1023(跨平台 OS 信号 - Electron 推送方案,单独 PR B)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(activity): 9 条 review 反馈一并吞下(Codex 3 + CodeRabbit 4 + github-code-quality 2) Codex P1 — 隐私 + stale_returning 互动漏洞: 当用户从 away 回来时正好打开 KeePass,_classify_state 给出 'private' 但 get_snapshot 的 stale_returning 复盖逻辑会把 effective_state 改成 'stale_returning' → propensity 'greeting_window',proactive_chat 就 能跑了。给 stale_returning 复盖加 private 例外,配回归测试覆盖。 Codex P2 / CR Major — 偏好热重载: prefs 当年只在 ActivityStateMachine.__init__ 里抓一次,长会话看不到 user_preferences.json 的编辑(30s mtime cache 实质失效)。 UserActivityTracker 加 _refresh_prefs hook,每次 get_snapshot / get_snapshot_sync 起点拉一次(loader 自带 mtime cache,开销几乎为零), identity-compare 检测到 reload 就 swap 进 state_machine。 阈值仍冻结到 __init__(通常是一次性调优常量,热重载意义不大)。 配热重载测试。 Codex P2 / CR Major — 解析失败保留上次好配置: _load_from_file 现在返回 Optional[ActivityPreferences]: - None → 读/解析失败(文件正在被编辑、JSON 半成品等) - ActivityPreferences() → 文件 OK 但没 activity 段 - 实例 → 正常解析 调用方 get_activity_preferences 看到 None 就保留 _cache.prefs 不动, 只 bump fetched_at 防 retry 风暴。配两条 loader 测试(坏文件保留 + 正常文件无 activity 段返回默认)。 CR Major — `NEKO` 别名太泛: word-boundary 匹配下 'NEKO' 会撞 "Neko Atsume" 等无关标题,整条 追踪链就走 own_app 特殊分支了。改成只保留点号形式 'N.E.K.O' / 'Project N.E.K.O',差异化够强不会误伤。 CR Critical — closed propensity 仍把状态信息渲染进 prompt: format_activity_state_section 之前只跳了 tone 行,state 名 / reason templates / 时间 / unfinished_thread / enrichment 都还在渲染。 proactive_chat 已经在更上层 short-circuit 了 closed propensity,但 其它消费者(debug logging / future side panels)可能透传。 Defense in depth:propensity == 'closed' 直接返回空串,谁也别想看到 "用户在 KeePass 里" 这件事的任何蛛丝马迹。 CR Major — _VALID_CATEGORIES 允许用户 override 到 private/own_app: state_machine 把 private/own_app 定义成"只能来自静态 keyword DB"的 特例,但 loader 这边却把它们列入了 _VALID_CATEGORIES。结果是用户写 ``{"MyApp.exe": {"category": "private"}}`` 在 loader 端通过、但在 state_machine 端因为 static result 是 'unknown' 不算 locked、override 会真的让 MyApp 跑 private 流程——绕过了 privacy 的"只信静态库"承诺。 从 _VALID_CATEGORIES 里移除 private 和 own_app,loader 直接拒收。 CR Minor — docstring 示例 JSON shape 写反: 写成了 ``{"model_path": ..., "activity": ...}`` 单对象,但 loader 和 user_preferences.json 实际是 list of dict 形态(line 245 处理 ``isinstance(data, list)``)。改成数组示例 + 说明顶层结构。 github-code-quality — _cached_* "unused global" 误报: 4 个 module-level globals 改成单个 _CacheState 类的属性写入。这样 CodeQL 的"intra-function 流分析"看到的就是 attribute write 而非 global rebind,不再触发 unused-global 规则。状态封装也更显式。 invalidate_activity_preferences_cache 走 reset_metadata() 方法。 github-code-quality — tests/...followup.py:24 unused import: 之前删掉了用 ActivitySnapshot 的测试但没清 import。删掉。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(activity): 统一 utils.activity_config 的 import 风格 github-code-quality 在 tests/test_activity_tracker_followup.py:496 报 "Module is imported with 'import' and 'import from'"——文件顶部走的是 ``from utils.activity_config import ...``,但 `test_tracker_picks_up_fresh_prefs_via_refresh_hook` 内部又写了 ``import utils.activity_config as ac_mod`` 来访问 `_cache`。 把 `_cache` 加进顶部的 from-import 列表,函数体里直接用就行,删掉 inner 的 `import as` 语句。混合 import 风格警告消除,行为不变。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(activity): 4 条 round-2 review 反馈(CR) CR Major — update_window 折叠没把 intensity/genre 算进去: 当用户在游戏内热改 user_game_overrides 时,新 observation 的 category/subcategory/canonical 完全相同,只有 intensity/genre 变了。原来的 same-check 看不出区别,会把它折叠掉,propensity / skip_probability / tone 用旧值直到玩家切换前台。same-check 加 prev.intensity / prev.genre 比较,hot reload 即时生效。 CR Major — mark_unfinished_thread_used 还在读 module 常量: threshold 已经穿到 self._unfinished_thread_max_followups 了, 但这一处漏改,还在和 UNFINISHED_THREAD_MAX_FOLLOWUPS 比较。 结果用户把 max_followups 调到 3 时还是 2 次后被清掉,调到 1 时 也不会立刻释放。改成 self._unfinished_thread_max_followups。 CR Major — own_app 早退导致 dwell 漏算/虚增: 早退后上一条 _current_window 继续被当成前台,dwell 时钟还在跑。 用户在 N.E.K.O 待 30s 再回 VS Code 时,dwell 凭空多 30s,可能 把"短暂使用"误抬成 focused_work。加 _own_app_freeze_started_at 字段:进入 own_app 时记录 freeze 起点,下一次非 own_app observation 时把 _current_window_started_at 前推一段(own_app 停留时长),dwell 只算非 own_app 时间。 CR Minor — canonical 缺省没真正回退到 override key: _AppOverride 的 docstring 说"canonical 缺省时回退到 override key",但 loader 这里实际存了 None,下游再各自 fallback——其中 user_title_overrides 的 fallback 是完整 window_title,和"override key(substring)"不一致。loader 端直接补:canon 缺省时存 k 原 case,contract 一致化。 新增 4 条回归测试: - test_update_window_collapses_on_canonical_but_invalidates_on_intensity_change - test_mark_unfinished_thread_used_honors_threshold_override - test_own_app_freezes_dwell_timer_on_previous_window - test_loader_canonical_falls_back_to_override_key 42/42 通过,ruff clean。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(activity): 删掉未使用的 _CacheState import CR 在 tests/test_activity_tracker_followup.py:411 报 \`_CacheState\` 没用到—— 之前写测试时随手 import,后来改成只通过 \`_cache.prefs\` 直接 mutate 不需要这个 class symbol。删掉。 行为不变,42 测试全过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(activity): own_app dwell freeze 测试加 snap_post 状态断言 github-code-quality 报 \`snap_post\` 是 unused local variable。这次没删 变量、改成补一条更强的断言:在 dwell 数值断言之外,再用 state-machine API 层面验证一遍——brief own_app 走神 45s 后回 VS Code,state 必须**不** 是 focused_work。这条断言比"dwell < 90"语义更直白,明确把 dwell freeze 的目的("防止 brief glance 把 focused_work 提前触发")固定下来。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(activity): 2 条 round-3 review 反馈(CR) CR Major — user_app_overrides 缺 unknown 门槛: 之前只 gate 了 user_title_overrides(result.category == 'unknown'), app 路径没加。结果用户在 user_app_overrides 写的条目能强行改写已经 被静态 DB 稳定识别的窗口(比如把 Code.exe 改成 entertainment), 和 title 路径行为不对称、和 doc 也不一致。统一加上 unknown 门槛—— override 只填补静态 DB 漏掉的,不改写已有结果。privacy/own_app 仍 然受 static_locked 保护不变。doc 同步更新:"additive, not overriding"。 CR Minor — high_gpu reason 还在硬编码 60: state classifier 已经走 self._gaming_gpu_threshold_percent,但 _build_propensity_reasons 里 high_gpu 还在比 60。用户把阈值调到 40 时 reason 漏报;调到 85 时 reason 误报,prompt 解释和 state 决策对 不上。改成读 self._gaming_gpu_threshold_percent。CPU 阈值(70)保持 硬编码——没有 per-instance CPU threshold(CPU 永远只是补充上下文, 不 gate state),保持现状。 新增 2 条回归测试: - test_user_app_override_does_not_rewrite_stable_static_classification: Code.exe(work/ide 静态命中)+ user override (entertainment) → 仍是 work - test_high_gpu_reason_uses_threshold_override: 阈值=85 时 GPU=70 既不触发 gaming-by-GPU 也不触发 high_gpu reason; GPU=90 时两者都触发。 44/44 通过,ruff clean。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(activity): own_app GPU fallback 测试语义改正 CR 在 tests/test_activity_tracker_followup.py:169 指出 \`test_own_app_suppresses_gpu_fallback_gaming\` 把 own_app 的语义钉反了: 原测试断言 "own_app 前台 ⇒ state != gaming",然后用空白起点(没 prev window)跑,靠 \`win is None\` 落进 idle。本质上测的是"没有任何 observation 时 state 是 idle",跟 own_app 没关系。 own_app 的真正契约是:冻结 dwell + 不替换 prev 窗口。GPU fallback 本身**不**应该被 own_app 短路——如果用户后台正跑一个未识别的高 GPU 小游戏(unknown 类别 + GPU fallback gaming),brief 切到猫娘瞄一眼 时 state 还应该是 'gaming',因为用户'真实活动'就是后台那个游戏。 把测试改名 \`test_own_app_preserves_previous_window_for_gpu_fallback\` 并改写: Step 1:先建立 prev = unknown indie game + GPU=85%,验证 GPU fallback 触发 state='gaming'。 Step 2:再切到 N.E.K.O 前台,验证 active_window.process_name 还是 'IndieGame.exe'(不被替换)+ category 还是 'unknown' + state 还是 'gaming'(GPU fallback 在 prev 上继续生效)。 把 own_app 的真正语义从两条互补角度钉死——既不替换 prev observation, 也不抑制其分类。配合已有的 \`test_own_app_freezes_dwell_timer_on_previous_window\` (dwell 冻结),own_app 行为契约完整。 44/44 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(activity): 6 条 round-5 review 反馈(CR) CR Major — 别名 `英雄` / `REPO` 太泛: Apex Legends 旧 alias `'英雄'` 是纯 CJK 子串匹配,会撞任何含「英雄」二字 的标题(魔兽世界·英雄之路 / 新闻 / 博客)。`REPO` 经 _make_needle 编成 独立单词匹配,会撞 `repo - Visual Studio Code` 这种工作标题。两条都删, 各加一行注释说明为什么不能再加回来。 CR Minor x3 — own_app 注释三处写反: ActivityCategory 枚举注释 / OWN_APP_TITLE_KEYWORDS 块顶部注释 / _build_title_table docstring 都写成了"前一个窗口继续累计 dwell"和 "GPU fallback 被压掉",但实际契约是"进入 own_app 时记录 freeze 起点、 退出时把 _current_window_started_at 前推抵消、GPU fallback 不被短路"。 跟 _own_app_freeze_started_at 实现对齐重写三处。 CR Minor — 配置路径写错: snapshot.py 两处注释 + 设计 doc 一处都把路径写成 `activity_preferences.json::skip_probability_overrides`,但实际加载 的是 `user_preferences.json::__global_conversation__::activity::*`。 按错路径改文件就石沉大海。三处全部改正确。 CR Minor — user_app_overrides doc 没说"只补 unknown": 设计 doc 之前只强调"不能降级 private/own_app",没写出实际更严格的 contract:"只对 unknown 补漏、不改写已有静态分类"。补充 additive semantics 说明 + 把 user_game_overrides 的"唯一例外"特性也写清楚。 CR Minor — high_gpu reason 边界 `>` vs `>=` 不一致: classifier 在 line 809 用 `>= self._gaming_gpu_threshold_percent`, reason emitter 在 line 927 用 `> self._gaming_gpu_threshold_percent`。 GPU 恰好等于阈值时会判 gaming-by-GPU 但不报 high_gpu reason,prompt 解释和实际判定打架。改成 `>=` 对齐。 44/44 通过,ruff clean。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(activity): canonical 用家族名而非带年份的版本号 CR Minor — 三个游戏家族条目的 canonical 是带年份的版本号,但其它年份的 版本被并到同一别名列表里。结果是:用户想用 user_game_overrides 单独覆盖某个年份(比如「F1 25 我玩 casual」),按 result.canonical 查 键时找不到 — 因为 canonical 永远是 'F1 24' / 'NBA 2K25' / 'Battlefield 2042',用户写 'F1 25' / 'NBA 2K24' 之类的键完全 dead。 把三处都改成家族名作为 canonical: ('F1 24', ['F1 24', 'F1 25', 'F1 23'], ...) → ('F1', ['F1 25', 'F1 24', 'F1 23'], ...) ('NBA 2K25', ['NBA 2K25', 'NBA 2K24', 'NBA 2K23', 'NBA 2K'], ...) → ('NBA 2K', ['NBA 2K25', 'NBA 2K24', 'NBA 2K23', 'NBA 2K'], ...) ('Battlefield 2042', ['Battlefield 2042', 'Battlefield V', 'Battlefield 1', ...], ...) → ('Battlefield', ['Battlefield 2042', 'Battlefield V', 'Battlefield 1', ...], ...) EA Sports FC 已经是家族名('EA Sports FC' canonical 配 FIFA / FC 24/25 别名),不动。 44/44 通过,ruff clean。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(activity): 删掉两条 unreachable 重复 game keyword 条目 CR Minor — \`('Genshin', ['Genshin'], 'casual', 'rpg')\` 和后面那条 \`('Rocket League', ['Rocket League'], 'competitive', 'sports')\` 都是 unreachable dead code: - 'Genshin' 别名已经在 line 317 的 'Genshin Impact' 条目里 (\`['Genshin Impact', 'Genshin', '原神', '원신']\`) - 'Rocket League' 已经在 line 474 的 Western multiplayer 段 (含 zh/jp/ko 别名) \`_build_title_table\` 是首匹配(first-match wins),后面的重复条目 永远命中不到,徒增表大小。两条都删,加注释说明为什么不能再加回来。 行为不变:smoke test 验证 'Genshin' 标题仍归到 canonical 'Genshin Impact'、'Rocket League' 标题仍归到 canonical 'Rocket League'。 44/44 通过,ruff clean。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(activity): browser-tab title hit on private 关键词降级到 unknown CR Major — \`PRIVATE_TITLE_KEYWORDS\` 在浏览器 title fallback 路径上 误命中:用户访问 \`bitwarden.com/pricing\` / KeePass 文档 / 1Password 对比博客 / HN 上吐槽 Vaultwarden 的帖子,标题里都有 'Bitwarden'/'KeePass'/ '1Password'/'Vaultwarden' 字样,会被高优先级一刀切到 \`private\`, 直接关掉本应正常工作的 enrichment + proactive chat。 修法:\`observation_from_system\` 里浏览器路径的 title fallback 后加一道 guard——如果 fallback 命中了 \`private\`,降级回 \`unknown\`。 原生 private 应用还是走 \`PRIVATE_PROCESS_NAMES\` 的 process-name 匹配 (KeePass.exe / 1Password.exe 等),不受影响。 Tradeoff: Vaultwarden 这种自托管 web vault 通过浏览器访问就不再 触发 lockdown 了。理论上想覆盖这种场景需要扩 domain 表(vaultwarden.\* / bitwarden.com/vault 等具体路径),现在没必要——browser-context 误判 比 niche 场景的漏报代价更高。如果将来用户反馈这个 niche,单独再开 PR。 测试同步调整: - \`test_privacy_classification_emits_private_state\` 把 Vaultwarden 浏览器 case 移除(现在该用例属于"不该 lock"了)。 - 新增 \`test_private_title_in_browser_does_not_trigger_lockdown\`, 4 个 parametrize 用例覆盖 marketing / docs / blog / HN 四种典型 浏览器 tab 场景,全部断言 state != 'private'。 47/47 通过,ruff clean。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(activity): 3 条 round-8 review 反馈(CR) CR Major — count 阈值 int() 静默截断: ActivityStateMachine.__init__ 给 \`window_switch_transition_threshold\` 和 \`unfinished_thread_max_followups\` 直接 \`int(prefs.thresholds.get(...))\`, 会把 \`0.9\` 静默变成 0、\`1.7\` 变成 1。loader 那边 \`_parse_thresholds\` 只验证"正数",浮点放行。结果用户写 \`max_followups: 1.7\` 想表达"约 2 次" 会得到提前 1 次封顶。 抽 \`_int_threshold(name, default)\` helper:用 \`float(raw).is_integer()\` 判断是否真的是整数值(接受 3.0 这种),不是就回退默认值;负数 / 零也 当无效(\`>= 1\` 才接受)。同时把 bool 显式排除(虽然 loader 已经过滤, 但 instance attr 这边再守一道)。两处 instance attr 都走 helper。 CR Minor — \`test_loader_keeps_last_good_prefs_on_parse_failure\` 没真 钉契约: 原版只断言 \`_load_from_file()\` 在坏 JSON 上返回 None,但 \`_cache.prefs\` 在外层 \`get_activity_preferences()\` 失败时是否真的保留旧值, 没经过测试。如果有人未来"helpfully"在 parse 失败时把 cache 重置成 defaults,这条测试还会绿。 补 round 3:monkeypatch \`_resolve_preferences_path\` 指向 tmp 文件、 invalidate cache、写好文件 + 公开 API 装填一份 cached_good, 再写坏文件 + invalidate + 公开 API 拉一次,断言 cache 还是 cached_good 的字段(thresholds + user_app_overrides)。 CR Minor — \`test_tracker_picks_up_fresh_prefs_via_refresh_hook\` 绕过 公开入口: 原版只调 \`tracker._refresh_prefs()\`,钉不住"\`get_snapshot_sync\` / \`get_snapshot\` 入口处必须调用 _refresh_prefs"这个公开契约。 补一段:\`tracker._sm._prefs = sentinel_prefs\` 把 instance prefs 换掉,loader cache 保持 new_prefs;调一次 \`get_snapshot_sync()\`, 断言 \`tracker._sm._prefs is new_prefs\`。如果未来有人把 hook 调用从 公开入口删掉,这条 assertion 立刻爆。 外加:补一条 \`test_count_thresholds_reject_non_integer_floats\` 直接钉 \`_int_threshold\` 行为——0.9/1.7 回退默认;7.0/3.0(whole floats)接受。 48/48 通过,ruff clean。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(activity): unify _apply_user_overrides docstring on additive rule Round-9 CR feedback: the prior docstring led with "App / title overrides REPLACE the keyword classification (user wins over static DB)" and then contradicted itself with "Override priority is additive ... only fire when static keyword DB returned unknown". The first paragraph was stale phrasing from before the round-7 additive switch. Drop the contradictory "REPLACE / user wins" paragraph and keep one unambiguous description: app/title overrides are additive (only fire on unknown), private/own_app are locked, game intensity/genre is the lone patch-on-top exception. No behavior change — _apply_user_overrides itself already implements the additive rule (line 237 / 256 ``result.category == 'unknown'`` guards). <review_comment_addressed> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(activity): pin async hot-reload + clean up stale override comment Round-9 CR nitpicks (2): 1. tests/test_activity_tracker_followup.py:303-309 The comment in `test_user_app_override_cannot_unmask_private` was leftover from before the round-7 additive switch — described the behavior as "depending on implementation order this may be the inverse" and pinned "the safer ordering". The actual contract is now explicit: app/title overrides are additive-only (only fire on `result.category == 'unknown'`) and `private` is `static_locked`. Rewrote the comment to match. 2. tests/test_activity_tracker_followup.py:642-699 The hot-reload regression only covered `get_snapshot_sync()`. If a future refactor stripped `_refresh_prefs()` from the async public entry, live sessions would silently lose hot-reload while the test stayed green. Added a parallel async assertion via `asyncio.run(tracker.get_snapshot())` (with sentinel reset between sync/async halves) — both public entries now contractually pinned to call `_refresh_prefs`. 48 tests passing. <review_comment_addressed> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b7735e0 commit 06ef685

9 files changed

Lines changed: 2770 additions & 191 deletions

File tree

config/activity_keywords.py

Lines changed: 361 additions & 134 deletions
Large diffs are not rendered by default.

config/prompts_activity.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@
274274
'voice_engaged': '语音对话中',
275275
'idle': '空闲',
276276
'transitioning': '切换状态中',
277+
'private': '隐私应用前台',
277278
},
278279
'en': {
279280
'away': 'away',
@@ -285,6 +286,7 @@
285286
'voice_engaged': 'voice conversation',
286287
'idle': 'idle',
287288
'transitioning': 'transitioning',
289+
'private': 'private app foreground',
288290
},
289291
'ja': {
290292
'away': '離席',
@@ -296,6 +298,7 @@
296298
'voice_engaged': 'ボイス会話中',
297299
'idle': 'アイドル',
298300
'transitioning': '状態切替中',
301+
'private': 'プライベートアプリ前面',
299302
},
300303
'ko': {
301304
'away': '자리 비움',
@@ -307,6 +310,7 @@
307310
'voice_engaged': '음성 대화 중',
308311
'idle': '유휴',
309312
'transitioning': '상태 전환 중',
313+
'private': '비공개 앱 전면',
310314
},
311315
'ru': {
312316
'away': 'отсутствует',
@@ -318,6 +322,70 @@
318322
'voice_engaged': 'голосовая беседа',
319323
'idle': 'простой',
320324
'transitioning': 'смена контекста',
325+
'private': 'приватное приложение в фокусе',
326+
},
327+
}
328+
329+
330+
# ── Tone hints (single-line style modifier) ─────────────────────────
331+
#
332+
# Tone is orthogonal to propensity: propensity decides *what kind of
333+
# source* the AI may draw from, tone decides *how to deliver it*. The
334+
# Phase 2 prompt renders tone as one extra line:
335+
#
336+
# 口吻:短句优先,不延展话题,避免动作描写
337+
#
338+
# Tones and when they fire (see ``derive_tone`` in
339+
# ``main_logic/activity/snapshot.py`` for the full table):
340+
#
341+
# * ``terse`` — competitive games, rhythm games
342+
# * ``hushed`` — immersive horror games
343+
# * ``mellow`` — immersive RPG / story-driven games
344+
# * ``playful`` — casual gaming, casual_browsing
345+
# * ``warm`` — voice / chatting / stale_returning
346+
# * ``concise`` — focused_work / idle / default (rendered nothing —
347+
# format_activity_state_section skips when concise to
348+
# save a line in the common case)
349+
ACTIVITY_TONE_HINTS: dict[str, dict[str, str]] = {
350+
'zh': {
351+
'terse': '短句优先,不延展话题,避免动作描写',
352+
'hushed': '轻声细语,配合氛围克制说话',
353+
'mellow': '慢节奏放松陪伴,不丢专业术语进来',
354+
'playful': '闲适带点小俏皮,可以开玩笑',
355+
'warm': '自然对话,回应感强',
356+
'concise': '不啰嗦,专业克制',
357+
},
358+
'en': {
359+
'terse': 'short sentences first; do not extend topics; avoid action narration',
360+
'hushed': 'soft and quiet, restrained to match the atmosphere',
361+
'mellow': 'slow-paced, relaxed companionship; no jargon dumps',
362+
'playful': 'easygoing with a touch of mischief; jokes welcome',
363+
'warm': 'natural conversation, responsive in tone',
364+
'concise': 'no fluff, professional and restrained',
365+
},
366+
'ja': {
367+
'terse': '短文優先・話題を広げない・動作描写を避ける',
368+
'hushed': '小声で控えめに、雰囲気に合わせて',
369+
'mellow': 'ゆったりした寄り添い、専門用語は出さない',
370+
'playful': 'のんびりしつつ少し茶目っ気、冗談 OK',
371+
'warm': '自然な会話、反応性高め',
372+
'concise': '冗長なし、控えめでプロフェッショナル',
373+
},
374+
'ko': {
375+
'terse': '짧은 문장 우선, 화제 확장 금지, 동작 묘사 자제',
376+
'hushed': '낮은 목소리로, 분위기에 맞게 절제',
377+
'mellow': '느긋한 동행, 전문 용어는 자제',
378+
'playful': '편안하게 약간 장난스럽게, 농담도 OK',
379+
'warm': '자연스러운 대화, 반응성 높게',
380+
'concise': '군더더기 없이, 절제된 전문성',
381+
},
382+
'ru': {
383+
'terse': 'короткие фразы; не расширять тему; без описаний действий',
384+
'hushed': 'тихо и сдержанно, в тон атмосфере',
385+
'mellow': 'неспешное сопровождение, без жаргона',
386+
'playful': 'непринуждённо и слегка игриво, шутки уместны',
387+
'warm': 'естественный разговор, отзывчивая интонация',
388+
'concise': 'без воды, сдержанно и профессионально',
321389
},
322390
}
323391

@@ -395,6 +463,7 @@
395463
'state_chatting': '前台聊天:{app}',
396464
'state_transitioning': '近期窗口频繁切换',
397465
'state_idle': '在电脑前但无明显任务',
466+
'state_private': '前台是隐私应用——不分类、不缓存',
398467
'high_cpu': 'CPU 30s 均值 {cpu_percent}%',
399468
'high_gpu': 'GPU 利用率 {gpu_percent}%',
400469
'gaming_by_gpu': 'GPU 持续高负载(怀疑未识别的游戏)',
@@ -409,6 +478,7 @@
409478
'state_chatting': 'foreground chat: {app}',
410479
'state_transitioning': 'rapid window switching recently',
411480
'state_idle': 'at the computer but no clear task',
481+
'state_private': 'private app in foreground — not classifying / caching',
412482
'high_cpu': 'CPU 30s avg {cpu_percent}%',
413483
'high_gpu': 'GPU utilization {gpu_percent}%',
414484
'gaming_by_gpu': 'sustained high GPU (likely unrecognized game)',
@@ -423,6 +493,7 @@
423493
'state_chatting': 'フォアグラウンドチャット:{app}',
424494
'state_transitioning': '最近のウィンドウ切替が頻繁',
425495
'state_idle': 'PC前にいるが明確な作業なし',
496+
'state_private': 'プライベートアプリ前面——分類もキャッシュもしない',
426497
'high_cpu': 'CPU 30秒平均 {cpu_percent}%',
427498
'high_gpu': 'GPU 使用率 {gpu_percent}%',
428499
'gaming_by_gpu': 'GPU 高負荷継続(未識別のゲームの可能性)',
@@ -437,6 +508,7 @@
437508
'state_chatting': '전경 채팅: {app}',
438509
'state_transitioning': '최근 창 전환 빈번',
439510
'state_idle': 'PC 앞에 있으나 명확한 작업 없음',
511+
'state_private': '비공개 앱 전면 — 분류/캐시하지 않음',
440512
'high_cpu': 'CPU 30초 평균 {cpu_percent}%',
441513
'high_gpu': 'GPU 사용률 {gpu_percent}%',
442514
'gaming_by_gpu': 'GPU 고부하 지속 (미식별 게임 의심)',
@@ -451,6 +523,7 @@
451523
'state_chatting': 'переписка на переднем плане: {app}',
452524
'state_transitioning': 'недавно частая смена окон',
453525
'state_idle': 'за компьютером без явной задачи',
526+
'state_private': 'приватное приложение в фокусе — не классифицируем / не кэшируем',
454527
'high_cpu': 'CPU средн. 30с {cpu_percent}%',
455528
'high_gpu': 'загрузка GPU {gpu_percent}%',
456529
'gaming_by_gpu': 'устойчиво высокая GPU (вероятно нераспознанная игра)',
@@ -497,6 +570,7 @@
497570
'activity_scores_label': '评估',
498571
'activity_guess_label': '叙述',
499572
'open_threads_label': '开放话题',
573+
'tone_label': '口吻',
500574
'time_user_ai_fmt': '{time} | 用户 {user} | AI {ai}',
501575
'time_user_only_fmt': '{time} | 用户 {user}',
502576
'time_only_fmt': '{time}',
@@ -517,6 +591,7 @@
517591
'activity_scores_label': 'scores',
518592
'activity_guess_label': 'narrative',
519593
'open_threads_label': 'open threads',
594+
'tone_label': 'tone',
520595
'time_user_ai_fmt': '{time} | user msg {user} ago | AI {ai} ago',
521596
'time_user_only_fmt': '{time} | user msg {user} ago',
522597
'time_only_fmt': '{time}',
@@ -537,6 +612,7 @@
537612
'activity_scores_label': '評価',
538613
'activity_guess_label': '叙述',
539614
'open_threads_label': '保留話題',
615+
'tone_label': '口調',
540616
'time_user_ai_fmt': '{time} | ユーザー {user} | AI {ai}',
541617
'time_user_only_fmt': '{time} | ユーザー {user}',
542618
'time_only_fmt': '{time}',
@@ -557,6 +633,7 @@
557633
'activity_scores_label': '평가',
558634
'activity_guess_label': '서술',
559635
'open_threads_label': '보류 화제',
636+
'tone_label': '말투',
560637
'time_user_ai_fmt': '{time} | 사용자 {user} | AI {ai}',
561638
'time_user_only_fmt': '{time} | 사용자 {user}',
562639
'time_only_fmt': '{time}',
@@ -577,6 +654,7 @@
577654
'activity_scores_label': 'оценки',
578655
'activity_guess_label': 'описание',
579656
'open_threads_label': 'открытые нити',
657+
'tone_label': 'тон',
580658
'time_user_ai_fmt': '{time} | польз. {user} назад | AI {ai} назад',
581659
'time_user_only_fmt': '{time} | польз. {user} назад',
582660
'time_only_fmt': '{time}',

docs/design/user-activity-tracker.md

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ for all fields.
7171
|---|---|---|---|
7272
| `away` | System idle ≥ 15 min | `open` | Normal proactive — frontend backoff handles frequency |
7373
| `stale_returning` | Just back from `away` (≤ 60s window) | `greeting_window` | Encourage greeting, allow 1d+ reminiscence |
74-
| `gaming` | Game window in foreground (subcategory='game') | `restricted_screen_only` | Only screen-derived chatter; no externals, no reminisce |
74+
| `private` | Sensitive app (password mgr / banking / wallet) foreground | `closed` | Hard skip — no LLM, no enrichment, no buffer caching |
75+
| `gaming` | Game window in foreground (subcategory='game') | `restricted_screen_only` *or* `open` (casual intensity) | Intensity / genre refines further (see "Game intensity & genre" below) |
7576
| `focused_work` | Work window + ≥ 90s dwell + recent input | `restricted_screen_only` | Same as gaming |
7677
| `casual_browsing` | Entertainment window + ≥ 30s dwell | `open` | Encourage external material |
7778
| `chatting` | Communication app in foreground | `open` | Allow externals, careful with screen comments |
@@ -85,18 +86,193 @@ the existing frontend backoff curve in `static/app-proactive.js`),
8586
not "don't speak". The greeting machinery in `core.py:trigger_greeting`
8687
uses a separate path on first reconnect.
8788

89+
The `own_app` keyword category (catgirl app foreground) is handled at
90+
the observation layer — see "Own-app exclusion" below. It never
91+
produces a state, just a no-op tick.
92+
8893
## Propensity directives (what Phase 2 sees)
8994

9095
| Propensity | Allowed channels | Recommended emphasis |
9196
|---|---|---|
92-
| `closed` | (reserved; no longer emitted) | |
97+
| `closed` | None — hard skip | Used only by `private` state; proactive Phase 1 short-circuits before any LLM call |
9398
| `restricted_screen_only` | Screen only | Avoid duplication with last 1h; no externals; no reminiscence |
9499
| `open` | All channels | Reminiscence and externals both available |
95100
| `greeting_window` | All channels | Encourage gentle greeting + 1d+ reminiscence |
96101

97102
Phase 2 prompt rewrites map these directives into language directives
98103
(see `config/prompts_proactive.py` for the post-revision prompt).
99104

105+
## Skip probability (probabilistic gate, distinct from propensity)
106+
107+
`ActivitySnapshot.skip_probability` is rolled at proactive Phase 1 entry
108+
*before any other gating* — if `random() < skip_probability` and there's
109+
no unfinished thread to follow up on, the round is skipped entirely
110+
(no LLM, no source fetch, no prompt assembly). Default 0 means "always
111+
proceed".
112+
113+
Defaults are derived from `(state, intensity, genre)` in
114+
`derive_skip_probability()`:
115+
116+
| Combo | Default skip |
117+
|---|---|
118+
| `gaming + competitive` (any genre) | 0.3 |
119+
| `gaming + immersive + horror` | 0.3 |
120+
| `gaming + immersive` (other genre) | 0.0 |
121+
| `gaming + casual` | 0.0 |
122+
| `gaming + varied` / untagged | 0.0 |
123+
| Non-gaming states | 0.0 |
124+
125+
User overrides via `user_preferences.json`'s
126+
`__global_conversation__::activity::skip_probability_overrides` take
127+
precedence — set `1.0` for "fully silent during this combo" or `0.0`
128+
to disable. Keys are intensity-only (`competitive`) or intensity_genre
129+
joined with underscore (`immersive_horror`).
130+
131+
The `unfinished_thread` guard means an open AI question still gets its
132+
follow-up window even at `skip_probability=1.0`. Promise-keeping trumps
133+
silence; the existing 2-followup hard cap prevents harassment.
134+
135+
## Tone modifier (style hint, orthogonal to propensity)
136+
137+
`ActivitySnapshot.tone` is a single-axis style hint that controls *how*
138+
the AI delivers its message. Six tones, derived in `derive_tone()`:
139+
140+
| Tone | When | Prompt hint (zh) |
141+
|---|---|---|
142+
| `terse` | competitive games / rhythm | "短句优先,不延展话题,避免动作描写" |
143+
| `hushed` | immersive horror | "轻声细语,配合氛围克制说话" |
144+
| `mellow` | immersive RPG / story | "慢节奏放松陪伴,不丢专业术语进来" |
145+
| `playful` | casual gaming / casual_browsing | "闲适带点小俏皮,可以开玩笑" |
146+
| `warm` | voice / chatting / stale_returning | "自然对话,回应感强" |
147+
| `concise` | focused_work / idle / default | "不啰嗦,专业克制" |
148+
149+
Rendered by `format_activity_state_section` as one extra line:
150+
151+
```
152+
口吻:短句优先,不延展话题,避免动作描写
153+
```
154+
155+
`concise` (the safe fallback) and any tone under `propensity=closed`
156+
are NOT rendered — saves a token line in the common case.
157+
158+
`silent` is intentionally not a tone. Silencing the AI is the
159+
`skip_probability` mechanism's job; conflating "voice" with "presence"
160+
muddies both axes.
161+
162+
## Game intensity & genre
163+
164+
Game-keyword rows in `config/activity_keywords.py::GAME_TITLE_KEYWORDS`
165+
support two shapes:
166+
167+
```python
168+
# Legacy 2-tuple (untagged — falls through to varied/None)
169+
('Some Indie Game', ['Some Indie Game', 'SIG'])
170+
171+
# New 4-tuple (tagged)
172+
('League of Legends', ['LoL', '英雄联盟'], 'competitive', 'moba')
173+
```
174+
175+
`intensity` (`competitive` / `casual` / `immersive` / `varied`) drives
176+
propensity + skip_probability. `genre` (`fps` / `moba` / `rpg` / `sim` /
177+
`horror` / `racing` / `rhythm` / `strategy` / `sports` / `party` /
178+
`action` / `misc`) refines tone — the only genre-specific branch is
179+
`horror` triggering the `hushed` tone.
180+
181+
User overrides (`user_game_overrides` in preferences) patch
182+
intensity/genre on top of static-DB classification by canonical name —
183+
useful for "I'm playing Elden Ring chill, not sweaty" style flips.
184+
185+
The retag is incremental: top ~70 well-known games are tagged at the
186+
moment this doc was written; long-tail entries stay 2-tuple and behave
187+
identically to PR #1015's single `gaming → restricted_screen_only`
188+
bucket.
189+
190+
## Privacy blacklist
191+
192+
`PRIVATE_TITLE_KEYWORDS` and `PRIVATE_PROCESS_NAMES` in
193+
`config/activity_keywords.py` list password managers (KeePass /
194+
1Password / Bitwarden / etc), authenticator apps, and crypto wallets.
195+
A match emits `state='private', propensity='closed'`. The state
196+
machine sanitizes the observation (clears title + process_name from the
197+
snapshot's `active_window`) and the tracker bypasses LLM enrichment +
198+
suppresses background `activity_guess` ticks while in this state. Net
199+
effect: sensitive context never reaches the prompt or any model API.
200+
201+
User app/title overrides cannot demote a static-DB privacy hit.
202+
Game intensity/genre overrides still apply (no privacy implication).
203+
204+
## Own-app exclusion
205+
206+
`OWN_APP_TITLE_KEYWORDS` and `OWN_APP_PROCESS_NAMES` cover the catgirl
207+
app's own windows (`projectneko_server.exe`, `Xiao8.exe`,
208+
`lanlan_frd.exe`, plus titles `N.E.K.O` / `Xiao8` / `小八` / `Project
209+
N.E.K.O`). When the catgirl app is foreground, the tracker treats the
210+
tick as "no fresh window data" — observation is dropped, dwell timer
211+
freezes, GPU fallback gaming doesn't trip on the catgirl's own
212+
Live2D / VRM rendering. Avoids the recursive feedback where "user is
213+
looking at the catgirl" itself becomes an input the catgirl reasons
214+
over.
215+
216+
## User overrides & externalized config
217+
218+
`utils/activity_config.py` reads the `activity` sub-dict from
219+
`user_preferences.json::__global_conversation__`:
220+
221+
```json
222+
{
223+
"model_path": "__global_conversation__",
224+
"activity": {
225+
"thresholds": {
226+
"away_idle_seconds": 600,
227+
"focused_work_min_dwell_seconds": 60
228+
},
229+
"user_app_overrides": {
230+
"MyCorpApp.exe": {"category": "work", "subcategory": "office", "canonical": "MyCorpApp"}
231+
},
232+
"user_title_overrides": {
233+
"MyCustomDashboard": {"category": "work", "subcategory": "office"}
234+
},
235+
"user_game_overrides": {
236+
"Elden Ring": {"intensity": "casual"}
237+
},
238+
"skip_probability_overrides": {
239+
"competitive": 0.5,
240+
"immersive_horror": 1.0
241+
}
242+
}
243+
}
244+
```
245+
246+
* **thresholds** — every state-machine constant (away_idle_seconds,
247+
focused_work_min_dwell_seconds, etc.) can be tuned. Code defaults
248+
remain hardcoded in `state_machine.py` as the fallback when an entry
249+
is missing or invalid (positive numbers only; bad values silently
250+
dropped).
251+
* **user_app_overrides** — process-name keyed, lowercased. **Additive
252+
only**: fires only when the static DB returned `unknown`. Cannot
253+
rewrite stable static classifications (e.g. `Code.exe → work/ide`
254+
cannot be flipped to `entertainment` via override). Cannot demote
255+
`private` or `own_app` static-DB hits (privacy / catgirl-app
256+
guarantee).
257+
* **user_title_overrides** — title-substring keyed, lowercased. Same
258+
additive rule as app overrides — fires only when the static DB
259+
returned `unknown`.
260+
* **user_game_overrides** — canonical-name keyed (case-sensitive). The
261+
one exception to the additive rule: it patches `(intensity, genre)`
262+
on top of an existing gaming classification (doesn't change
263+
category/subcategory/canonical, only refines intensity / genre tags
264+
within gaming).
265+
* **skip_probability_overrides** — float in [0, 1] per intensity[_genre]
266+
combo. Beats default lookups; out-of-range values are clamped.
267+
268+
Cache: file is read at most once per 30s with mtime-based
269+
invalidation. Edits to the JSON take effect on the next reload tick.
270+
`invalidate_activity_preferences_cache()` is exposed for tests + for
271+
explicit reload after a settings UI write.
272+
273+
There's no save path for the activity sub-dict yet — users hand-edit
274+
the JSON. Add a settings-UI write path when a UI lands.
275+
100276
## Architecture
101277

102278
```text

0 commit comments

Comments
 (0)