Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .github/workflows/plugin-api-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
- uses: actions/checkout@v4
with:
repository: kachofugetsu09/akashic-agent
ref: 3005f838bcd96e2cbc58616aede46e4f39df4523
ref: 9da3a988a2bf62b0f550bd4f6bb98c4eeb1f56f5
path: .akashic-core
- uses: actions/setup-python@v5
with:
Expand All @@ -55,13 +55,14 @@ jobs:
- name: Verify Steam v3 composition and migration
env:
AKASHIC_AGENT_ROOT: .akashic-core
AKASHIC_PLUGIN_FIXTURE_PYTHON: ${{ github.workspace }}/mcp/.venv/bin/python
PYTHONPATH: .akashic-core:mcp:mcp/.venv/lib/python3.13/site-packages
run: mcp/.venv/bin/python -m pytest -q mcp/tests tests
- name: Check changed v3 sources
env:
PYTHONPATH: .akashic-core:mcp
run: mcp/.venv/bin/pyright plugin.py mcp/runtime_config.py mcp/run_mcp.py scripts tests
run: mcp/.venv/bin/pyright plugin.py context_source.py steam_runtime mcp/run_mcp.py mcp/steam_mcp.py scripts tests
- name: Compile Python sources
run: python -m compileall -q plugin.py mcp scripts tests
run: python -m compileall -q plugin.py context_source.py steam_runtime mcp scripts tests
- name: Check diff formatting
run: git diff --check
74 changes: 36 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,41 @@
# steam-mcp

Akashic Steam plugin. It bundles:
Steam 是 Akashic Plugin API v3 插件。它用现有普通原语组合 current context:

- `steam` MCP server
- `steam-inventory-analyzer` skill

