浏览代码

feat(v2.0.0): 重制为云函数免密钥通道(零依赖 / ESM-first)

真因:v1.0 依赖 obs SDK + S3_AK/S3_SK 环境变量 + boto3,全舰队容器无一具备
→ 所有 Agent 日志静默失败,主协调者看不到任何动态。

v2.0 改造:
- 上传通道:云函数 fmodeagent-upload-url (AlP56LCKFm) 预签名 PUT,仅需 sessionToken
- 零依赖:Node>=18 内置 fetch;指标采集用 Python3 标准库(3.6+ 兼容)
- ESM-first 四端结构:lib/ bin/ skills/ test/ + package.json(exports/bin)
- 身份解析高兼容链(env → /opt/data/fmode-identity.json → ~/.fmode/config.json)
- 错误处理:缺 token → exit 2 + 指引;上传失败 → 显式报错 exit 1(不伪造成功)
- 旧日志滚动:免密钥通道不支持列举,诚实声明 skipped 并给控制台生命周期规则建议
- 六项质检 + 35 项冒烟测试

实测证据(芯葶001 容器内):
  check → 身份解析 OK / 云函数连通 OK / 验证上报 200
  report → https://s3.fmode.cn/user/dhv9yTqK6K/log/20260923/1122.json(公网 GET 200)
fmode-ops 1 天之前
父节点
当前提交
13397ada0d
共有 17 个文件被更改,包括 1399 次插入194 次删除
  1. 48 0
      CHANGELOG.md
  2. 21 0
      LICENSE
  3. 99 0
      QA.md
  4. 207 0
      README.md
  5. 188 32
      SKILL.md
  6. 201 0
      bin/agent-log.mjs
  7. 180 0
      lib/collect.py
  8. 72 0
      lib/identity.mjs
  9. 36 0
      lib/index.mjs
  10. 101 0
      lib/upload.mjs
  11. 53 0
      package.json
  12. 0 104
      scripts/collect_metrics.py
  13. 0 9
      scripts/report.sh
  14. 0 49
      scripts/upload_log.py
  15. 29 0
      skill-package-manifest.json
  16. 84 0
      skills/skill-agent-log-s3/SKILL.md
  17. 80 0
      test/smoke.mjs

+ 48 - 0
CHANGELOG.md

@@ -0,0 +1,48 @@
+# CHANGELOG
+
+## [2.0.0] - 2026-09-23
+
+### 重大改造(Breaking Change)
+
+**v1.0 彻底废弃,v2.0 为完全重写**
+
+#### 核心变更
+
+- **上传通道**:从 `boto3`/`obs` SDK + `S3_AK/S3_SK` 改为平台云函数 `fmodeagent-upload-url`(`AlP56LCKFm`),只需 `sessionToken`,**零密钥、零 pip 依赖**
+- **包结构**:从 Python-only scripts 改为 ESM-first Node.js 标准结构(`lib/` + `bin/` + `skills/`)
+- **CLI**:新增 `agent-log.mjs` 四命令(`report` / `collect` / `check` / `config`)
+- **身份解析**:新增高兼容链(5 个来源,hermes `/opt/data` 与 root `/root` 两处均可命中)
+- **指标采集**:保留 v1.0 所有维度(timestamp/agent/host/resources/concurrency/services/events),新增 `schema_version: "2.0"`、`agent_id`、`userid`、`hostname` 字段
+- **错误处理**:缺 `sessionToken` 打印详细指引 + `exit 2`,不再静默失败
+
+#### 修复的 v1.0 问题
+
+- `collect_metrics.py` 中 `time` 模块在 `collect()` 函数体内调用但未提前 import(已修复)
+- `upload_log.py` 依赖 `obs.ObsClient`,容器内无法安装(已废弃,改为 fetch)
+- `S3_AK/S3_SK` 环境变量在所有容器中均缺失,导致所有 Agent 静默失败(已解决)
+- 上报路径 hardcode 为 `~/.fmode-harness-agent/tmp/`(已改为直接内存传递)
+
+#### 删除
+
+- `scripts/upload_log.py`(boto3/obs SDK 通道,已废弃)
+- `scripts/report.sh`(旧 shell 入口)
+- `scripts/collect_metrics.py`(已迁移为 `lib/collect.py`,修复 bug,统一入口)
+
+#### 新增
+
+- `lib/identity.mjs` — 身份解析高兼容链
+- `lib/upload.mjs` — 云函数预签名上传
+- `lib/index.mjs` — 公共 SDK 入口
+- `lib/collect.py` — 指标采集(标准库,3.6+ 兼容)
+- `bin/agent-log.mjs` — CLI 入口
+- `package.json` — ESM-first 标准(type:module, node>=18)
+- `skill-package-manifest.json` — 看板数据源
+- `test/smoke.mjs` — 冒烟测试
+- `CHANGELOG.md` — 本文件
+- `QA.md` — 六项质检结果
+
+---
+
+## [1.0.0] - 2026-09-20(已废弃)
+
+初始版本,使用 boto3/obs SDK + AK/SK 上传,实际全部静默失败。

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Fmode (未来飞马)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 99 - 0
QA.md

@@ -0,0 +1,99 @@
+# QA.md — 六项质检结果
+
+工具:`node /root/cc-task/work/skill-core-guide/bin/skill-core.mjs check .`  
+执行时间:2026-09-23  
+目录:`/root/cc-task/work/skill-agent-log-s3`
+
+---
+
+## 质检结果汇总
+
+| # | 检查项 | 状态 | 说明 |
+|---|--------|------|------|
+| 1 | 功能完整性 `functional` | ✅ **通过** | `npm test` exit 0,35 项冒烟测试全部通过 |
+| 2 | Fmode API 联通 `apiConnectivity` | ✅ **通过** | `api.fmode.cn` → HTTP 401,`server.fmode.cn` → HTTP 401(401=端点存在,鉴权正确) |
+| 3 | 基础 SOP 跑通 `sop` | ⏭️ **skip(已解释)** | skillhub CLI 未安装无法 `--dry-run`;frontmatter 字段完整(slug/displayName/version/summary/license 全部存在)|
+| 4 | 看板后台就绪 `dashboard` | ✅ **通过** | `skill-package-manifest.json` 存在,字段合法(name/version/description/skills 均齐) |
+| 5 | Loop 迭代能力 `loop` | ⏭️ **skip(已解释)** | 包尚未发布到 npm(任务要求由运维001 审核后发布,禁止自行 publish) |
+| 6 | 多端可用性 `multiRuntime` | ✅ **通过** | ESM import 成功(7 个导出),CLI `--help` 正常,无 node: 依赖污染,无 .cjs 文件 |
+
+**结论:4 通过 / 0 失败 / 2 skip(均有合理解释)**
+
+---
+
+## 详细证据
+
+### 1. functional ✅
+
+```
+$ npm test
+> node test/smoke.mjs
+
+smoke test — skill-agent-log-s3 v2.0.0
+
+[1] 文件结构  12/12 ✓
+[2] package.json  8/8 ✓
+[3] SDK 导出  5/5 ✓
+[4] 身份解析  4/4 ✓
+[5] 日志键格式  2/2 ✓
+[6] 禁用端点检查  4/4 ✓
+
+结果: 35 通过 / 0 失败
+exit 0
+```
+
+### 2. apiConnectivity ✅
+
+```
+POST https://api.fmode.cn/v1/chat/completions → HTTP 401
+POST https://server.fmode.cn/api/listen/transcribe → HTTP 401
+planned 端点探测: storageUpload=404, storageCredentials=404(已确认废弃)
+```
+
+### 3. sop ⏭️
+
+**skip 原因**:skillhub CLI 未安装(容器内无 `skillhub` 命令)。  
+**已验证部分**:SKILL.md frontmatter 包含 `slug`, `displayName`, `version`, `summary`, `description`, `platform`, `level`, `category`, `icon`, `homepage`, `license`, `author`, `changelog`, `tags` 全部字段。  
+**修复路径**:运维001 发布时运行:
+```bash
+curl -fsSL https://skillhub.cn/install/install.sh | bash -s -- --cli-only
+skillhub publish . --changelog "v2.0.0 云函数免密钥通道"
+```
+
+### 4. dashboard ✅
+
+```
+skill-package-manifest.json 存在且结构合法
+name=skill-agent-log-s3  version=2.0.0  skills=1
+```
+
+### 5. loop ⏭️
+
+**skip 原因**:包 `skill-agent-log-s3@2.0.0` 尚未发布到 npm。任务硬约束:「不要推送 Gogs/GitHub/npm —— 由运维001 审核后再发布」。  
+**修复路径**(发布后):
+```bash
+npm publish --access public
+node /root/cc-task/work/skill-core-guide/bin/skill-core.mjs check .  # 重新验证
+```
+
+### 6. multiRuntime ✅
+
+```
+$ node --input-type=module -e "import('...')" → exit 0
+ESM_OK  exports=7
+  names: ENDPOINTS, getUploadUrl, identitySummary, listOldLogs, makeLogKey, resolveIdentity, uploadJson
+bin/agent-log.mjs  shebang=ok
+$ node bin/agent-log.mjs --help → exit 0
+browser/index.mjs 不存在(浏览器端非必需,技能以服务器端为主)
+无 .cjs 文件,符合 ESM only 约定 ✅
+```
+
+---
+
+## 三条铁律自检
+
+| 铁律 | 检查 | 结果 |
+|------|------|------|
+| 零密钥入库 | `git grep -iE "sk-[a-z0-9]{20}\|sk-ent-\|ghp_\|github_pat_"` | ✅ 无命中 |
+| 不伪造成功 | 缺 sessionToken → `exit 2` + 详细指引;PUT 失败 → `throw + exit 1` | ✅ 已实现 |
+| 端点先探测再调用 | 云函数 `AlP56LCKFm` 已上线(live),`/api/storage/credentials` 已废弃不引用 | ✅ 已验证 |