## Install
```text
Core Timer ──触发──> Steam shared domain ──覆盖──> current presence
└──有意义变化──追加──> game snapshot history

```bash
python main.py plugin-install --source https://github.com/akashic-plugins/steam-mcp --marketplace github
Wake BeforeTurn ──只读 fresh state──> extra_hints
Passive BeforeTurn ──────────────────> 不变
用户 Turn ──调用 Steam MCP───────────> 主动查询 Steam API
```

Restart Akashic after install.
## 能力与 owner

## Data directory
- `MCP_SERVERS`:保留用户主动查询工具。MCP 不再包含 context fetch 或手动
snapshot 特权工具。
- `TIMERS`:正式稳定 Root 独占一个 one-shot Timer。presence 每 5 分钟刷新;
网络瞬时失败记录结构化 Incident,并在 60 秒后重试。
- `turn.context_prepared`:只在 `channel=wake` 且 current presence 仍 fresh 时
append 一个普通 hint;不会 abort、替换 prompt 或影响 passive Turn。

Runtime data lives in:
Steam SQLite 是唯一 domain state owner:

```text
<workspace>/plugin-data/steam-<marketplace>/
```
- `current_state` 是可覆盖 singleton,保存 current presence、当前游戏列表、刷新
deadline 和最近错误。
- `snapshots`、`snapshot_runs` 保存真实游戏快照历史,不自动裁切。相同快照或空
结果只推进 current check time,不制造历史。
- 旧文件名 `steam_proactive.sqlite3` 为了原位继承正式历史而保留;运行代码、表
owner 和插件能力已经不依赖旧主动系统。

Common files:
纯诊断日志固定为 5 MiB、最多 3 个备份。candidate 只在自己的隔离目录完成 MCP
readiness/handshake:不注册 Timer、不访问 Steam 外网、不读取或写入正式 state。

- `steam_mcp_config.json`
- `steam_user_cache.json`
- `steam_app_cache.json`
- `steam_proactive.sqlite3`
## 配置

## Config

Create `steam_mcp_config.json` in the plugin data directory:
在插件 data root 创建 `steam_mcp_config.json`:

```json
{
Expand All @@ -40,25 +45,18 @@ Create `steam_mcp_config.json` in the plugin data directory:
}
```

`get_steam_context` 每次读取实时在线状态,并在历史游戏时长快照超过
`snapshot_interval_seconds` 时自动刷新。空的最近游玩列表也会记录快照批次,避免重复刷新
`snapshot_interval_seconds` 只控制游戏历史快照检查;presence 使用固定 5 分钟
freshness,避免把历史采样频率和当前状态时效揉成一个概念

## v2 data migration

v3 不会在插件加载时隐式复制正式数据。停止 Akashic 后显式执行:

```bash
PYTHONPATH=/path/to/akashic-agent \
python scripts/migrate_v2_data.py \
--workspace /path/to/workspace \
--marketplace github
```
显式迁移仍使用 `scripts/migrate_v2_data.py`。它保留 `mcp/steam-mcp` 原文件,
通过 SQLite backup、integrity check 和 hash receipt 发布到
`plugin-data/steam-<marketplace>/`,不删除历史源。

迁移保留 `mcp/steam-mcp` 原文件,在
`plugin-data/steam-<marketplace>/.steam-v2-migration.json` 写入 hash 与 SQLite
完整性证据。进程内失败会回滚本次新增文件;进程崩溃后重跑会清理 staging,
并只接纳已经发布且内容完全相同的文件。
## 验证

候选验证使用无凭证、无外网、无数据库的 recording backend;正式 MCP
只从自己的 `plugin-data` 读取 `steam_mcp_config.json`,不读取 ambient
`STEAM_API_KEY` 或 `STEAM_ID`。
CI 固定 Core `9da3a988a2bf62b0f550bd4f6bb98c4eeb1f56f5`。测试覆盖真实
PluginManager + stdio MCP + Timer、candidate 零正式 write set、reload Timer
换班、网络失败恢复、fresh/stale/unknown、Wake/passive 分流、历史保留、日志轮转、
pyright、compileall、Plugin API contract 和 `git diff --check`。
16 changes: 13 additions & 3 deletions akashic.plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema_version = 1
name = "steam"
version = "3.0.0"
version = "3.1.0"
api_version = 3
entrypoint = "plugin.py"

Expand All @@ -13,12 +13,22 @@ exclude_data_paths = [
"steam_user_cache.json",
"steam_app_cache.json",
"steam_proactive.sqlite3",
"steam_proactive.sqlite3-wal",
"steam_proactive.sqlite3-shm",
"steam_mcp.runtime.log",
"steam_mcp.runtime.log.1",
"steam_mcp.runtime.log.2",
"steam_mcp.runtime.log.3",
"steam_context.runtime.log",
"steam_context.runtime.log.1",
"steam_context.runtime.log.2",
"steam_context.runtime.log.3",
".steam-v2-migration.json",
]

[[mcp]]
name = "steam"
command = ["python", "mcp/run_mcp.py"]
required_tools = ["get_steam_context"]
candidate_read_only_tools = ["get_steam_context"]
required_tools = ["get_player_summaries"]
candidate_read_only_tools = []
candidate_env = {STEAM_BACKEND = "recording"}
189 changes: 189 additions & 0 deletions context_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
from __future__ import annotations

import asyncio
import json
import logging
from collections.abc import Callable
from datetime import UTC, datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path

from agent.control.timer import TimerHandle, TimerStatus
from agent.lifecycle.types import BeforeTurnCtx
from agent.plugin_composition import HealthHandle, PluginTimers

from .steam_runtime import backend


class SteamContextRuntime:
"""用 Timer 刷新 Steam current state,并只为 Wake 追加 fresh hint。"""

def __init__(
self,
data_root: Path,
timers: PluginTimers,
health: HealthHandle,
report_incident: Callable[[str, str], object],
*,
now: Callable[[], datetime] | None = None,
) -> None:
self._data_root = data_root
self._timers = timers
self._health = health
self._report_incident = report_incident
self._now = now or (lambda: datetime.now(UTC))
self._handle: TimerHandle | None = None
self._task: asyncio.Task[None] | None = None
self._closed = False
self._log = logging.Logger("steam-context-source", level=logging.INFO)
self._log.propagate = False
self._log_handler: RotatingFileHandler | None = None

async def start(self) -> None:
"""初始化正式 state,并只注册一个 source Timer。"""

if self._closed:
raise RuntimeError("Steam Context runtime 已关闭")
if self._handle is not None:
return
self._start_diagnostics()
now = self._aware_now()
await asyncio.to_thread(backend.initialize, self._data_root, now)
deadline = await asyncio.to_thread(backend.next_deadline, self._data_root, now)
self._arm(deadline)

async def close(self) -> None:
"""取消并收束当前等待,不改变 Steam domain state。"""

if self._closed:
return
self._closed = True
handle = self._handle
task = self._task
self._handle = None
self._task = None
if handle is not None:
_ = await handle.cancel()
if task is not None and task is not asyncio.current_task():
_ = await asyncio.gather(task, return_exceptions=True)
if handle is not None:
await handle.cleanup()
self._stop_diagnostics()

def prepare(self, ctx: BeforeTurnCtx) -> None:
"""只在 Wake channel 读取 fresh state 并追加一个普通 hint。"""

if ctx.channel != "wake":
return
current = backend.wake_context(self._data_root, ctx.timestamp)
if current is None:
return
ctx.extra_hints.append(
"Steam current context:\n"
+ json.dumps(current, sort_keys=True, separators=(",", ":"))
)

def _arm(self, deadline: datetime) -> None:
if self._closed or self._handle is not None:
return
handle = self._timers.schedule(deadline)
self._handle = handle
self._task = asyncio.create_task(
self._wait_refresh_rearm(handle),
name="steam-context-source:refresh",
)
self._task.add_done_callback(self._observe_task)

async def _wait_refresh_rearm(self, handle: TimerHandle) -> None:
"""消费一次 Timer,明确处理网络重试,再注册下一次。"""

next_due: datetime | None = None
try:
receipt = await handle.result()
if receipt.status is TimerStatus.CANCELLED or self._closed:
return
now = self._aware_now()
try:
result = await asyncio.to_thread(
backend.refresh,
self._data_root,
now,
)
except backend.SteamNetworkError as error:
next_due = await asyncio.to_thread(
backend.record_transient_failure,
self._data_root,
now,
error,
)
_ = self._report_incident("steam_refresh_transient", str(error))
self._log.warning(
"refresh transient retry_at=%s error=%s",
next_due.isoformat(),
error,
)
except Exception as error:
reason = f"{type(error).__name__}: {error}"
self._health.degrade(reason)
_ = self._report_incident("steam_refresh_contract", reason)
self._log.exception("refresh stopped by contract failure")
raise
else:
self._health.recover()
next_due = result.next_due
self._log.info(
"refresh committed presence=%s history_appended=%s next_due=%s",
result.presence,
result.history_appended,
result.next_due.isoformat(),
)
finally:
self._handle = None
self._task = None
await handle.cleanup()
if not self._closed and next_due is not None:
self._arm(next_due)

def _aware_now(self) -> datetime:
value = self._now()
if value.tzinfo is None:
raise ValueError("Steam Context clock 必须包含时区")
return value.astimezone(UTC)

def _observe_task(self, task: asyncio.Task[None]) -> None:
"""把未被等待的 runtime failure 转为 required health 与 Incident。"""

if task.cancelled():
return
error = task.exception()
if error is None or not self._health.healthy:
return
reason = f"{type(error).__name__}: {error}"
self._health.degrade(reason)
_ = self._report_incident("steam_runtime_failure", reason)

def _start_diagnostics(self) -> None:
if self._log_handler is not None:
return
self._data_root.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
self._data_root / "steam_context.runtime.log",
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s | %(message)s"
)
)
self._log.addHandler(handler)
self._log_handler = handler

def _stop_diagnostics(self) -> None:
handler = self._log_handler
if handler is None:
return
self._log.removeHandler(handler)
handler.close()
self._log_handler = None
Loading
Loading