+ 207 - 0
README.md

@@ -0,0 +1,207 @@
+---
+slug: fmode-skill-agent-log-s3
+displayName: skill-agent-log-s3
+version: 2.0.0
+summary: 数字生命自报运行日志到个人S3空间 — 零密钥云函数通道,上报CPU/内存/并发/事件指标
+description: "数字生命(FmodeAgent/HermesAgent)通过平台云函数 fmodeagent-upload-url 把运行状态 JSON 写入个人 S3 空间 user/<userid>/log/ 目录,主协调者每天下载各家日志做汇总分析。v2.0 走云函数免密钥通道,不再依赖 boto3/obs SDK 或 AK/SK 环境变量,零依赖、Node>=18 原生 fetch,Python3 标准库采集指标。Digital life agent log reporting — uploads runtime metrics (CPU/memory/disk/concurrency/services/events) to personal S3 space via cloud function presigned URL, no AK/SK required."
+platform: HermesAgent
+level: 系统级
+category: 平台基础设施
+icon: "emoji: 📊"
+homepage: https://git.fmode.cn/fmode/skill-agent-log-s3
+license: MIT
+author: Yuyang001 (FmodeAgent)
+tags: [HermesAgent, FmodeAgent, 系统级, 平台基础设施, 日志上报, s3-upload, agent-log, monitoring, 运行状态, digital-life]
+---
+
+# skill-agent-log-s3 · 数字生命自报运行日志
+
+> 数字生命通过**云函数免密钥通道**把运行状态写入自己的 S3 空间,
+> 主协调者(雨飏001)每天下载各家日志做汇总分析。
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+
+**技能名**:`skill-agent-log-s3` | **版本**:`2.0.0` | **slug**:`fmode-skill-agent-log-s3`
+
+---
+
+## 是什么
+
+每个数字生命(FmodeAgent/HermesAgent)定期调用本技能,把自己的运行指标(CPU、内存、磁盘、并发、服务存活、事件计数)写入个人 S3 空间的 `user/<userid>/log/<YYYYMMDD>/<HHMM>.json`。
+
+**v2.0 核心改进**:v1.0 依赖 `boto3`/`obs` SDK 与 `S3_AK/S3_SK` 环境变量,容器内全部缺失导致静默失败。v2.0 改为调用平台云函数 `fmodeagent-upload-url`(objectId `AlP56LCKFm`),只需 sessionToken,由云函数签发预签名 PUT URL,**零密钥、零 pip 依赖**。
+
+---
+
+## 安装
+
+```bash
+# npm 全局安装
+npm install -g skill-agent-log-s3
+
+# 或直接用 npx
+npx --yes skill-agent-log-s3@latest report
+```
+
+---
+
+## 用法
+
+```bash
+# 采集 + 上传(输出 publicUrl)
+node bin/agent-log.mjs report
+
+# 仅采集指标(不上传)
+node bin/agent-log.mjs collect
+
+# 自检:身份解析 + 连通性 + 验证上报
+node bin/agent-log.mjs check
+
+# 显示配置(敏感信息脱敏)
+node bin/agent-log.mjs config
+```
+
+### cron 示例(每 6 小时)
+
+```bash
+0 */6 * * *  node /opt/skills/agent-log/bin/agent-log.mjs report >> /var/log/agent-log.log 2>&1
+```
+
+### 重任务触发
+
+```bash
+# 任务开始前
+node bin/agent-log.mjs report
+
+# 任务结束后
+node bin/agent-log.mjs report
+```
+
+### SDK 用法
+
+```javascript
+// Node.js ESM
+import { resolveIdentity, uploadJson, makeLogKey } from 'skill-agent-log-s3';
+
+const identity = resolveIdentity();
+const { publicUrl } = await uploadJson({
+  sessionToken: identity.sessionToken,
+  key: makeLogKey(),
+  content: myMetrics,
+});
+console.log(publicUrl);
+```
+
+```html
+<!-- 浏览器(仅支持上传,不含指标采集) -->
+<script type="module">
+  import { uploadJson } from 'https://cdn.jsdelivr.net/npm/skill-agent-log-s3/lib/index.mjs';
+  // ... 使用方式同上
+</script>
+```
+
+---
+
+## 凭据(零密钥)
+
+**不需要 AK/SK**。只需 Fmode `sessionToken`,解析优先级:
+
+| 优先级 | 来源 |
+|--------|------|
+| 1 | 环境变量 `FMODE_SESSION_TOKEN` |
+| 2 | `~/.fmode/config/user.json` → `sessionToken` |
+| 3 | `~/.fmode/config.json` → `sessionToken` |
+| 4 | `./.fmode/config.json` → `sessionToken` |
+
+```bash
+export FMODE_SESSION_TOKEN=r:xxxxxxxxxxxxxxxxxxxxxxxx
+```
+
+或写入 `~/.fmode/config.json`:
+
+```json
+{ "sessionToken": "r:xxxxxxxxxxxxxxxxxxxxxxxx" }
+```
+
+---
+
+## FAQ
+
+### 技术概念
+
+**Q: 为什么 v2.0 不需要 AK/SK?**
+A: 改为调用平台云函数 `fmodeagent-upload-url`(`AlP56LCKFm`),该云函数持有 AK/SK 并签发带时效的预签名 PUT URL,调用方只需 sessionToken,对象最终写入 `storage-s3-nkkj` 桶的 `user/<userid>/` 前缀下。
+
+**Q: 为什么必须携带云函数返回的 headers?**
+A: OBS SigV2 签名校验要求 PUT 请求头与签名时的头完全一致;`x-obs-acl: public-read` 缺失则返回 403。
+
+**Q: 指标采集为什么用 Python 而不是纯 Node.js?**
+A: `/proc/meminfo`、`df`、`pgrep` 等系统接口在 Python3 标准库中更自然,且容器内 Python3 始终存在,无需额外安装。
+
+**Q: 旧日志如何清理?**
+A: 列举/删除 OBS 对象需 AK/SK 权限,免密钥通道不支持。建议运维在 OBS 控制台配置生命周期规则(`user/<userid>/log/` 前缀,保留 30 天),或用 `obsutil` 定期清理。
+
+### MPL-2.0 协议
+
+**Q: 本技能是什么许可协议?**
+A: MIT 许可协议(见 `LICENSE`)。可自由使用、修改、分发,但**不授予**使用「未来飞马」商标的权利。
+
+**Q: 商业项目可以使用吗?**
+A: 可以,MIT 无限制。商标使用需另获书面授权。
+
+**Q: 贡献代码需要签 CLA 吗?**
+A: 不需要,直接 PR 即可。
+
+### 业务场景
+
+**Q: 哪些 Agent 需要接入?**
+A: 所有持续运行的 FmodeAgent / HermesAgent 实例,尤其是云服务器上的 24 小时驻留 Agent。
+
+**Q: 主协调者如何批量采集?**
+A: 访问 `https://s3.fmode.cn/user/<userid>/log/` 下载各生命的最新 JSON 文件,或通过 OBS 控制台批量列举。
+
+**Q: 为什么看不到某个 Agent 的日志?**
+A: 检查该 Agent 的 sessionToken 是否有效,运行 `node bin/agent-log.mjs check` 自检。
+
+---
+
+## GEO 埋点
+
+默认关闭(`geoTracking: false`)。本技能不采集地区信息,仅上报机器指标。
+如需开启,需用户显式配置,且不阻塞主上报逻辑。
+
+---
+
+## License
+
+MIT © 2026 Fmode (未来飞马)
+
+---
+
+## Trademark Notice
+
+MPL-2.0 governs copyright for source code only.
+This license **does NOT grant you any right to use our trademarks**:
+未来飞马, Harness Loop, RSI, and the slogan
+"让AI进化提前发生,让AI落地快人一步".
+
+You may not use these trademarks in your product name, marketing,
+documentation, or public promotion unless you obtain separate written
+permission from 未来飞马.
+
+---
+
+## 贡献指南
+
+1. Fork 仓库
+2. 遵循 ESM-first 规范(见 `skill-core-guide` §4)
+3. 运行 `npm test` 和 `node bin/skill-core.mjs check . --offline`
+4. 提 PR
+
+---
+
+## 相关项目
+
+- [skill-core-guide](https://git.fmode.cn/fmode/skill-core-guide) — 平台母技能标准指南(Harness Loop 宪法)
+- [Hermes](https://git.fmode.cn/fmode/hermes) — Agent 运行时
+- [RSI](https://git.fmode.cn/fmode/rsi) — 驾驭工程框架

+ 188 - 32
SKILL.md

@@ -1,48 +1,204 @@
 ---
-name: skill-agent-log-s3
-description: 触发词:日志上报、性能记录、运行状态。数字生命自报运行日志到个人S3空间log/目录。
+slug: fmode-skill-agent-log-s3
+displayName: skill-agent-log-s3
+version: 2.0.0
+summary: 数字生命自报运行日志到个人S3空间 — 零密钥云函数通道,上报CPU/内存/并发/事件指标
+description: "数字生命(FmodeAgent/HermesAgent)通过平台云函数 fmodeagent-upload-url 把运行状态 JSON 写入个人 S3 空间 user/<userid>/log/ 目录,主协调者每天下载各家日志做汇总分析。v2.0 走云函数免密钥通道,不再依赖 boto3/obs SDK 或 AK/SK 环境变量,零依赖、Node>=18 原生 fetch,Python3 标准库采集指标。Digital life agent log reporting via cloud function presigned URL, no AK/SK required."
+platform: HermesAgent
+level: 系统级
+category: 平台基础设施
+icon: "emoji: 📊"
+homepage: https://git.fmode.cn/fmode/skill-agent-log-s3
+license: MIT
+author: Yuyang001 (FmodeAgent)
+changelog: "v2.0.0: 重制为云函数免密钥通道,移除 boto3/obs 依赖,Node.js ESM-first 标准结构"
+tags: [HermesAgent, FmodeAgent, 系统级, 平台基础设施, 日志上报, s3-upload, agent-log, monitoring, 运行状态, digital-life]
 ---
 
-# 数字生命自报日志 (skill-agent-log-s3)
+# skill-agent-log-s3 · 数字生命自报运行日志
 
-> 命名规则: skill-agent-log-s3 = agent领域的log功能,存到S3
-> 核心逻辑: 每个生命**用自己的密钥**把运行状态写进**自己的**S3空间(log/目录),
-> 主协调者(雨飏001)每天只需下载各家日志做分析,不消耗监控资源。
+> 未来飞马 — 让AI进化提前发生,让AI落地快人一步
 
-## 我上报什么
+> 数字生命通过**云函数免密钥通道**把运行状态写入自己的 S3 空间,
+> 主协调者(雨飏001)每天下载各家日志做汇总分析。
 
-| 维度 | 指标 |
-|------|------|
-| 并发 | 活跃 profile 数、活跃 session 数、运行中 sub-agent 数 |
-| 资源 | CPU%、内存 used/total、磁盘 used/total、GPU(有则记) |
-| 网络 | 累计上行/下行字节 |
-| 进程 | gateway/dashboard/studio 存活状态 |
-| 事件 | 自上次上报以来的异常/重启/错误计数 |
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
 
-## 怎么上报
+**技能名**:`skill-agent-log-s3` | **版本**:`2.0.0` | **slug**:`fmode-skill-agent-log-s3`
+
+---
+
+## 是什么
+
+每个数字生命(FmodeAgent/HermesAgent)定期调用本技能,把自己的运行指标(CPU、内存、磁盘、并发、服务存活、事件计数)写入个人 S3 空间的 `user/<userid>/log/<YYYYMMDD>/<HHMM>.json`。
+
+**v2.0 核心改进**:v1.0 依赖 `boto3`/`obs` SDK 与 `S3_AK/S3_SK` 环境变量,容器内全部缺失导致静默失败。v2.0 改为调用平台云函数 `fmodeagent-upload-url`(objectId `AlP56LCKFm`),只需 sessionToken,由云函数签发预签名 PUT URL,**零密钥、零 pip 依赖**。
+
+---
+
+## 安装
+
+```bash
+# npm 全局安装
+npm install -g skill-agent-log-s3
+
+# 或直接用 npx
+npx --yes skill-agent-log-s3@latest report
+```
+
+---
+
+## 用法
+
+```bash
+# 采集 + 上传(输出 publicUrl)
+node bin/agent-log.mjs report
+
+# 仅采集指标(不上传)
+node bin/agent-log.mjs collect
+
+# 自检:身份解析 + 连通性 + 验证上报
+node bin/agent-log.mjs check
+
+# 显示配置(敏感信息脱敏)
+node bin/agent-log.mjs config
+```
+
+### cron 示例(每 6 小时)
+
+```bash
+0 */6 * * *  node /opt/skills/agent-log/bin/agent-log.mjs report >> /var/log/agent-log.log 2>&1
+```
+
+### 重任务触发
+
+```bash
+# 任务开始前
+node bin/agent-log.mjs report
+
+# 任务结束后
+node bin/agent-log.mjs report
+```
+
+### SDK 用法(Node.js ESM)
+
+```javascript
+// Node.js ESM
+import { resolveIdentity, uploadJson, makeLogKey } from 'skill-agent-log-s3';
+
+const identity = resolveIdentity();
+const { publicUrl } = await uploadJson({
+  sessionToken: identity.sessionToken,
+  key: makeLogKey(),
+  content: myMetrics,
+});
+console.log(publicUrl);
+```
+
+```html
+<!-- 浏览器(仅支持上传,不含指标采集) -->
+<script type="module">
+  import { uploadJson } from 'https://cdn.jsdelivr.net/npm/skill-agent-log-s3/lib/index.mjs';
+  // 用法同上
+</script>
+```
+
+---
+
+## 凭据(零密钥)
+
+**不需要 AK/SK**。只需 Fmode `sessionToken`,解析优先级:
+
+| 优先级 | 来源 |
+|--------|------|
+| 1 | 环境变量 `FMODE_SESSION_TOKEN` |
+| 2 | `~/.fmode/config/user.json` → `sessionToken` |
+| 3 | `~/.fmode/config.json` → `sessionToken` |
+| 4 | `./.fmode/config.json` → `sessionToken` |
 
 ```bash
-bash ~/.fmode-harness-agent/skills/agent-log-s3/report.sh
+export FMODE_SESSION_TOKEN=r:xxxxxxxxxxxxxxxxxxxxxxxx
 ```
-- 脚本采集本机指标 → JSON
-- 写入 `~/.fmode-harness-agent/tmp/agent-log.json`
-- 用**自己的**身份(FEME_USERID + S3凭证)上传到:
-  `user/<我的userid>/log/<YYYYMMDD>/<HHMM>.json`
-- 保留最近N份,自然滚动
 
-## 上报频率建议
+---
+
+## FAQ
+
+### 技术概念
+
+**Q: 为什么 v2.0 不需要 AK/SK?**
+A: 改为调用平台云函数 `fmodeagent-upload-url`(`AlP56LCKFm`),该云函数持有 AK/SK 并签发带时效的预签名 PUT URL,调用方只需 sessionToken,对象最终写入 `storage-s3-nkkj` 桶的 `user/<userid>/` 前缀下。
+
+**Q: 为什么必须携带云函数返回的 headers?**
+A: OBS SigV2 签名校验要求 PUT 请求头与签名时的头完全一致;`x-obs-acl: public-read` 缺失则返回 403。
+
+**Q: 指标采集为什么用 Python 而不是纯 Node.js?**
+A: `/proc/meminfo`、`df`、`pgrep` 等系统接口在 Python3 标准库中更自然,且容器内 Python3 始终存在,无需额外安装。
+
+**Q: 旧日志如何清理?**
+A: 列举/删除 OBS 对象需 AK/SK 权限,免密钥通道不支持。建议运维在 OBS 控制台配置生命周期规则(`user/<userid>/log/` 前缀,保留 30 天),或用 `obsutil` 定期清理。
+
+### MPL-2.0 协议
+
+**Q: 本技能是什么许可协议?**
+A: MIT 许可协议(见 `LICENSE`)。可自由使用、修改、分发,但**不授予**使用「未来飞马」商标的权利。
+
+**Q: 商业项目可以使用吗?**
+A: 可以,MIT 无限制。商标使用需另获书面授权。
+
+**Q: 贡献代码需要签 CLA 吗?**
+A: 不需要,直接 PR 即可。
+
+### 业务场景
+
+**Q: 哪些 Agent 需要接入?**
+A: 所有持续运行的 FmodeAgent / HermesAgent 实例,尤其是云服务器上的 24 小时驻留 Agent。
 
-- 常态: 每 6 小时一次(cron)
-- 重任务执行时: 任务开始/结束各加一条
-- 手动: 随时跑 report.sh
+**Q: 主协调者如何批量采集?**
+A: 访问 `https://s3.fmode.cn/user/<userid>/log/` 下载各生命的最新 JSON 文件,或通过 OBS 控制台批量列举。
 
-## 主协调者的收集
+**Q: 为什么看不到某个 Agent 的日志?**
+A: 检查该 Agent 的 sessionToken 是否有效,运行 `node bin/agent-log.mjs check` 自检。
 
-雨飏001每天跑 collect-all.sh:
-- 逐个生命的 S3 前缀 `user/<userid>/log/` 列举最新文件
-- 下载汇总 → 分析 → 生成日报(容量/异常/趋势)
+---
+
+## GEO 埋点
+
+默认关闭(`geoTracking: false`)。本技能不采集地区信息,仅上报机器指标。
+如需开启,需用户显式配置,且不阻塞主上报逻辑。
+
+---
+
+## License
+
+MIT © 2026 Fmode (未来飞马)
+
+---
+
+## Trademark Notice
+
+MPL-2.0 governs copyright for source code only.
+This license **does NOT grant you any right to use our trademarks**:
+未来飞马, Harness Loop, RSI, and the slogan
+"让AI进化提前发生,让AI落地快人一步".
+
+You may not use these trademarks in your product name, marketing,
+documentation, or public promotion unless you obtain separate written
+permission from 未来飞马.
+
+---
+
+## 贡献指南
+
+1. Fork 仓库
+2. 遵循 ESM-first 规范(见 `skill-core-guide` §4)
+3. 运行 `npm test` 和 `node bin/skill-core.mjs check . --offline`
+4. 提 PR
+
+---
 
-## 依赖
+## 相关项目
 
-- boto3 (S3协议)
-- 本身份 fmode-identity.json (userid) + S3凭证(env或credentials API)
+- [skill-core-guide](https://git.fmode.cn/fmode/skill-core-guide) — 平台母技能标准指南(Harness Loop 宪法)
+- [Hermes](https://git.fmode.cn/fmode/hermes) — Agent 运行时
+- [RSI](https://git.fmode.cn/fmode/rsi) — 驾驭工程框架

+ 201 - 0
bin/agent-log.mjs

@@ -0,0 +1,201 @@
+#!/usr/bin/env node
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+import { resolveIdentity, identitySummary } from '../lib/identity.mjs';
+import { uploadJson, ENDPOINTS } from '../lib/upload.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const COLLECT_PY = path.join(__dirname, '..', 'lib', 'collect.py');
+const VERSION = '2.0.0';
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 工具函数
+// ──────────────────────────────────────────────────────────────────────────────
+
+function makeLogKey(now = new Date()) {
+  const pad = n => String(n).padStart(2, '0');
+  const d = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
+  const t = `${pad(now.getHours())}${pad(now.getMinutes())}`;
+  return `log/${d}/${t}.json`;
+}
+
+function collectMetrics(identity) {
+  const args = [COLLECT_PY];
+  if (identity.agentId) args.push('--agent-id', identity.agentId);
+  if (identity.userid)  args.push('--userid', identity.userid);
+
+  const result = spawnSync('python3', args, {
+    timeout: 30_000,
+    encoding: 'utf-8',
+  });
+
+  if (result.error) {
+    throw new Error(`python3 启动失败: ${result.error.message}`);
+  }
+  if (result.status !== 0) {
+    throw new Error(`采集脚本非零退出 (${result.status}): ${(result.stderr || '').slice(0, 300)}`);
+  }
+  try {
+    return JSON.parse(result.stdout);
+  } catch {
+    throw new Error(`指标 JSON 解析失败,输出: ${(result.stdout || '').slice(0, 200)}`);
+  }
+}
+
+function assertToken(identity) {
+  if (!identity.sessionToken) {
+    console.error('[错误] 缺少 sessionToken,无法调用云函数上传通道');
+    console.error('\n修复方式(任选其一):');
+    console.error('  1. export FMODE_SESSION_TOKEN=r:xxxxxxxxxxxxxxxxxxxxxxxx');
+    console.error('  2. 在 ~/.fmode/config.json 中添加 "sessionToken": "r:xxx"');
+    console.error('  3. 在 ~/.fmode/config/user.json 中添加 "sessionToken": "r:xxx"');
+    console.error('\ntoken 可在 Fmode 控制台 → 账号设置 → Session Token 处获取。');
+    process.exit(2);
+  }
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 命令实现
+// ──────────────────────────────────────────────────────────────────────────────
+
+async function cmdReport() {
+  const identity = resolveIdentity();
+  assertToken(identity);
+
+  if (!identity.userid) {
+    console.log('[report] 未找到本地 userid,将由云函数从 token 自动推断');
+  }
+
+  console.log('[report] 采集本机指标...');
+  const metrics = collectMetrics(identity);
+
+  const key = makeLogKey();
+  console.log(`[report] 上传到 ${key} ...`);
+
+  const { publicUrl } = await uploadJson({
+    sessionToken: identity.sessionToken,
+    key,
+    content: metrics,
+  });
+
+  console.log('[report] 上报成功');
+  console.log(`publicUrl: ${publicUrl}`);
+  return publicUrl;
+}
+
+async function cmdCollect() {
+  const identity = resolveIdentity();
+  const metrics = collectMetrics(identity);
+  process.stdout.write(JSON.stringify(metrics, null, 2) + '\n');
+}
+
+async function cmdCheck() {
+  const identity = resolveIdentity();
+  const summary = identitySummary(identity);
+
+  console.log('=== 身份解析 ===');
+  console.log(`userid:       ${summary.userid}`);
+  console.log(`sessionToken: ${summary.sessionToken}`);
+  console.log(`agentId:      ${summary.agentId}`);
+
+  console.log('\n=== 云函数连通性 ===');
+  console.log(`端点: ${ENDPOINTS.functions}`);
+  console.log(`函数 ID: AlP56LCKFm (fmodeagent-upload-url)`);
+
+  if (!identity.sessionToken) {
+    console.error('\n[错误] 无 sessionToken,无法测试连通性');
+    console.error('修复: export FMODE_SESSION_TOKEN=r:xxx 或写入 ~/.fmode/config.json');
+    process.exit(2);
+  }
+
+  // 用一次真实的轻量上报来验证连通性(check 专用路径)
+  const testContent = {
+    schema_version: '2.0',
+    type: 'connectivity-check',
+    timestamp: new Date().toISOString(),
+    agent_id: identity.agentId,
+  };
+  const now = new Date();
+  const pad = n => String(n).padStart(2, '0');
+  const testKey = `log/check/${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}/${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.json`;
+
+  try {
+    const { publicUrl } = await uploadJson({
+      sessionToken: identity.sessionToken,
+      key: testKey,
+      content: testContent,
+    });
+    console.log('状态: OK ✓');
+    console.log(`验证上报: ${publicUrl}`);
+    console.log('\n=== 结论 ===');
+    console.log('所有检查通过 ✓');
+  } catch (e) {
+    console.error(`\n[错误] 连通性检查失败: ${e.message}`);
+    process.exit(1);
+  }
+}
+
+async function cmdConfig() {
+  const identity = resolveIdentity();
+  const summary = identitySummary(identity);
+  console.log('当前配置(敏感信息已脱敏):');
+  console.log(JSON.stringify(summary, null, 2));
+  console.log('\n上传端点:', ENDPOINTS.functions);
+  console.log('公网基址:', ENDPOINTS.publicBase);
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 路由
+// ──────────────────────────────────────────────────────────────────────────────
+
+const [, , cmd] = process.argv;
+
+if (!cmd || cmd === '--help' || cmd === '-h') {
+  console.log(`agent-log v${VERSION} — 数字生命自报运行日志 (未来飞马)
+
+用法: agent-log <command>
+
+命令:
+  report    采集本机指标并上传到个人 S3 空间,输出 publicUrl
+  collect   仅采集指标(输出 JSON,不上传)
+  check     打印身份解析 + 云函数连通性 + 验证上报
+  config    显示当前配置(敏感信息脱敏,token 只显示前8字符+长度)
+
+示例(cron 每6小时):
+  0 */6 * * *  node /path/to/bin/agent-log.mjs report >> /var/log/agent-log.log 2>&1
+
+重任务:
+  node bin/agent-log.mjs report  # 任务开始/结束各调一次
+
+凭据配置(零密钥,不需要 AK/SK):
+  export FMODE_SESSION_TOKEN=r:xxxxxxxxxxxxxxxxxxxxxxxx
+  或在 ~/.fmode/config.json 中添加 "sessionToken": "r:xxx"
+`);
+  process.exit(0);
+}
+
+const handlers = { report: cmdReport, collect: cmdCollect, check: cmdCheck, config: cmdConfig };
+const handler = handlers[cmd];
+
+if (!handler) {
+  console.error(`未知命令: ${cmd}  (可用: report, collect, check, config)`);
+  process.exit(1);
+}
+
+handler().catch(e => {
+  console.error(`[错误] ${e.message}`);
+  process.exit(1);
+});

+ 180 - 0
lib/collect.py

@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Copyright (c) 未来飞马
+# 纯标准库,Python 3.6+ 兼容,禁止 pip 依赖
+
+"""采集本机运行指标,输出 JSON(纯标准库,3.6+ 兼容,禁止 boto3/obs/pip 安装)"""
+
+import json
+import os
+import subprocess
+import datetime
+import socket
+import sys
+import time
+import argparse
+
+
+def sh(cmd, timeout=15):
+    """执行 shell 命令,失败返回空字符串"""
+    try:
+        result = subprocess.run(
+            cmd,
+            shell=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            universal_newlines=True,
+            timeout=timeout
+        )
+        return result.stdout.strip()
+    except Exception:
+        return ""
+
+
+def collect(agent_id=None, userid=None):
+    now = datetime.datetime.now()
+    data = {
+        "schema_version": "2.0",
+        "timestamp": now.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
+        "agent": agent_id or os.environ.get("AGENT_ID") or socket.gethostname(),
+        "agent_id": agent_id or os.environ.get("FMODE_AGENT_ID") or os.environ.get("AGENT_ID") or socket.gethostname(),
+        "userid": userid or os.environ.get("FMODE_USERID") or "",
+        "hostname": socket.gethostname(),
+        "resources": {},
+        "concurrency": {},
+        "services": {},
+        "events": {}
+    }
+
+    # CPU / 内存(/proc/meminfo + /proc/loadavg)
+    try:
+        meminfo = {}
+        with open("/proc/meminfo") as f:
+            for line in f:
+                k, v = line.split(":")
+                meminfo[k.strip()] = int(v.strip().split()[0])
+        total = meminfo["MemTotal"]
+        avail = meminfo.get("MemAvailable", meminfo.get("MemFree", 0))
+        data["resources"]["memory"] = {
+            "total_mb": round(total / 1024),
+            "used_mb": round((total - avail) / 1024),
+            "percent": round((total - avail) / total * 100, 1)
+        }
+        load = open("/proc/loadavg").read().split()
+        data["resources"]["cpu_load"] = {
+            "m1": float(load[0]),
+            "m5": float(load[1]),
+            "m15": float(load[2])
+        }
+        cpu = sh("top -bn1 2>/dev/null | grep 'Cpu(s)' | awk '{print $2+$4}'")
+        if cpu:
+            try:
+                data["resources"]["cpu_percent"] = round(float(cpu), 1)
+            except ValueError:
+                data["resources"]["cpu_percent"] = None
+        else:
+            data["resources"]["cpu_percent"] = None
+    except Exception as e:
+        data["resources"]["error"] = str(e)[:100]
+
+    # 磁盘(根分区)
+    disk = sh("df -B1 / 2>/dev/null | tail -1 | awk '{print $2, $3, $5}'")
+    if disk:
+        parts = disk.split()
+        if len(parts) == 3:
+            try:
+                data["resources"]["disk"] = {
+                    "total_gb": round(int(parts[0]) / 1e9, 1),
+                    "used_gb": round(int(parts[1]) / 1e9, 1),
+                    "percent": parts[2]
+                }
+            except ValueError:
+                pass
+
+    # 网络(主网卡,累计上下行)
+    net = sh(
+        "cat /proc/net/dev 2>/dev/null | "
+        "awk 'NR>2 && $1!~/lo:/ {gsub(\":\",\"\",$1); print $1, $2, $10}' | "
+        "sort -k2 -rn | head -1"
+    )
+    if net:
+        parts = net.split()
+        if len(parts) == 3:
+            try:
+                data["resources"]["network"] = {
+                    "iface": parts[0],
+                    "rx_total_mb": round(int(parts[1]) / 1e6, 1),
+                    "tx_total_mb": round(int(parts[2]) / 1e6, 1)
+                }
+            except ValueError:
+                pass
+
+    # GPU(有则记,无则跳过)
+    gpu = sh(
+        "nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total "
+        "--format=csv,noheader,nounits 2>/dev/null"
+    )
+    if gpu:
+        parts = [p.strip() for p in gpu.split(",")]
+        if len(parts) == 3:
+            try:
+                data["resources"]["gpu"] = {
+                    "percent": int(parts[0]),
+                    "mem_used_mb": int(parts[1]),
+                    "mem_total_mb": int(parts[2])
+                }
+            except ValueError:
+                pass
+
+    # 并发:profiles 总数
+    profiles = sh("ls /opt/data/profiles/ 2>/dev/null | wc -l")
+    data["concurrency"]["profiles_total"] = int(profiles) if profiles.isdigit() else None
+
+    # 并发:活跃 session / 运行中 subagent(hermes state.db)
+    try:
+        import sqlite3
+        conn = sqlite3.connect("file:/opt/data/state.db?mode=ro", uri=True, timeout=5)
+        cur = conn.cursor()
+        cur.execute(
+            "SELECT COUNT(DISTINCT session_id) FROM messages WHERE timestamp > ?",
+            (time.time() - 1800,)
+        )
+        data["concurrency"]["active_sessions_30min"] = cur.fetchone()[0]
+        cur.execute("SELECT COUNT(*) FROM async_delegations WHERE status='running'")
+        data["concurrency"]["running_subagents"] = cur.fetchone()[0]
+        conn.close()
+    except Exception:
+        data["concurrency"]["active_sessions_30min"] = None
+        data["concurrency"]["running_subagents"] = None
+
+    # 服务存活(gateway / dashboard / studio)
+    for svc, pat in [
+        ("gateway", "hermes gateway"),
+        ("dashboard", "hermes-dashboard"),
+        ("studio", "fmode-studio")
+    ]:
+        out = sh("pgrep -f '{}' 2>/dev/null | wc -l".format(pat))
+        data["services"][svc] = "up" if out.isdigit() and int(out) > 0 else "down"
+
+    # 事件:最近 1 小时错误计数
+    errc = sh(
+        "find /opt/data/logs -name '*.log' -mmin -60 -exec "
+        "grep -ci 'error\\|fatal' {} + 2>/dev/null | "
+        "awk -F: '{s+=$2} END {print s}'"
+    )
+    data["events"]["errors_last_1h"] = int(errc) if errc.isdigit() else None
+
+    rest = sh("grep -c 'Reconnected' /opt/data/logs/gateway.log 2>/dev/null")
+    data["events"]["wecom_reconnects_total"] = int(rest) if rest.isdigit() else None
+
+    return data
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="采集本机指标输出 JSON")
+    parser.add_argument("--agent-id", default=None, help="Agent 标识")
+    parser.add_argument("--userid", default=None, help="用户 ID")
+    args = parser.parse_args()
+
+    result = collect(agent_id=args.agent_id, userid=args.userid)
+    print(json.dumps(result, ensure_ascii=False))

+ 72 - 0
lib/identity.mjs

@@ -0,0 +1,72 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+
+import { readFileSync, existsSync } from 'node:fs';
+import { hostname, homedir } from 'node:os';
+
+// 读取 JSON 文件,自动剥 BOM,失败返回 null
+function readJson(p) {
+  if (!existsSync(p)) return null;
+  try {
+    const raw = readFileSync(p, 'utf-8').replace(/^/, '');
+    return JSON.parse(raw);
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * 身份解析高兼容链:
+ *   userid:        FMODE_USERID → FMODE_AGENT_USERID → /opt/data/fmode-identity.json → $HOME/.fmode/config.json → ./.fmode/config.json
+ *   sessionToken:  FMODE_SESSION_TOKEN → $HOME/.fmode/config/user.json → $HOME/.fmode/config.json → ./.fmode/config.json
+ *   agentId:       FMODE_AGENT_ID → AGENT_ID → $HOME/.fmode/config.json → hostname()
+ *
+ * $HOME 容器 hermes 用户 = /opt/data;root 用户 = /root;两处都能命中。
+ */
+export function resolveIdentity() {
+  const home = process.env.HOME || homedir();
+
+  const userid =
+    process.env.FMODE_USERID ||
+    process.env.FMODE_AGENT_USERID ||
+    readJson('/opt/data/fmode-identity.json')?.userid ||
+    readJson(`${home}/.fmode/config.json`)?.userid ||
+    readJson('./.fmode/config.json')?.userid ||
+    null;
+
+  const sessionToken =
+    process.env.FMODE_SESSION_TOKEN ||
+    readJson(`${home}/.fmode/config/user.json`)?.sessionToken ||
+    readJson(`${home}/.fmode/config.json`)?.sessionToken ||
+    readJson('./.fmode/config.json')?.sessionToken ||
+    null;
+
+  const agentId =
+    process.env.FMODE_AGENT_ID ||
+    process.env.AGENT_ID ||
+    readJson(`${home}/.fmode/config.json`)?.agentId ||
+    hostname();
+
+  return { userid, sessionToken, agentId };
+}
+
+/** 仅打印身份摘要(不暴露密钥本体)*/
+export function identitySummary(identity) {
+  const { userid, sessionToken, agentId } = identity;
+  return {
+    userid: userid || '(未找到)',
+    sessionToken: sessionToken
+      ? `${sessionToken.slice(0, 8)}... (${sessionToken.length} 字符)`
+      : '(未找到)',
+    agentId,
+  };
+}

+ 36 - 0
lib/index.mjs

@@ -0,0 +1,36 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+
+/**
+ * skill-agent-log-s3 公共 SDK 入口
+ *
+ * 数字生命自报运行日志到个人 S3 空间,走云函数免密钥通道。
+ *
+ * @example Node.js
+ * import { resolveIdentity, uploadJson, ENDPOINTS } from 'skill-agent-log-s3';
+ * const id = resolveIdentity();
+ * const { publicUrl } = await uploadJson({ sessionToken: id.sessionToken, key: 'log/20260923/1400.json', content: metrics });
+ *
+ * @example Browser (不支持指标采集,仅支持上传)
+ * import { uploadJson } from 'skill-agent-log-s3/browser';
+ */
+
+export { resolveIdentity, identitySummary } from './identity.mjs';
+export { getUploadUrl, uploadJson, listOldLogs, ENDPOINTS } from './upload.mjs';
+
+/** 生成当前时刻的日志路径 log/<YYYYMMDD>/<HHMM>.json */
+export function makeLogKey(now = new Date()) {
+  const pad = n => String(n).padStart(2, '0');
+  const d = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
+  const t = `${pad(now.getHours())}${pad(now.getMinutes())}`;
+  return `log/${d}/${t}.json`;
+}

+ 101 - 0
lib/upload.mjs

@@ -0,0 +1,101 @@
+// Copyright (c) 未来飞马
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Trademark Notice:
+// The MPL-2.0 license grants copyright permissions for source code only.
+// It does NOT grant any rights to use trademarks including "未来飞马",
+// "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
+// Any use of these trademarks requires separate written permission.
+
+// 端点常量 —— 仅在此处定义,禁止在其他地方硬编码
+export const ENDPOINTS = {
+  functions:  'https://server.fmode.cn/api/functions',
+  publicBase: 'https://s3.fmode.cn',
+};
+
+const FUNCTION_ID = 'AlP56LCKFm'; // fmodeagent-upload-url v0.2.2
+
+/**
+ * 向云函数申请预签名 PUT URL。
+ * @param {object} opts
+ * @param {string} opts.sessionToken  - 用户 session token (r:xxx)
+ * @param {string} opts.key           - 相对路径,如 log/20260923/1400.json
+ * @param {number} opts.size          - 文件字节数
+ * @returns {Promise<{uploadUrl, key, publicUrl, headers, expiresAt}>}
+ */
+export async function getUploadUrl({ sessionToken, key, size }) {
+  const filename = key.split('/').pop();
+
+  const res = await fetch(ENDPOINTS.functions, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      token: sessionToken,
+      id: FUNCTION_ID,
+      params: {
+        filename,
+        mimeType: 'application/json',
+        size,
+        namespace: 'log',
+        key,
+      },
+    }),
+    signal: AbortSignal.timeout(15000),
+  });
+
+  if (!res.ok) {
+    const text = await res.text().catch(() => '');
+    throw new Error(`云函数请求失败 HTTP ${res.status}: ${text.slice(0, 200)}`);
+  }
+
+  const data = await res.json();
+  if (data.code !== 200) {
+    throw new Error(`云函数返回错误 code=${data.code}: ${JSON.stringify(data).slice(0, 300)}`);
+  }
+
+  return data; // { uploadUrl, key, publicUrl, headers, expiresAt }
+}
+
+/**
+ * 采集 JSON 对象并直传到个人 S3 空间。
+ * @param {object} opts
+ * @param {string}  opts.sessionToken - 用户 session token
+ * @param {string}  opts.key          - 对象相对路径,如 log/20260923/1400.json
+ * @param {object}  opts.content      - 要上传的 JSON 对象
+ * @returns {Promise<{publicUrl, key}>}
+ */
+export async function uploadJson({ sessionToken, key, content }) {
+  const buf = Buffer.from(JSON.stringify(content, null, 2), 'utf-8');
+  const size = buf.length;
+
+  const { uploadUrl, headers, publicUrl, key: serverKey } = await getUploadUrl({ sessionToken, key, size });
+
+  // PUT 时必须携带云函数返回的 headers(Content-Type + x-obs-acl),否则 403
+  const putRes = await fetch(uploadUrl, {
+    method: 'PUT',
+    headers: { ...headers },
+    body: buf,
+    signal: AbortSignal.timeout(30000),
+  });
+
+  if (!putRes.ok) {
+    const text = await putRes.text().catch(() => '');
+    throw new Error(`PUT 上传失败 HTTP ${putRes.status}: ${text.slice(0, 300)}`);
+  }
+
+  return { publicUrl, key: serverKey };
+}
+
+/**
+ * 尝试列举旧日志(滚动删除用)。
+ * 注:OBS 的 S3 列举需 AK/SK 鉴权,无法通过预签名仅读操作完成;
+ * 此函数仅为 API 兼容占位,总是返回空列表并附提示。
+ */
+export async function listOldLogs(userid, cutoffDay) {
+  // 旧日志自动删除需 AK/SK 权限,当前免密钥通道不支持列举操作。
+  // 运维001 可通过 OBS 控制台或 obsutil 定期清理 user/<userid>/log/ 目录。
+  return { skipped: true, reason: '列举操作需 AK/SK 权限,云函数通道不支持;请在 OBS 控制台或 obsutil 手动清理旧日志' };
+}

+ 53 - 0
package.json

@@ -0,0 +1,53 @@
+{
+  "name": "skill-agent-log-s3",
+  "version": "2.0.0",
+  "description": "数字生命自报运行日志到个人S3空间 — 零密钥云函数通道,每6小时上报CPU/内存/并发/事件指标",
+  "type": "module",
+  "main": "./lib/index.mjs",
+  "exports": {
+    ".": {
+      "import": "./lib/index.mjs",
+      "default": "./lib/index.mjs"
+    }
+  },
+  "bin": {
+    "agent-log": "./bin/agent-log.mjs"
+  },
+  "files": [
+    "lib/",
+    "bin/",
+    "skills/",
+    "README.md",
+    "LICENSE",
+    "skill-package-manifest.json"
+  ],
+  "engines": {
+    "node": ">=18"
+  },
+  "scripts": {
+    "test": "node test/smoke.mjs"
+  },
+  "keywords": [
+    "fmode",
+    "hermes",
+    "harness-loop",
+    "rsi",
+    "ai-agent",
+    "agent-skill",
+    "super-skill",
+    "esm",
+    "未来飞马",
+    "智能体技能",
+    "agent-log",
+    "s3-upload",
+    "monitoring",
+    "日志上报",
+    "运行状态",
+    "digital-life",
+    "fmodeagent"
+  ],
+  "license": "MIT",
+  "author": "Yuyang001 (FmodeAgent)",
+  "homepage": "https://git.fmode.cn/fmode/skill-agent-log-s3",
+  "dependencies": {}
+}

+ 0 - 104
scripts/collect_metrics.py

@@ -1,104 +0,0 @@
-#!/usr/bin/env python3
-"""采集本机运行指标 → JSON (跨平台: Linux/macOS/Windows-WSL)"""
-import json, os, subprocess, datetime, socket, sys
-
-WORK = os.path.expanduser("~/.fmode-harness-agent")
-
-def sh(cmd, timeout=15):
-    try:
-        return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout).stdout.strip()
-    except Exception as e:
-        return f"ERR:{e}"
-
-def collect():
-    now = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00")
-    data = {
-        "timestamp": now,
-        "agent": os.environ.get("AGENT_NAME", socket.gethostname()),
-        "host": {
-            "platform": sys.platform,
-            "hostname": socket.gethostname(),
-        },
-        "resources": {},
-        "concurrency": {},
-        "services": {},
-        "events": {}
-    }
-
-    # --- CPU/内存 (跨平台: /proc/meminfo + uptime) ---
-    try:
-        meminfo = {}
-        for line in open("/proc/meminfo"):
-            k, v = line.split(":")
-            meminfo[k.strip()] = int(v.strip().split()[0])  # kB
-        total = meminfo["MemTotal"]
-        avail = meminfo["MemAvailable"]
-        data["resources"]["memory"] = {
-            "total_mb": round(total / 1024),
-            "used_mb": round((total - avail) / 1024),
-            "percent": round((total - avail) / total * 100, 1)
-        }
-        load = open("/proc/loadavg").read().split()
-        data["resources"]["cpu_load"] = {"m1": float(load[0]), "m5": float(load[1]), "m15": float(load[2])}
-        # CPU%
-        cpu = sh("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'")
-        data["resources"]["cpu_percent"] = round(float(cpu), 1) if cpu and not cpu.startswith("ERR") else None
-    except Exception as e:
-        data["resources"]["error"] = str(e)[:100]
-
-    # --- 磁盘 (工作区所在分区) ---
-    disk = sh(f"df -B1 {WORK} | tail -1 | awk '{{print $2, $3, $5}}'")
-    if disk and not disk.startswith("ERR"):
-        t, u, p = disk.split()
-        data["resources"]["disk"] = {"total_gb": round(int(t)/1e9,1), "used_gb": round(int(u)/1e9,1), "percent": p}
-
-    # --- 网络 (累计上下行, 取主网卡) ---
-    net = sh("cat /proc/net/dev | awk 'NR>2 && $1!~/lo:/ {gsub(\":\",\"\",$1); print $1, $2, $10}' | sort -k2 -rn | head -1")
-    if net and not net.startswith("ERR"):
-        iface, rx, tx = net.split()
-        data["resources"]["network"] = {"iface": iface, "rx_total_mb": round(int(rx)/1e6,1), "tx_total_mb": round(int(tx)/1e6,1)}
-
-    # --- GPU (有则记) ---
-    gpu = sh("nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits 2>/dev/null")
-    if gpu:
-        u, mu, mt = gpu.split(", ")
-        data["resources"]["gpu"] = {"percent": int(u), "mem_used_mb": int(mu), "mem_total_mb": int(mt)}
-
-    # --- 并发: profile / session / subagent ---
-    profiles = sh("ls /opt/data/profiles/ 2>/dev/null | wc -l")
-    data["concurrency"]["profiles_total"] = int(profiles) if profiles.isdigit() else None
-    # 活跃session(hermes state.db最近30分钟)
-    try:
-        import sqlite3
-        conn = sqlite3.connect("file:/opt/data/state.db?mode=ro", uri=True, timeout=5)
-        cur = conn.cursor()
-        cur.execute("SELECT COUNT(DISTINCT session_id) FROM messages WHERE timestamp > ?", (time.time()-1800,))
-        data["concurrency"]["active_sessions_30min"] = cur.fetchone()[0]
-        cur.execute("SELECT COUNT(*) FROM async_delegations WHERE status='running'")
-        data["concurrency"]["running_subagents"] = cur.fetchone()[0]
-        conn.close()
-    except Exception:
-        data["concurrency"]["active_sessions_30min"] = None
-
-    # --- 服务存活 ---
-    for svc, pat in [("gateway", "hermes gateway"), ("dashboard", "hermes-dashboard"), ("studio", "fmode-studio")]:
-        out = sh(f"pgrep -f '{pat}' | wc -l")
-        data["services"][svc] = "up" if out.isdigit() and int(out) > 0 else "down"
-
-    # --- 事件: 最近1小时错误日志计数 ---
-    errc = sh("find /opt/data/logs -name '*.log' -mmin -60 -exec grep -ci 'error\\|fatal' {} + 2>/dev/null | awk -F: '{s+=$2} END {print s}'")
-    data["events"]["errors_last_1h"] = int(errc) if errc.isdigit() else None
-    rest = sh("grep -c 'Reconnected' /opt/data/logs/gateway.log 2>/dev/null")
-    data["events"]["wecom_reconnects_total"] = int(rest) if rest.isdigit() else None
-
-    return data
-
-if __name__ == "__main__":
-    import time
-    out = os.path.join(WORK, "tmp")
-    os.makedirs(out, exist_ok=True)
-    d = collect()
-    path = os.path.join(out, "agent-log.json")
-    json.dump(d, open(path, "w"), ensure_ascii=False, indent=2)
-    print(json.dumps(d, ensure_ascii=False)[:300])
-    print(f"\nsaved: {path}")

+ 0 - 9
scripts/report.sh

@@ -1,9 +0,0 @@
-#!/usr/bin/env bash
-# 一键上报: 采集 + 上传 (cron入口)
-set -euo pipefail
-WORK="$HOME/.fmode-harness-agent"
-SKILL="$WORK/skills/agent-log-s3/scripts"
-export AGENT_NAME="${AGENT_NAME:-$(hostname)}"
-
-python3 "$SKILL/collect_metrics.py" > /dev/null
-python3 "$SKILL/upload_log.py"

+ 0 - 49
scripts/upload_log.py

@@ -1,49 +0,0 @@
-#!/usr/bin/env python3
-"""上传日志到自己S3空间(华为OBS官方SDK): user/<userid>/log/<YYYYMMDD>/<HHMM>.json"""
-import json, os, sys, datetime
-
-WORK = os.path.expanduser("~/.fmode-harness-agent")
-
-def upload():
-    ident_path = "/opt/data/fmode-identity.json"
-    if not os.path.exists(ident_path):
-        ident_path = os.path.join(WORK, "fmode-identity.json")
-    ident = json.load(open(ident_path))
-    userid = ident["userid"]
-
-    log_file = os.path.join(WORK, "tmp", "agent-log.json")
-    if not os.path.exists(log_file):
-        print("先跑 collect_metrics.py"); sys.exit(1)
-
-    ak = os.environ.get("S3_AK") or os.environ.get("CLOUD_SDK_AK")
-    sk = os.environ.get("S3_SK") or os.environ.get("CLOUD_SDK_SK")
-    if not ak:
-        print("缺少S3凭证"); sys.exit(1)
-
-    from obs import ObsClient
-    obs = ObsClient(access_key_id=ak, secret_access_key=sk,
-                    server="https://obs.cn-north-4.myhuaweicloud.com")
-
-    now = datetime.datetime.now()
-    key = f"user/{userid}/log/{now:%Y%m%d}/{now:%H%M}.json"
-    r = obs.putObject("storage-s3-nkkj", key, content=open(log_file, "rb").read())
-    if r.status < 300:
-        print(f"uploaded: {key} (status {r.status})")
-    else:
-        print(f"upload FAILED: {r.status} {r.reason}"); sys.exit(1)
-
-    # 清理30天前旧日志
-    cutoff = (now - datetime.timedelta(days=30)).strftime("%Y%m%d")
-    resp = obs.listObjects("storage-s3-nkkj", prefix=f"user/{userid}/log/")
-    cleaned = 0
-    if resp.status < 300 and resp.body.contents:
-        for obj in resp.body.contents:
-            day = obj.key.split("/")[3]
-            if day < cutoff:
-                obs.deleteObject("storage-s3-nkkj", obj.key)
-                cleaned += 1
-    if cleaned:
-        print(f"cleaned {cleaned} old logs")
-
-if __name__ == "__main__":
-    upload()

+ 29 - 0
skill-package-manifest.json

@@ -0,0 +1,29 @@
+{
+  "name": "skill-agent-log-s3",
+  "version": "2.0.0",
+  "description": "数字生命自报运行日志到个人S3空间 — 零密钥云函数通道,上报CPU/内存/并发/事件指标",
+  "slug": "fmode-skill-agent-log-s3",
+  "displayName": "skill-agent-log-s3",
+  "summary": "数字生命自报运行日志到个人S3空间 — 零密钥云函数通道,上报CPU/内存/并发/事件指标",
+  "platform": "HermesAgent",
+  "level": "系统级",
+  "category": "平台基础设施",
+  "icon": "emoji: 📊",
+  "homepage": "https://git.fmode.cn/fmode/skill-agent-log-s3",
+  "license": "MIT",
+  "author": "Yuyang001 (FmodeAgent)",
+  "install": "npx --yes skill-agent-log-s3@latest report",
+  "changelog": "v2.0.0: 重制为云函数免密钥通道,移除 boto3/obs 依赖,Node.js ESM-first 标准结构",
+  "tags": [
+    "HermesAgent", "FmodeAgent", "系统级", "平台基础设施",
+    "日志上报", "s3-upload", "agent-log", "monitoring", "运行状态", "digital-life"
+  ],
+  "skills": [
+    {
+      "name": "skill-agent-log-s3",
+      "version": "2.0.0",
+      "path": "skills/skill-agent-log-s3/SKILL.md",
+      "description": "数字生命自报运行日志到个人S3空间,零密钥云函数通道"
+    }
+  ]
+}

+ 84 - 0
skills/skill-agent-log-s3/SKILL.md

@@ -0,0 +1,84 @@
+---
+name: skill-agent-log-s3
+description: "触发词:日志上报、性能记录、运行状态、自报指标。数字生命通过云函数免密钥通道把运行状态 JSON 上传到个人 S3 空间 user/<userid>/log/ 目录。"
+version: 2.0.0
+author: Yuyang001 (FmodeAgent)
+license: MIT
+tags: [HermesAgent, FmodeAgent, 系统级, 平台基础设施, 日志上报, agent-log, monitoring]
+---
+
+# skill-agent-log-s3 · 数字生命自报运行日志
+
+> 数字生命通过**云函数免密钥通道**把运行状态写入自己的 S3 空间,
+> 主协调者(雨飏001)每天下载各家日志做汇总分析。
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+
+## 触发场景
+
+当以下情形出现时使用本技能:
+
+- 需要记录 Agent 运行状态(CPU、内存、并发、服务存活)
+- 主协调者需要汇总各 Agent 运行状态
+- cron 定时自动上报(每 6 小时)
+- 重任务开始/结束时额外上报一次
+
+## 如何触发
+
+```bash
+node bin/agent-log.mjs report   # 采集+上传
+node bin/agent-log.mjs check    # 自检
+node bin/agent-log.mjs collect  # 仅采集(不上传)
+node bin/agent-log.mjs config   # 查看配置
+```
+
+## 上报内容(JSON 字段)
+
+| 字段 | 含义 |
+|------|------|
+| `schema_version` | 数据格式版本(2.0) |
+| `timestamp` | 采集时间(ISO8601+08:00) |
+| `agent_id` / `agent` | Agent 标识 |
+| `userid` | 用户 ID(路径前缀来源) |
+| `hostname` | 主机名 |
+| `resources.memory` | 内存 used/total/percent |
+| `resources.cpu_load` | load avg (1m/5m/15m) |
+| `resources.cpu_percent` | CPU 使用率 % |
+| `resources.disk` | 磁盘 used/total/percent |
+| `resources.network` | 主网卡累计上下行 MB |
+| `resources.gpu` | GPU 占用(有则记) |
+| `concurrency.profiles_total` | profile 总数 |
+| `concurrency.active_sessions_30min` | 近 30 分钟活跃 session |
+| `concurrency.running_subagents` | 运行中 subagent 数 |
+| `services.gateway/dashboard/studio` | 服务存活状态 |
+| `events.errors_last_1h` | 最近 1 小时错误计数 |
+| `events.wecom_reconnects_total` | 企微重连总次数 |
+
+## cron 触发示例
+
+```cron
+# 每 6 小时上报一次
+0 */6 * * *  node /opt/skills/agent-log/bin/agent-log.mjs report >> /var/log/agent-log.log 2>&1
+```
+
+## 凭据(零密钥,无需 AK/SK)
+
+| 优先级 | 来源 |
+|--------|------|
+| 1 | 环境变量 `FMODE_SESSION_TOKEN` |
+| 2 | `~/.fmode/config/user.json` → `sessionToken` |
+| 3 | `~/.fmode/config.json` → `sessionToken` |
+| 4 | `./.fmode/config.json` → `sessionToken` |
+
+## 云函数通道
+
+| 项 | 值 |
+|----|----|
+| 端点 | `POST https://server.fmode.cn/api/functions` |
+| 函数 ID | `AlP56LCKFm` (fmodeagent-upload-url v0.2.2) |
+| 对象桶 | `storage-s3-nkkj` (AK/SK 由云函数保管) |
+| 公网地址 | `https://s3.fmode.cn/user/<userid>/log/<YYYYMMDD>/<HHMM>.json` |
+
+## License
+
+MIT © 2026 Fmode (未来飞马)

+ 80 - 0
test/smoke.mjs

@@ -0,0 +1,80 @@
+// 冒烟测试:验证包结构和基础导出
+import { resolveIdentity, identitySummary, makeLogKey } from '../lib/index.mjs';
+import { ENDPOINTS } from '../lib/upload.mjs';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const root = path.join(__dirname, '..');
+
+let pass = 0, fail = 0;
+
+function ok(label, cond) {
+  if (cond) { console.log(`  ✓ ${label}`); pass++; }
+  else       { console.error(`  ✗ ${label}`); fail++; }
+}
+
+console.log('smoke test — skill-agent-log-s3 v2.0.0\n');
+
+// 1. 必要文件存在
+console.log('[1] 文件结构');
+ok('package.json',            existsSync(path.join(root, 'package.json')));
+ok('LICENSE',                 existsSync(path.join(root, 'LICENSE')));
+ok('README.md',               existsSync(path.join(root, 'README.md')));
+ok('SKILL.md',                existsSync(path.join(root, 'SKILL.md')));
+ok('CHANGELOG.md',            existsSync(path.join(root, 'CHANGELOG.md')));
+ok('skill-package-manifest',  existsSync(path.join(root, 'skill-package-manifest.json')));
+ok('lib/index.mjs',           existsSync(path.join(root, 'lib/index.mjs')));
+ok('lib/identity.mjs',        existsSync(path.join(root, 'lib/identity.mjs')));
+ok('lib/upload.mjs',          existsSync(path.join(root, 'lib/upload.mjs')));
+ok('lib/collect.py',          existsSync(path.join(root, 'lib/collect.py')));
+ok('bin/agent-log.mjs',       existsSync(path.join(root, 'bin/agent-log.mjs')));
+ok('skills/.../SKILL.md',     existsSync(path.join(root, 'skills/skill-agent-log-s3/SKILL.md')));
+
+// 2. package.json 关键字段
+console.log('\n[2] package.json');
+import { readFileSync } from 'node:fs';
+const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf-8'));
+ok('type=module',             pkg.type === 'module');
+ok('version=2.0.0',           pkg.version === '2.0.0');
+ok('main=./lib/index.mjs',    pkg.main === './lib/index.mjs');
+ok('exports[.] import',       pkg.exports?.['.']?.import === './lib/index.mjs');
+ok('exports[.] default',      pkg.exports?.['.']?.default === './lib/index.mjs');
+ok('bin agent-log',           pkg.bin?.['agent-log'] === './bin/agent-log.mjs');
+ok('node>=18',                pkg.engines?.node === '>=18');
+ok('no dependencies',         Object.keys(pkg.dependencies || {}).length === 0);
+
+// 3. SDK 导出
+console.log('\n[3] SDK 导出');
+ok('resolveIdentity is fn',   typeof resolveIdentity === 'function');
+ok('identitySummary is fn',   typeof identitySummary === 'function');
+ok('makeLogKey is fn',        typeof makeLogKey === 'function');
+ok('ENDPOINTS.functions',     typeof ENDPOINTS.functions === 'string' && ENDPOINTS.functions.includes('server.fmode.cn'));
+ok('ENDPOINTS.publicBase',    typeof ENDPOINTS.publicBase === 'string' && ENDPOINTS.publicBase.includes('s3.fmode.cn'));
+
+// 4. 身份解析逻辑
+console.log('\n[4] 身份解析');
+const id = resolveIdentity();
+ok('返回对象',                typeof id === 'object' && id !== null);
+ok('有 agentId 字段',         'agentId' in id);
+ok('agentId 非空',            typeof id.agentId === 'string' && id.agentId.length > 0);
+const summary = identitySummary(id);
+ok('summary 不含完整 token',  !summary.sessionToken?.match(/^r:[a-f0-9]{20}/));
+
+// 5. makeLogKey 格式
+console.log('\n[5] 日志键格式');
+const key = makeLogKey();
+ok('以 log/ 开头',            key.startsWith('log/'));
+ok('格式 log/YYYYMMDD/HHMM.json', /^log\/\d{8}\/\d{4}\.json$/.test(key));
+
+// 6. 没有硬编码禁用端点
+console.log('\n[6] 禁用端点检查');
+const uploadSrc = readFileSync(path.join(root, 'lib/upload.mjs'), 'utf-8');
+ok('不含 /api/storage/credentials', !uploadSrc.includes('/api/storage/credentials'));
+ok('不含 /api/storage/upload',      !uploadSrc.includes('/api/storage/upload'));
+ok('不含 S3_AK',                    !uploadSrc.includes('S3_AK'));
+ok('不含 S3_SK',                    !uploadSrc.includes('S3_SK'));
+
+console.log(`\n结果: ${pass} 通过 / ${fail} 失败`);
+if (fail > 0) process.exit(1);