diff --git a/assets/respire/icon.png b/assets/respire/icon.png new file mode 100644 index 0000000..b2c6c8c Binary files /dev/null and b/assets/respire/icon.png differ diff --git a/external_plugins/respire/.zcode-plugin/plugin.json b/external_plugins/respire/.zcode-plugin/plugin.json new file mode 100644 index 0000000..eb99c4a --- /dev/null +++ b/external_plugins/respire/.zcode-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "respire", + "version": "0.1.0", + "description": "respire memory for ZCode: every-round title-tier recall, one-prefix store, model-optional degradation. Register on rsrs.rs for sync; the sidecar model is an optional boost.", + "description_i18n": { + "en": "respire local encrypted memory: every-round title-tier recall, one-prefix store, Stop storage check. Fully usable without the sidecar model.", + "zh-CN": "respire 本地加密记忆:每轮标题档自动召回、一句「记:」落库、Stop 存储检查,不装模型也完全可用。" + }, + "author": { + "name": "risense-ai", + "url": "https://github.com/risense-ai" + }, + "homepage": "https://github.com/risense-ai/respire-zcode", + "repository": "https://github.com/risense-ai/respire-zcode", + "license": "MIT", + "keywords": [ + "memory", + "respire", + "rsrs", + "recall", + "hooks", + "local-first" + ], + "skills": "./skills", + "hooks": "./hooks/hooks.json" +} diff --git a/external_plugins/respire/CHANGELOG.md b/external_plugins/respire/CHANGELOG.md new file mode 100644 index 0000000..a43771d --- /dev/null +++ b/external_plugins/respire/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project are documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). + +## [0.1.0] - 2026-10-23 + +### Added + +- 首版:respire(rsrs)记忆层接入 ZCode——SessionStart 简则注入、UserPromptSubmit 每轮标题档召回、「记:」一前缀落库回执、Stop 存储检查(block 续跑一轮)。 +- ZCode 协议适配(对照 respire-qoder 0.6.3 骨架): + - 输出走严格 JSON:文本 → `{"additionalContext":…}`(ZCode 忽略纯文本 stdout,已核 runtime 源码); + - Stop 阻塞用 `{"decision":"block","reason":…}`(ZCode 枚举 approve|block,不收 `deny`); + - 数据目录探测 `RESPITE_DATA` → `ZCODE_PLUGIN_DATA` → 脚本目录;hooks.json 仅 ZCode schema 收录字段,不带 `commandWindows`。 +- `scripts/validate_plugin.py`(结构/清单/hooks/输出契约/敏感扫描)、`scripts/package.sh`+`release.yml`(tag→release,与 dimcode 同口径)、`tests/test_hooks.sh`(stdin 模拟断言+`--store` 写探针自清理)。 diff --git a/external_plugins/respire/LICENSE b/external_plugins/respire/LICENSE new file mode 100644 index 0000000..a693395 --- /dev/null +++ b/external_plugins/respire/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 fslong + +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. diff --git a/external_plugins/respire/README.md b/external_plugins/respire/README.md new file mode 100644 index 0000000..1ec927a --- /dev/null +++ b/external_plugins/respire/README.md @@ -0,0 +1,76 @@ +# respire + +> Store on inhale, recall on exhale — local encrypted memory for ZCode. + +`respire` (CLI: `rsrs`) is a standard ZCode plugin that wires the respire memory engine into the agent loop with command hooks: title-tier recall injected every turn, one-prefix storing, and a Stop storage check. Local-first, end-side encrypted, cloud LWW sync. + +Sibling builds for other hosts: [respire-dimcode](https://github.com/risense-ai/respire-dimcode) (Dim) · [respire-qoder](https://github.com/risense-ai/respire-qoder) (Qoder) · [respire-workbuddy](https://github.com/risense-ai/respire-workbuddy) (WorkBuddy) · [respire-zcode](https://github.com/risense-ai/respire-zcode) (this plugin's standalone repo). + +## Fully usable without the model (degradation is a first-class citizen) + +- **Registration, login, sync and recall never depend on a local model** — the plugin works right after install; without the model the engine automatically falls back to the lexical tier. +- BGE-M3 is a **pure optional boost**: for better semantic recall run `rsrs model install && rsrs model activate m3`. The plugin notices it is missing and mentions it once in-session; installing or not never blocks anything. + +## What happens after install + +| When | Behavior | +| --- | --- | +| `SessionStart` | Injects a short memory playbook (when to recall, how to store, red lines); **one probe, three states** — signed-out gets registration guidance, an empty store (<10 records) gets first-day seeding/import guidance, a missing semantic model gets the **optional** install hint; zero interruption otherwise | +| `UserPromptSubmit` | Automatic `rsrs recall --titles`: **title-tier injection** — score + 8-char short id + title only; the agent fetches bodies on demand with `rsrs show `. Default is **direct output without threshold filtering**; noise is bounded by the engine's own skip gate + limit 5 + title tier | +| Prompt starts with `记:` / `存:` / `remember:` | Stores directly via `rsrs remember … --importance important` and returns a receipt; when the dedup judge finds a similar record nothing is written — candidates are relayed for the model to decide (`--parent` attach or `--force` write); the receipt requires the model to extract a "subject + action + result" fact sentence and `retitle` | +| `Stop` (agent finished) | **Storage check**: `{"decision":"block","reason":…}` continues one round, nudging the model to store new facts/decisions/agreements/lessons/pitfalls; pure Q&A or chat ends directly. Loop-safe: the continuation round carries `stop_hook_active=true` and passes through (the host caps continuations at three; this layer nudges at most once per turn); throttle `store_nudge_every` (1 = every turn, N = every N turns, 0 = off); if the store saw a write in the last 10 minutes (engine `list`), it stays silent | +| Prompt starts with `/nomem` | Skips recall for that turn | +| `skills/respire` | On-demand playbook (recall/remember/show/forget/sync and envelope parsing notes); **the skill is documentation — actions are hook-driven** | + +## Install + +Install directly from the official plugin market (**Settings → Plugins**, search respire). Alternatively add the standalone repo as a custom market: clone [risense-ai/respire-zcode](https://github.com/risense-ai/respire-zcode), then **Plugin Marketplace → Add → Add Plugin Marketplace** with the repo directory, and install from "Personal → risense-zcode". + +## First use · three things + +1. **Register & sign in** (works out of the box, no model needed): register at [rsrs.rs](https://rsrs.rs) → run `rsrs register` locally; day-to-day `rsrs login` via browser; autosync is always on, `rsrs sync` for manual sync, `rsrs status` for state. +2. **Import existing rules and memory** (one-shot store, LLM extracts): paste this into a new ZCode task — + + > Please import my existing rules and memory into respire: read one by one — `~/.zcode/AGENTS.md` (home rules, if present), workspace-root and per-project `AGENTS.md`, `~/.agents/skills/*/SKILL.md`; extract reusable facts, agreements, lessons and red lines (never passwords, ID numbers, card numbers, private keys — record only where they are stored), dedupe, and store them one by one with `rsrs remember` (`--importance important`, fact-sentence titles; on dedup candidates decide to attach or merge), then give me a list. + +3. **Local model (optional boost)**: `rsrs model install && rsrs model activate m3` — fully usable without it; install for better semantic recall. + +## Tuning (optional) + +`hooks/respire.config.json` inside the installed plugin directory (missing = all defaults; new sessions pick it up; **never touches login or account**; template: `hooks/respire.config.example.json`): + +| Field | Default | Meaning | +| --- | --- | --- | +| `rsrs` | `""` | Engine path; empty = auto-detect (PATH → `~/.local/bin` → `~/.rsrs/bin`) | +| `recall_threshold` | `0.0` | Recall floor; **0 = off by default (engine direct output)** | +| `recall_limit` | `5` | Top N per turn | +| `min_prompt_chars` | `2` | Skip 1-char prompts | +| `session_rules` | `true` | Inject the SessionStart playbook | +| `budget_chars` | `1500` | Per-turn injection budget | +| `enabled` | `true` | **Master switch**: false = fully silent | +| `store_nudge_every` | `1` | Stop-check throttle: 1 = every turn, N = every N turns, 0 = off | +| `skip_prefixes` | `[]` | Host-internal prompts matching these prefixes are neither recalled nor stored | +| `log` | `true` | `hook.log` trace in the data dir (rotated at 256KB) | + +## Dependencies, network and side effects + +- **Required dependency**: the respire engine (`rsrs` CLI, npm package [`@rsrsai/cli`](https://www.npmjs.com/package/@rsrsai/cli), MIT, by risense-ai — the same vendor as this plugin). When missing, the plugin injects the install command (`npm i -g @rsrsai/cli@latest`) for the agent to run in-session; auto-detected at PATH, `~/.local/bin`, `~/.rsrs/bin`. +- **Network**: installing the engine uses npm; `rsrs sync` talks to the respire cloud (end-side encrypted; account optional — local-only mode works). No other network access. +- **Model/API**: optional BGE-M3 sidecar model downloaded via `rsrs model install`; no external LLM API is called by this plugin. +- **Command execution**: hooks invoke `rsrs` (`recall`/`remember`/`list`/`status`) as subprocesses. +- **File writes**: `hook.log` and `.nudge_counter` in the plugin data dir (or the hooks directory as fallback); nothing else. +- **Hooks**: `SessionStart`, `UserPromptSubmit`, `Stop` — all fail-open (empty output + exit 0 on any error, never blocks a session). +- **No MCP server**, no telemetry. +- **Assets**: `assets/respire/icon.png` is the project logo of risense-ai's respire (MIT). Third-party services referenced: rsrs.rs (account/sync, optional), npm registry (engine install). + +## Self-test + +```bash +python3 ../../scripts/validate.py # marketplace structure (repo root) +bash tests/test_hooks.sh # read-only behavior assertions against a real engine +bash tests/test_hooks.sh --store # adds a write probe (writes the real store, then rsrs forget cleanup) +``` + +## License + +MIT diff --git a/external_plugins/respire/README_CN.md b/external_plugins/respire/README_CN.md new file mode 100644 index 0000000..a100ea5 --- /dev/null +++ b/external_plugins/respire/README_CN.md @@ -0,0 +1,76 @@ +# respire + +> 吸即存,呼即取——给 ZCode 装上本地加密记忆。 + +`respire`(CLI 名 `rsrs`)是 ZCode 标准插件:以命令钩子把 respire 记忆引擎挂进 agent 循环——每轮标题档自动召回、「记:」一前缀即落库、Stop 存储检查。本地优先、端侧加密、云端 LWW 同步。 + +其他宿主的姊妹版本:[respire-dimcode](https://github.com/risense-ai/respire-dimcode)(Dim)· [respire-qoder](https://github.com/risense-ai/respire-qoder)(Qoder)· [respire-workbuddy](https://github.com/risense-ai/respire-workbuddy)(WorkBuddy)· [respire-zcode](https://github.com/risense-ai/respire-zcode)(本插件的独立源仓)。 + +## 不装模型完全可用(降级是一等公民) + +- **注册、登录、同步、召回全不依赖端侧模型**——装完插件即可用,未装模型时引擎自动走词面档。 +- BGE-M3 是**纯可选增强**:想要更好的语义召回再 `rsrs model install && rsrs model activate m3`,插件检测到未装会在会话内提示一句,装与不装都不报错、不拦路。 + +## 装上后发生什么 + +| 时机 | 行为 | +| --- | --- | +| `SessionStart` | 注入记忆层简则(何时翻忆、如何落库、红线);**一次探测三态**——未登录附注册引导、库空(<10 条)附首日存料与导入引导、缺语义模型附**可选**装法;常态零打扰 | +| `UserPromptSubmit` | 自动 `rsrs recall --titles`:**标题档注入**——只给分数+8 位短 id+标题,正文由 agent `rsrs show ` 按需取;默认**直出不过滤**,噪音边界=rsrs skip 门+limit 5+标题档 | +| `prompt` 以「记:」「存:」「remember:」开头 | 直接 `rsrs remember … --importance important` 落库并回执;判后即存判为相似时**不写入**、回执转述候选(`--parent` 挂靠或 `--force` 直写),由模型决断;回执硬约束 AI 抽「主语+动作+结果」事实句 `retitle` 替换暂拟题 | +| `Stop`(AI 干完活) | **存储检查**:`{"decision":"block","reason":…}` 续跑一轮,提示模型把本轮新的**事实/决定/约定/教训/踩坑**分条 `rsrs remember`;闲聊无新事实则直接结束。防循环:续跑轮 stdin 带 `stop_hook_active=true` 即放行(宿主限续跑至多三次,本层每轮至多催一次);节流 `store_nudge_every`(默认 1=每轮,N=每 N 轮,0=关);近 10 分钟本库有过落库(问引擎 `list`)则不再催 | +| `prompt` 以 `/nomem` 开头 | 本轮跳过召回 | +| `skills/respire` | 按需加载的操作法(recall/remember/show/forget/sync 与信封解析要点);**注意技能只是说明书,动作全由钩子驱动** | + +## 安装 + +官方插件市场直接安装(**设置 → 插件** 搜索 respire)。也可自建市场源安装:克隆 [risense-ai/respire-zcode](https://github.com/risense-ai/respire-zcode) 后,在 **插件市场 → 添加 → 添加插件市场** 粘贴仓目录,再在「个人 → risense-zcode」安装。 + +## 首次使用 · 三件事 + +1. **注册登录**(开箱即用,不依赖模型):[rsrs.rs](https://rsrs.rs) 注册 → 本机 `rsrs register`;日常 `rsrs login` 浏览器授权;autosync 常开,`rsrs sync` 手动同步,`rsrs status` 查态。 +2. **导入既有记忆与规则(一次成库,LLM 代抽)**:开个 ZCode 新任务粘贴下面整段—— + + > 请把本机的既有规则与记忆导入 respire:逐个读取——`~/.zcode/AGENTS.md`(家目录规则,若在)、工作区根与各项目的 `AGENTS.md`、`~/.agents/skills/*/SKILL.md`;抽出可复用的事实、约定、教训与红线(口令、证件号、卡号、私钥一律排除、只记位置),去重后用 `rsrs remember` 分条存入(`--importance important`、标题按事实句;判重给候选先决断挂靠或合并),存完给我一份清单。 + +3. **端侧模型(可选增强)**:`rsrs model install && rsrs model activate m3`——不装完全可用,想要更好语义召回再装。 + +## 调优(可选) + +插件安装目录下 `hooks/respire.config.json`(缺失即全默认,改完新会话生效;**不涉及登录与账号**;模板见 `hooks/respire.config.example.json`): + +| 字段 | 默认 | 含义 | +| --- | --- | --- | +| `rsrs` | `""` | 引擎路径;空 = 自动探测(PATH → `~/.local/bin` → `~/.rsrs/bin`) | +| `recall_threshold` | `0.0` | 召回下限;**0=默认关(rsrs 直出)**,设正数才启用过滤 | +| `recall_limit` | `5` | 取前 N 条 | +| `min_prompt_chars` | `2` | 1 字才跳,2 字起即查 | +| `session_rules` | `true` | 是否注入 SessionStart 简则 | +| `budget_chars` | `1500` | 单轮注入总预算 | +| `enabled` | `true` | **总开关**:false=全线静默 | +| `store_nudge_every` | `1` | Stop 存储检查节流:1=每轮、N=每 N 轮、0=关 | +| `skip_prefixes` | `[]` | 宿主内部提示白名单:命中前缀的 prompt 不召回不落库 | +| `log` | `true` | 数据目录 `hook.log` 留痕(超 256KB 清轮) | + +## 依赖、网络与副作用 + +- **必需依赖**:respire 引擎(`rsrs` CLI,npm 包 [`@rsrsai/cli`](https://www.npmjs.com/package/@rsrsai/cli),MIT,与本插件同属 risense-ai)。缺失时插件会在会话内注入装法(`npm i -g @rsrsai/cli@latest`)由 agent 代装;探测顺序 PATH → `~/.local/bin` → `~/.rsrs/bin`。 +- **网络**:装引擎走 npm;`rsrs sync` 连 respire 云端(端侧加密;账号可选,纯本机模式可用)。除此之外无网络访问。 +- **模型/API**:可选 BGE-M3 端侧模型经 `rsrs model install` 下载;本插件不调用任何外部 LLM API。 +- **命令执行**:钩子以子进程调用 `rsrs`(`recall`/`remember`/`list`/`status`)。 +- **文件写入**:插件数据目录(回退 hooks 目录)的 `hook.log` 与 `.nudge_counter`;无其他写入。 +- **钩子**:`SessionStart`、`UserPromptSubmit`、`Stop`——全部 fail-open(任何异常空输出+exit 0,绝不拦会话)。 +- **无 MCP server**、无遥测。 +- **素材**:`assets/respire/icon.png` 为 risense-ai respire 项目 logo(MIT)。引用的第三方服务:rsrs.rs(账号/同步,可选)、npm registry(引擎安装)。 + +## 自测 + +```bash +python3 ../../scripts/validate.py # 市场结构(仓根) +bash tests/test_hooks.sh # 行为只读断言(需真机 rsrs 引擎) +bash tests/test_hooks.sh --store # 加写探针(写真库→回执→rsrs forget 自清理) +``` + +## License + +MIT diff --git a/external_plugins/respire/hooks/hooks.json b/external_plugins/respire/hooks/hooks.json new file mode 100644 index 0000000..36c41f0 --- /dev/null +++ b/external_plugins/respire/hooks/hooks.json @@ -0,0 +1,41 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/respire.py\" SessionStart || python \"${CLAUDE_PLUGIN_ROOT}/hooks/respire.py\" SessionStart", + "timeout": 20, + "statusMessage": "respire: injecting memory playbook" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/respire.py\" UserPromptSubmit || python \"${CLAUDE_PLUGIN_ROOT}/hooks/respire.py\" UserPromptSubmit", + "timeout": 20, + "statusMessage": "respire: recalling memory" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/respire.py\" Stop || python \"${CLAUDE_PLUGIN_ROOT}/hooks/respire.py\" Stop", + "timeout": 20, + "statusMessage": "respire: checking storage" + } + ] + } + ] + } +} diff --git a/external_plugins/respire/hooks/respire.config.example.json b/external_plugins/respire/hooks/respire.config.example.json new file mode 100644 index 0000000..9c625ea --- /dev/null +++ b/external_plugins/respire/hooks/respire.config.example.json @@ -0,0 +1,10 @@ +{ + "rsrs": "", + "recall_threshold": 0.0, + "recall_limit": 5, + "min_prompt_chars": 2, + "session_rules": true, + "budget_chars": 1500, + "enabled": true, + "skip_prefixes": [] +} diff --git a/external_plugins/respire/hooks/respire.py b/external_plugins/respire/hooks/respire.py new file mode 100755 index 0000000..461d779 --- /dev/null +++ b/external_plugins/respire/hooks/respire.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +# respire —— respire(rsrs) × ZCode 记忆插件(hook 端单入口) +# 与 respire-qoder / respire-dimcode / respire-workbuddy 同源方案: +# SessionStart 注简则、UserPromptSubmit 每轮召回、Stop 存储检查。 +# 用法: python3 respire.py (事件 JSON 走 stdin) +# ZCode 协议差异(已核 ZCode runtime 源码): +# 1. stdout 不认纯文本——只有以 { 开头且过严格 schema 的 JSON 才被消费, +# 故一切注入走 {"additionalContext": "..."} 顶层键(三事件均注入); +# 输出非法 JSON 会被判 hook 失败,纯文本被静默丢弃。 +# 2. Stop 阻塞续跑的决策枚举是 approve|block——{"decision":"block","reason":...}; +# 其他宿主用的 "deny" 在 ZCode 会过不了 schema。 +# 3. stdin 全量带事件字段(prompt / stop_hook_active / hook_event_name)。 +# 铁律: fail-open —— 任何异常 → 空 stdout、exit 0(协议里即「无决策、放行」),绝不拦会话。 +from __future__ import annotations + +import datetime +import json +import os +import re +import shutil +import subprocess +import sys +import time + +VERSION = "0.1.0" + +# 超时窗(秒):须盖住 rsrs runtime 冷启动(实测 doctor 模型加载 7s+),否则首轮静默、热后即灵 +RECALL_T = 14 +PROBE_T = 10 +STORE_T = 18 +LIST_T = 6 # Stop 查「最近一条落库时间」的窗口(实测 list ~0.9s,热后即回) +LOG_MAX = 256 * 1024 # hook.log 超此整文件清轮 + +DEFAULTS = { + "rsrs": "", # respire 引擎(rsrs)路径;空 = 自动探测 + "recall_threshold": 0.0, # 召回下限,0=默认关(rsrs 直出):skip 门管无关、limit 限流、标题档廉价;设正数才启用过滤 + "recall_limit": 5, # 取前 N 条 + "min_prompt_chars": 2, # 过短不检索(1 字才跳;2 字起即查,rsrs 自带 skip 门、本地 100–400ms) + "session_rules": True, # SessionStart 是否注入用法简则 + "budget_chars": 1500, # 单轮注入总字数预算 + "log": True, # hook.log 轻量留痕(每事件一行,复盘时灵时不灵的抓手) + "enabled": True, # 总开关:false=记忆层全线静默(ZCode 插件页亦可逐组件关 hook) + "store_nudge_every": 1, # Stop 存储检查节流:1=每轮、N=每 N 轮一次、0=关闭(每轮=多一轮模型小调用) + "skip_prefixes": [], # 宿主内部提示白名单:命中这些前缀的 prompt 不召回也不落库(如宿主给自动化任务写的 Recap;默认空,按需配置) +} + +# 显式落库前缀:prompt 以「记:」等开头 → 直接入库并回执 +STORE_PREFIX = re.compile(r"^(?:记|存|记录|记住|remember|store)\s*[::]\s*", re.I) +# 本轮跳过召回 +SKIP_PREFIX = re.compile(r"^(?:/nomem|#nomem)\b") +# 技能指认前缀:$skill: 名称 后才是真任务——技能名会词面污染召回 query,须剥掉 +SKILL_PREFIX = re.compile(r"^\$\s*skill\s*:\s*\S+\s*", re.I) + +RULES = """【respire 记忆层 · 已挂接】本机 respire(rsrs)记忆库随本会话生效;数据本地加密、云端同步(账号注册与登录不归本层管,见 rsrs.rs)。 +1. 答前先翻忆:rsrs recall "项目名+关键词"(--json 结构化、--titles 只要 id+标题);报错、异常先按报错原文翻忆。 +2. 收尾可存:rsrs remember "内容" --title "事实句" --importance important(判后即存:相似会给候选,先决断再发)。**title 是召回与判重的门面,须你从内容抽取「主语+动作+结果」的完整事实句,禁主题分类名、禁硬截断**;prompt 以「记:」开头时由本层先落库保底——回执带暂拟标题与短 id,**你须抽取更准的事实句并 rsrs retitle <短id> --title 替换**。 +3. 每轮自动召回已随 prompt 注入;**未见召回注入、命中不贴题、或话题涉及此前的项目、报错、约定、日期时,换词 rsrs recall 补查再答(同义词、上位词、英文对应词各试一路),可见地调用**;仅当已有注入确实相关方才勿重复。本轮加 /nomem 前缀可关闭自动召回。 +4. 口令、证件号、手机号、卡号、私钥禁入记忆正文与查询词,只写存放位置——库虽加密,密钥泄露即全裸。 +5. 同步 rsrs sync(autosync 常开)、rsrs status 查态、rsrs prompt 读完整工作流;深用法见 respire 技能。""" + +NO_RSRS = """【respire·引擎未装】插件已就绪,只差引擎。请立即代为执行安装,无需等用户: + npm i -g @rsrsai/cli@latest +(无全局写权限时先试:npm config set prefix ~/.npm-global && npm i -g @rsrsai/cli@latest) +装完跑 rsrs --version 确认——本层每轮实时探测,装完同会话内即恢复召回与「记:」落库。 +配置下一步(你顺手引导即可):rsrs status 查登录态;未登录则请用户去 https://rsrs.rs 注册后本机跑 rsrs register --user <名>(登录密码本人设≥8位;**超级密码绝不自造、绝不传 --super**——系统自动生成 A3-… 恢复码并在输出中醒目列出,**务必抄下保存**,丢失则任何设备都解不开记忆);日常 rsrs login;语义模型可选,想要更好召回再 rsrs model install && rsrs model activate m3。若已误填自定义超级密码,跑 rsrs super-reset 重发 A3 码。""" + +LOGIN_NUDGE = """注意:本机 respire 尚未登录——记忆只存本机、不同步、不跨设备。引导用户:去 rsrs.rs 注册后跑 rsrs register(登录密码本人设;**超级密码由系统自动生成 A3-… 恢复码,不要自造,务必保存**);日常登录可由你代跑 rsrs login --oauth --no-open,把授权链接交给用户点击。""" + +EMPTY_NUDGE = """注意:本机 respire 记忆库还是空的——没有记忆可召回,自动召回无动静是正常而非故障。引导:用「记:〈事实〉」由本层落库,或让 agent 先把既有项目的约定、报错教训、关键结论 rsrs remember 存上几条;既有规则与记忆可一次成库——让 agent 读 ~/.zcode/AGENTS.md、工作区根与项目的 AGENTS.md、~/.agents/skills/*/SKILL.md,分析抽条后分条存入(口令证件卡号排除);有货之后每轮召回才有内容。""" + +NO_MODEL = """注意:respire 语义模型(BGE-M3)未装——现为词面档。**完全可用**:注册、登录、同步与召回都不依赖模型;想要更好效果再装:rsrs model install 下载模型,再 rsrs model activate m3 建索引激活召回升为语义档(可代跑,建索引可后台挂起)。""" + +PROBE_FAIL = """注意:respire 状态探测失败(rsrs status 未回)——若是全新机器尚未注册,先引导用户去 rsrs.rs 注册并跑 rsrs register(首设备);否则可能 runtime 冷启动中,首轮召回空转稍候即通,持续无回跑 rsrs doctor 诊断并转告用户。""" + + +_REDACT = [ + (re.compile(r"(?i)(password|passwd|pwd|密码|口令)\s*[:=:]\s*\S+"), r"\1=[REDACTED]"), + (re.compile(r"\b(sk|ghp|gho|github_pat|AKIA|xox)[A-Za-z0-9_-]{8,}\b"), "[REDACTED]"), + (re.compile(r"\b\d{11,19}\b"), "[NUM]"), +] + + +def redact(s: str) -> str: + """日志脱敏:口令式赋值、token 前缀、11-19 位长数字(手机/卡号)。 + 日志只用于诊断,绝不落明文凭据。""" + try: + for pat, rep in _REDACT: + s = pat.sub(rep, s) + except Exception: + pass + return s + + +def data_dir() -> str: + """运行时数据目录(hook.log / .nudge_counter):显式环境变量 → ZCode 插件数据目录 → 脚本目录回退。""" + for env in ("RESPITE_DATA", "ZCODE_PLUGIN_DATA"): + d = os.environ.get(env, "") + if d: + try: + os.makedirs(d, exist_ok=True) + return d + except Exception: + pass + return os.path.dirname(os.path.abspath(__file__)) + + +def log_event(cfg: dict, event: str, outcome: str, detail: str = "", ms=None): + """轻量留痕:hook.log 单行 JSONL、超 256KB 整文件清轮;日志失败绝不影响主链。 + config log:false 关闭。字段:ts/ev/out/ms(耗时毫秒)/d(摘要)。""" + if not cfg.get("log", True): + return + try: + path = os.environ.get("RESPITE_LOG") or os.path.join(data_dir(), "hook.log") + try: + if os.path.exists(path) and os.path.getsize(path) > LOG_MAX: + os.remove(path) + except Exception: + pass + rec = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "ev": event, "out": outcome} + if ms is not None: + rec["ms"] = int(ms) + if detail: + rec["d"] = redact(detail)[:160] + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + except Exception: + pass + + +def _typed(val, ref) -> bool: + if isinstance(ref, bool): + return isinstance(val, bool) + if isinstance(ref, (int, float)): + return isinstance(val, (int, float)) and not isinstance(val, bool) + return isinstance(val, type(ref)) + + +def load_config() -> dict: + cfg = dict(DEFAULTS) + candidates = [os.environ.get("RESPITE_CONFIG") or "", + os.path.join(data_dir(), "respire.config.json"), + os.path.join(os.path.dirname(os.path.abspath(__file__)), "respire.config.json")] + for path in candidates: + if not path: + continue + try: + with open(path, encoding="utf-8") as f: + doc = json.load(f) + if isinstance(doc, dict): + for key, ref in DEFAULTS.items(): + if key in doc and _typed(doc[key], ref): + cfg[key] = doc[key] + break + except Exception: + continue # 配置缺失/损坏 → 用默认值,绝不拦 + return cfg + + +def find_rsrs(cfg: dict): + """引擎探测链:显式配置 → 环境变量 → PATH → 常见安装位。 + 顺序即优先级;rsrs 可能多处并存(npm-global / ~/.local/bin / 自编译 ~/.rsrs/bin),显式路径优先。""" + home = os.path.expanduser("~") + cands = [cfg.get("rsrs") or "", + os.environ.get("RESPITE_RSRS") or "", + os.environ.get("RESPIRE_BIN") or "", + shutil.which("rsrs") or "", + shutil.which("respire") or "", + f"{home}/.local/bin/respire", f"{home}/.local/bin/rsrs", + f"{home}/.rsrs/bin/rsrs", "/usr/local/bin/rsrs", "/usr/bin/rsrs"] + seen = set() + for c in cands: + if not c or c in seen: + continue + seen.add(c) + if os.path.isfile(c) and os.access(c, os.X_OK): + return c + return None + + +def run_rsrs(bin_path: str, args: list, timeout: int): + # rsrs 的 --json 模式下进度写 stderr,capture 须分离,只解析 stdout + return subprocess.run([bin_path] + args, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout) + + +def extract_title(content: str) -> str: + """无 LLM 下尽量抽完整事实句:整句优先 → 退到分隔符 → 绝不在词中硬截(上限 70 字)。 + 引擎不代抽题(retitle --title 必填),抽毕由回执引导 agent 用 retitle 精修。""" + s = re.sub(r"\s+", " ", content.strip()) + if not s: + return "" + m = re.match(r"^(.{6,}?[。!?!?])", s) # 第一个 ≥6 字的完整句 + if m: + return m.group(1)[:70].strip() + head = s[:70] + cuts = [head.rfind(c) for c in ",;、,;"] # 退到分隔符,不切半词 + cut = max(cuts) + if cut >= 20: + return head[:cut + 1].rstrip(",,;; ").strip() + return head # 无标点长串才落到 70 字上界 + + +def do_store(bin_path: str, content: str, cfg: dict) -> str: + """显式落库:判后即存三态——ok 直写 / pending 给候选待决 / 其余为失败,皆回执不静默。""" + if not content: + log_event(cfg, "store", "empty_content") + return "" + title = extract_title(content) or "respire 快存" + t0 = time.time() + try: + r = run_rsrs(bin_path, ["remember", content, "--title", title, + "--importance", "important", "--json"], STORE_T) + except Exception as e: + log_event(cfg, "store", "timeout_or_error", f"{type(e).__name__}: {e}", (time.time() - t0) * 1000) + return "【respire·落库】respire 超时未回,请改手动 rsrs remember 核实是否入库。" + ms = (time.time() - t0) * 1000 + try: + doc = json.loads(r.stdout) if r.stdout else {} + except Exception: + doc = {} + if not isinstance(doc, dict): + doc = {} + status = str(doc.get("status") or "") + if status == "ok": + sid = str((doc.get("summary") or {}).get("id") or "") + log_event(cfg, "store", "ok", sid[:8], ms) + tail = f"({sid[:8]})" if sid else "" + return (f"【respire·落库】已存入:{title}{tail}。标题系规则暂拟——" + "请从内容抽取「主语+动作+结果」的完整事实句," + f"rsrs retitle {sid[:8]} --title 替换(title 是召回与判重的门面)。") + if status == "pending": + # 判后即存判定疑似重复/可挂靠,未写入(written:false)——转述候选,模型接续决断 + log_event(cfg, "store", "pending", f"cand={len(doc.get('items') or [])}", ms) + lines = ["【respire·落库待决】respire 判定本条与既有记忆相似,未写入。候选:"] + for it in (doc.get("items") or [])[:5]: + if isinstance(it, dict): + lines.append(f"- [{it.get('name', '?')}] {it.get('value', '')}") + lines.append("请决断:挂靠则 rsrs remember 同文 --parent ;确属独立则加 --force 直写。") + return "\n".join(lines) + errs = doc.get("errors") or [] + msg = json.dumps(errs, ensure_ascii=False)[:200] if errs else f"status={status or r.returncode}" + log_event(cfg, "store", "fail", msg, ms) + return f"【respire·落库失败】respire 未收:{msg}" + + +def do_recall(bin_path: str, prompt: str, cfg: dict) -> str: + """每轮召回:--titles 标题档(respire 设计——只注分数+短 id+标题,正文由 agent rsrs show 按需取)。 + rsrs 自带相关性门(无关→skip 零命中),本层再按阈值滤掉碰瓷命中。""" + t0 = time.time() + try: + r = run_rsrs(bin_path, ["recall", prompt, "--limit", + str(int(cfg["recall_limit"])), "--titles", "--json"], RECALL_T) + except Exception as e: + log_event(cfg, "recall", "timeout_or_error", f"{type(e).__name__}", (time.time() - t0) * 1000) + return "" + ms = (time.time() - t0) * 1000 + if r.returncode != 0: + log_event(cfg, "recall", "rc_nonzero", f"rc={r.returncode} {r.stderr[:80] if r.stderr else ''}", ms) + return "" + try: + doc = json.loads(r.stdout) + except Exception: + log_event(cfg, "recall", "parse_error", (r.stdout or "")[:80], ms) + return "" + if not isinstance(doc, dict) or doc.get("status") not in ("ok", "skip"): + log_event(cfg, "recall", "status_fail", str(doc.get("status") if isinstance(doc, dict) else "?"), ms) + return "" # fail 静默(fail-open);skip = 自带门判为无关 + details = doc.get("details") or [] + if not isinstance(details, list): + log_event(cfg, "recall", "bad_details", "", ms) + return "" + thr = float(cfg["recall_threshold"]) # 0=直出不过滤;噪音边界交给 rsrs 自带 skip 门+limit+标题档 + keep = [] + max_score = 0.0 + for d in details: + if not isinstance(d, dict): + continue + score = d.get("score") + if isinstance(score, (int, float)): + max_score = max(max_score, float(score)) + if isinstance(score, (int, float)) and float(score) >= thr: + keep.append((float(score), d)) + if len(keep) >= int(cfg["recall_limit"]): + break + if not keep: + log_event(cfg, "recall", + "skip" if doc.get("status") == "skip" else "below_thr", + f"max={max_score:.3f} thr={thr:g} q={prompt[:40]}", ms) + return "" + budget = int(cfg["budget_chars"]) + lines, used = [], 90 # 90 留给头行上界,砍点据此提前 + for score, d in keep: + title = str(d.get("title") or "").strip() or "(无题)" + eid = str(d.get("id") or "")[:8] + stale = " →已被取代" if d.get("superseded_by") else "" + line = f"- [{score:.2f}] {title}({eid}){stale}" + if lines and used + len(line) + 1 > budget: + break + lines.append(line) + used += len(line) + 1 + if not lines: + log_event(cfg, "recall", "budget_empty", "", ms) + return "" + gate = f"阈值 {thr:g}," if thr > 0 else "rsrs 直出," + head = (f"【respire·自动召回】命中 {len(lines)} 条({gate}标题档," + "正文 rsrs show <短id> 按需读;分数是相关度不是可信度):") + log_event(cfg, "recall", "hit", f"n={len(lines)} max={max_score:.3f} q={prompt[:40]}", ms) + return "\n".join([head] + lines) + + +def probe_status(bin_path: str): + """SessionStart 一次 status 三态共用(登录态+库量+探测成败;开销实测热态约 190ms)。 + 返回 (logged, total, probe_ok):logged True/False/None;total int/None; + probe_ok False=status 未回(冷启动/异常),注入 PROBE_FAIL 段。 + 后门:RESPITE_FAKE_LOGGED_OUT=1 / RESPITE_FAKE_EMPTY=1 / RESPITE_FAKE_PROBE_FAIL=1。""" + fake_out = os.environ.get("RESPITE_FAKE_LOGGED_OUT") == "1" + fake_empty = os.environ.get("RESPITE_FAKE_EMPTY") == "1" + if os.environ.get("RESPITE_FAKE_PROBE_FAIL") == "1": + log_event(DEFAULTS, "probe", "fake_fail") + return (None, None, False) + t0 = time.time() + try: + r = run_rsrs(bin_path, ["status", "--json"], PROBE_T) + doc = json.loads(r.stdout) if r.stdout else {} + if not isinstance(doc, dict) or doc.get("status") != "ok": + log_event(DEFAULTS, "probe", "status_not_ok", + str(doc.get("status") if isinstance(doc, dict) else "?"), + (time.time() - t0) * 1000) + return (None, None, False) + summ = doc.get("summary") or {} + sess = summ.get("session") or {} + logged = bool(sess.get("has_token")) and bool(sess.get("user")) + total = summ.get("local_total") + total = int(total) if isinstance(total, int) else None + except Exception as e: + log_event(DEFAULTS, "probe", "error", f"{type(e).__name__}: {e}", + (time.time() - t0) * 1000) + return (None, None, False) + ms = (time.time() - t0) * 1000 + if fake_out: + logged = False + if fake_empty: + total = 0 + log_event(DEFAULTS, "probe", "ok", f"logged={logged} total={total}", ms) + return (logged, total, True) + + +def check_model(): + """语义模型态(SessionStart 专用):~/.rsrs/models/bge-m3 目录存在即已装(stat 零开销)。 + 未装时引擎自带词面回退(源码 indexed==0 → lexical_only),即未配嵌入的引擎效果。 + RESPITE_FAKE_NO_MODEL=1 为自测后门。""" + if os.environ.get("RESPITE_FAKE_NO_MODEL") == "1": + return False + try: + return os.path.isdir(os.path.expanduser("~/.rsrs/models/bge-m3")) + except Exception: + return None # 探测失败不打扰 + + +STORE_NUDGE = ("记忆检查(自动一次):只做一件事——若你本轮产生了新的**事实、决定、约定、教训或踩坑**," + "用 rsrs remember 分条存入(--importance important;标题按「主语+动作+结果」事实句;" + "判重给候选先决断:--parent 挂靠或 --force 直写);" + "若本轮只是问答闲聊而无新事实,直接结束、勿重复作答。存完或无需存即结束。") + + +def _epoch(rfc3339): + """'2026-10-22T15:06:38.926Z' → epoch 秒;解析失败返回 None(不猜时区)。""" + if not isinstance(rfc3339, str) or not rfc3339.strip(): + return None + s = rfc3339.strip() + if s.endswith(("Z", "z")): + s = s[:-1] + "+00:00" + try: + dt = datetime.datetime.fromisoformat(s) + except Exception: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) # 无偏移一律按 UTC(rsrs 输出恒带 Z) + return dt.timestamp() + + +def engine_last_store(engine, timeout: int = LIST_T): + """问引擎最近一条记录的落库时间(秒级 epoch),取 top3 的 updated/created 最大值。 + agent 直接跑 rsrs remember 不写本层 hook.log,节流必须问库才看得见。失败返回 None(放行给上层催)。""" + r = run_rsrs(engine, ["list", "--limit", "3", "--json"], timeout) + doc = json.loads(r.stdout) if r.stdout else None + dets = doc.get("details") if isinstance(doc, dict) else None + best = None + for d in (dets or []): + if not isinstance(d, dict): + continue + for k in ("updated_at", "created_at"): + v = _epoch(d.get(k)) + if v is not None and (best is None or v > best): + best = v + return best + + +def on_stop(data: dict, cfg: dict, engine): + """Stop 存储检查:干完活的存储提示。ZCode 协议(已核 runtime 源码): + {"decision":"block","reason":...} → blockRequested+续跑一轮,reason 注入上下文; + 续跑轮 stdin 带 stop_hook_active=true → 放行。返回 ""(放行)或 dict(阻塞 JSON)。""" + if data.get("stop_hook_active") is True: + log_event(cfg, "stop", "active_skip") + return "" + if not engine: + return "" + every = int(cfg.get("store_nudge_every", 1)) + if every <= 0: + return "" + try: # 节流计数(失败则不拦) + state = os.environ.get("RESPITE_NUDGE_STATE") or os.path.join(data_dir(), ".nudge_counter") + n = 0 + try: + with open(state, encoding="utf-8") as f: + n = int((f.read() or "0").strip()) + except Exception: + n = 0 + n += 1 + with open(state, "w", encoding="utf-8") as f: + f.write(str(n)) + if n % every != 0: + log_event(cfg, "stop", "every_skip", f"n={n} every={every}") + return "" + except Exception: + pass + now = time.time() + try: # 近 10 分钟本层已落库过 → 不再催(记忆活跃):钩子侧「记:」路径写本层日志 + logp = os.environ.get("RESPITE_LOG") or os.path.join(data_dir(), "hook.log") + if os.path.exists(logp): + with open(logp, encoding="utf-8", errors="replace") as f: + for ln in f.readlines()[-50:]: + try: + r = json.loads(ln) + except Exception: + continue + if r.get("ev") == "store" and r.get("out") == "ok": + try: + ts = time.mktime(time.strptime(r.get("ts", ""), "%Y-%m-%dT%H:%M:%S")) + except Exception: + continue + if now - ts < 600: + log_event(cfg, "stop", "stored_recent_skip", "src=hooklog") + return "" + except Exception: + pass + try: # 近 10 分钟引擎侧有落库(agent 直接 rsrs remember/update)→ 同样不催,问库而非看日志 + if engine: + t1 = time.time() + ts = engine_last_store(engine) + if ts is not None and now - ts < 600: + log_event(cfg, "stop", "stored_recent_skip", "src=engine", (time.time() - t1) * 1000) + return "" + except Exception: + pass + log_event(cfg, "stop", "nudge") + return {"decision": "block", "reason": STORE_NUDGE} + + +def on_prompt(data: dict, cfg: dict, engine) -> str: + prompt = data.get("prompt") + if not isinstance(prompt, str): + log_event(cfg, "prompt", "bad_prompt", "非字符串") + return "" + prompt = prompt.strip() + if not prompt or SKIP_PREFIX.match(prompt): + log_event(cfg, "prompt", "nomem_or_empty", prompt[:40]) + return "" + for pat in (cfg.get("skip_prefixes") or []): # 宿主内部提示白名单:不召回不落库 + if isinstance(pat, str) and pat and prompt[:len(pat)].lower() == pat.lower(): + log_event(cfg, "prompt", "internal_skip", pat) + return "" + m = STORE_PREFIX.match(prompt) + if m: + rest = prompt[m.end():].strip() + if not engine: + log_event(cfg, "store", "engine_missing", prompt[:40]) + return NO_RSRS + return do_store(engine, rest, cfg) + if len(prompt) < int(cfg["min_prompt_chars"]): + log_event(cfg, "prompt", "too_short", prompt[:40]) + return "" + if not engine: + log_event(cfg, "recall", "engine_missing", prompt[:40]) + return "" + query = SKILL_PREFIX.sub("", prompt).strip() or prompt # 技能指认轮剥前缀,防技能名污染 query + return do_recall(engine, query, cfg) + + +def main() -> None: + try: + event = (sys.argv[1] if len(sys.argv) > 1 else "") or os.environ.get("ZCODE_HOOK_EVENT", "") + try: + # 事件 JSON 恒为 UTF-8;Windows 非 UTF-8 区域(如 cp1252)下 read() 会按本地码页误解码 + raw = "" if sys.stdin.isatty() else sys.stdin.buffer.read().decode("utf-8", "replace") + except Exception: + try: + raw = "" if sys.stdin.isatty() else sys.stdin.read() + except Exception: + raw = "" + try: + data = json.loads(raw) if raw.strip() else {} + except Exception: + data = {} + if not isinstance(data, dict): + data = {} + if not event: + event = str(data.get("hook_event_name") or "") + except Exception: + data, event = {}, "" + + try: + cfg = load_config() + if not cfg.get("enabled", True): # 总开关:关闭=全线静默(管理器档) + log_event(cfg, event or "unknown", "disabled") + raise SystemExit(0) + engine = find_rsrs(cfg) + if event == "SessionStart": + if not cfg["session_rules"]: + out = "" + elif not engine: + out = NO_RSRS + log_event(cfg, "session", "engine_missing") + else: + out = RULES + logged, total, probe_ok = probe_status(engine) # 一次 status:登录+库量+成败 + segs = [] + if not probe_ok: + out += "\n" + PROBE_FAIL # 探测失败可见(冷启动/异常) + segs.append("probe_fail") + else: + if logged is False: # 仅未登录才附引导,已登录零打扰 + out += "\n" + LOGIN_NUDGE + segs.append("login") + if total is not None and total < 10: # 仅空库首日才附引导 + out += "\n" + EMPTY_NUDGE + segs.append("empty") + if check_model() is False: # 仅缺模型才附装法,已装零打扰 + out += "\n" + NO_MODEL + segs.append("no_model") + log_event(cfg, "session", "ok", "segs=" + (",".join(segs) or "none")) + elif event == "UserPromptSubmit": + out = on_prompt(data, cfg, engine) + elif event == "Stop": + out = on_stop(data, cfg, engine) + else: + out = "" + except Exception as e: + out = "" # fail-open——但必须留痕,否则「时灵时不灵」无从复盘 + try: + log_event(DEFAULTS, event or "unknown", "fatal", f"{type(e).__name__}: {e}") + except Exception: + pass + + if out: + try: # ZCode 只认 JSON:文本 → additionalContext 顶层键;Stop 阻塞已是 dict,原样发 + payload = out if isinstance(out, dict) else {"additionalContext": out} + buf = getattr(sys.stdout, "buffer", None) + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + if buf is None: + sys.stdout.write(data.decode("utf-8")) + sys.stdout.flush() + else: + buf.write(data) + buf.flush() + except Exception: + pass + sys.exit(0) # 恒 exit 0:合法 JSON 即被消费,无输出即无决策,绝不以退出码拦会话 + + +if __name__ == "__main__": + main() diff --git a/external_plugins/respire/skills/respire/SKILL.md b/external_plugins/respire/skills/respire/SKILL.md new file mode 100644 index 0000000..5677d63 --- /dev/null +++ b/external_plugins/respire/skills/respire/SKILL.md @@ -0,0 +1,32 @@ +--- +name: respire +description: respire(rsrs)本地加密记忆操作法。每轮自动召回已随 prompt 注入;**未见注入、命中不贴题、或涉旧(项目、报错、约定、日期)都须换词 rsrs recall 可见补查**(同义词/上位词/英文词分路试),仅当已有注入确实相关方才勿重复;手动翻忆 recall、落库 remember(判后即存)、读全文 show/chain、删除 forget、同步 sync、查态 status;prompt 以「记:」开头由插件直接落库,「/nomem」前缀关闭本轮召回。触发词:记忆、翻忆、recall、remember、存记忆、忘掉、同步。 +--- + +# respire · 记忆操作法(ZCode) + +本插件行为:SessionStart 注入简则;每轮 UserPromptSubmit 自动召回(rsrs 自带无关过滤(零命中即 skip),本层再按阈值兜底);prompt 以「记:」「存:」「remember:」开头由插件先落库保底并回执——**回执带暂拟标题与短 id 时,你须从事实抽取「主语+动作+结果」完整句 `rsrs retitle <短id> --title` 替换(硬约束,title 是召回与判重的门面)**;`/nomem` 关本轮;Stop 时若本轮有新事实而近 10 分钟库无落库,插件催存一轮。 + +## 命令 + +- `rsrs recall "项目名+关键词" --limit 5 --json` —— **答前必查、可见地调**(注入为空绝不等于无记忆,须自行补查);报错先按报错原文翻忆;`--titles` 只要短 id+标题 +- `rsrs remember "内容" --title "事实句" --importance important` —— 一事一条,先判后存 +- `rsrs show <短id>`(前 8 位)读全文;`rsrs chain` 一次取整条因果链 +- `rsrs forget ` —— 墓碑删除,随同步传播 +- `rsrs candidates <内容>` —— 存前候选账(合并/挂靠建议) +- `rsrs sync` 双向同步(autosync 常开);`rsrs status` 查态;`rsrs prompt` 读完整记忆工作流 +- `rsrs tree/resort/retitle` —— 整理因果树 + +## 解析要点(CLI 信封) + +- 一切命令 `--json` 走统一信封:`status`、`summary`、`items`、`details`、`errors`;**失败也是 HTTP 200+`status:"fail"`+`errors[]`**,须查 status 不查退出码 +- recall 的分数与条目在 `details[].score`、`details[].id`(全 id,取前 8 位当短 id)、`details[].title`、`details[].superseded_by`;`items` 只是「分数 标题」行 +- 进度写 stderr,勿 `2>&1` 混进 JSON +- remember 判后即存:`status:"ok"`=已写入(summary.id);`status:"pending"`+`written:false`=判为相似**未写入**,须 `--parent ` 挂靠或 `--force` 直写 +- 默认 `trivial` 档进日记区、不进召回主区且 title 被改「活动轨迹 日期」——事实类落库用 `--importance important` + +## 红线 + +- 口令、证件号、手机号、卡号、私钥禁入记忆正文与查询词,只写存放位置——库虽加密,密钥泄露即全裸 +- 召回分数是相关度不是可信度;存前 candidates 判重,改>并>存 +- 账号与同步配置不归本插件管:注册去 rsrs.rs,本机 `rsrs register`/`rsrs login`,插件不代办 diff --git a/external_plugins/respire/tests/test_hooks.sh b/external_plugins/respire/tests/test_hooks.sh new file mode 100755 index 0000000..f6e09af --- /dev/null +++ b/external_plugins/respire/tests/test_hooks.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# respire(zcode) 自测 —— 模拟 stdin 事件 JSON,逐分支断言。 +# ZCode 输出契约:一切输出为单行 JSON——文本走 {"additionalContext":...},Stop 阻塞走 {"decision":"block",...}。 +# rsrs 无 data-dir 隔离(runtime 单例+全局 client.json),故默认只读分支不碰库; +# 加 --store 才跑写探针(写真库→回执断言→rsrs forget 自清理)。 +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PY3="$(command -v python3)" +RSRS="$(command -v rsrs || true)" +[ -z "$RSRS" ] && RSRS="$HOME/.local/bin/rsrs" +[ -z "$RSRS" ] && RSRS="$HOME/.rsrs/bin/rsrs" +PASS=0; FAIL=0 +WITH_STORE=0 +[ "${1:-}" = "--store" ] && WITH_STORE=1 + +if [ -z "$RSRS" ] || [ ! -x "$RSRS" ]; then + echo "SKIP:未找到 rsrs(respire 引擎),无法自测。"; exit 0 +fi + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +CFG_ON="$TMP/config-on.json" +echo '{"session_rules": true}' > "$CFG_ON" +CFG_OFF="$TMP/config-off.json" +echo '{"session_rules": false}' > "$CFG_OFF" + +# run [prompt] [config] +run() { + local ev="$1" pr="${2-}" cfg="${3:-$CFG_ON}" + "$PY3" - "$ev" "$pr" <<'PY' | RESPITE_CONFIG="$cfg" RESPITE_LOG="$TMP/hook.log" RESPITE_NUDGE_STATE="$TMP/nudge" "$PY3" "$ROOT/hooks/respire.py" "$ev" +import json, sys +ev, pr = sys.argv[1], sys.argv[2] +d = {"hook_event_name": ev, "cwd": "/tmp", "session_id": "selftest"} +if ev == "UserPromptSubmit": + d["prompt"] = pr +if ev == "Stop": + d["stop_hook_active"] = False +sys.stdout.write(json.dumps(d, ensure_ascii=False)) +PY +} + +check() { # check <名字> <实际输出> <模式: 非空|空|含:xxx|不含:xxx> + local name="$1" got="$2" want="$3" ok=0 + case "$want" in + 非空) [ -n "$got" ] && ok=1 ;; + 空) [ -z "$got" ] && ok=1 ;; + 不含:*) printf '%s' "$got" | grep -qF -e "${want#不含:}" && ok=0 || ok=1 ;; + 含:*) printf '%s' "$got" | grep -qF -e "${want#含:}" && ok=1 ;; + esac + if [ "$ok" = 1 ]; then + PASS=$((PASS+1)); echo "PASS $name" + else + FAIL=$((FAIL+1)); echo "FAIL $name 期望[$want] 实际[$(printf '%s' "$got" | head -c 200)]" + fi +} + +# ---- 只读分支(真库查询,不写) ---- +out=$(run SessionStart) +check "SessionStart 注入简则" "$out" "含:respire 记忆层" +check "简则点明引擎与同步" "$out" "含:rsrs.rs" +check "输出为 JSON 且走 additionalContext" "$out" "含:{\"additionalContext\"" +check "输出单行无裸换行" "$( [ "$(printf '%s' "$out" | wc -l | tr -d ' ')" = "0" ] && echo SINGLE || echo MULTI)" "含:SINGLE" +check "additionalContext 可解析且含简则" "$(printf '%s' "$out" | python3 -c ' +import json,sys +d=json.loads(sys.stdin.read()) +print("OK" if "respire 记忆层" in d.get("additionalContext","") else "BAD")')" "含:OK" +check "已登录不带登录引导" "$(run SessionStart)" "不含:尚未登录" +check "未登录注入登录引导" "$(RESPITE_FAKE_LOGGED_OUT=1 run SessionStart)" "含:尚未登录" +check "未登录引导给出注册与代登法" "$(RESPITE_FAKE_LOGGED_OUT=1 run SessionStart)" "含:rsrs login --oauth --no-open" +check "session_rules=false 静默" "$(run SessionStart "" "$CFG_OFF")" "空" +check "已装模型不带提示" "$(run SessionStart)" "不含:语义模型(BGE-M3)未装" +check "缺模型注入装法" "$(RESPITE_FAKE_NO_MODEL=1 run SessionStart)" "含:rsrs model activate m3" +check "缺模型点明词面回退" "$(RESPITE_FAKE_NO_MODEL=1 run SessionStart)" "含:词面档" +check "有货库不带空库引导" "$(run SessionStart)" "不含:记忆库还是空的" +check "空库注入首日引导" "$(RESPITE_FAKE_EMPTY=1 run SessionStart)" "含:记忆库还是空的" +check "空库引导含导入线索" "$(RESPITE_FAKE_EMPTY=1 run SessionStart)" "含:~/.zcode/AGENTS.md" +check "探测失败注入冷启诊断段" "$(RESPITE_FAKE_PROBE_FAIL=1 run SessionStart)" "含:状态探测失败" +check "README 载三件事(模型可选)" "$(cat "$ROOT/README.md")" "含:不装模型完全可用" +check "README 载市场装法" "$(cat "$ROOT/README.md")" "含:添加插件市场" +check "包内无自定义安装脚本" "$( { [ -e "$ROOT/install.sh" ] || [ -e "$ROOT/uninstall.sh" ]; } && echo PRESENT || echo absent )" "含:absent" +# 日志留痕:任一事件后 hook.log 应有本行 +run SessionStart >/dev/null +run UserPromptSubmit "我的密码是 password: hunter2 这算敏感吗" >/dev/null +check "日志脱敏口令" "$(grep -q 'hunter2' "$TMP/hook.log" && echo LEAK || echo SAFE)" "含:SAFE" +check "日志脱敏留痕" "$(grep -q 'REDACTED' "$TMP/hook.log" && echo YES)" "含:YES" +check "hook.log 留痕" "$(grep -q '"ev": "session"' "$TMP/hook.log" 2>/dev/null && echo YES)" "含:YES" + +# 引擎缺失分支:单元注入 monkeypatch find_rsrs→None(真机造不出「全路径皆缺」) +run_engineless() { + ROOT="$ROOT" "$PY3" - <<'PY' +import io, json, os, sys, importlib.util +spec = importlib.util.spec_from_file_location("respire", os.path.join(os.environ["ROOT"], "hooks/respire.py")) +m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +m.find_rsrs = lambda cfg: None +sys.argv = ["respire.py", "SessionStart"] +sys.stdin = io.StringIO('{"hook_event_name":"SessionStart"}') +try: + m.main() +except SystemExit: + pass +PY +} +check "引擎缺失注入装法" "$(run_engineless)" "含:npm i -g @rsrsai/cli" +check "引导禁自造超级密码" "$(run_engineless)" "含:绝不传 --super" +check "引导要求保存恢复码" "$(run_engineless)" "含:务必抄下保存" +check "引擎缺失含注册站点" "$(run_engineless)" "含:rsrs.rs" +check "probe失败含未注册分支" "$(RESPITE_FAKE_PROBE_FAIL=1 run SessionStart)" "含:尚未注册" + +# Stop 存储检查节流分支的引擎桩:只回 list --json,fresh=刚落库 / stale=陈旧 +# (真机库随时有人写,不能拿真引擎断言催/不催) +STUB_FRESH="$TMP/rsrs-fresh"; STUB_STALE="$TMP/rsrs-stale" +FRESH_TS="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" +cat > "$STUB_FRESH" < "$STUB_STALE" <<'EOF' +#!/usr/bin/env bash +[ "${1:-}" = list ] && printf '{"command":"list","status":"ok","details":[{"id":"bbbbbbbbbbbbbbbb","created_at":"2020-01-01T00:00:00.000Z","updated_at":"2020-01-01T00:00:00.000Z","title":"stale","content":"x"}]}' +exit 0 +EOF +chmod +x "$STUB_FRESH" "$STUB_STALE" +CFG_STALE="$TMP/config-stale.json" +echo '{"session_rules": true, "rsrs": "'"$STUB_STALE"'"}' > "$CFG_STALE" +CFG_FRESH="$TMP/config-fresh.json" +echo '{"session_rules": true, "rsrs": "'"$STUB_FRESH"'"}' > "$CFG_FRESH" + +# Stop 存储检查:(节流断言一律走 stale 引擎桩,保证「仍催」确定性) +run_stop() { + "$PY3" - "$1" <<'PY' | RESPITE_CONFIG="$CFG_STALE" RESPITE_LOG="$TMP/hook.log" RESPITE_NUDGE_STATE="$TMP/nudge" "$PY3" "$ROOT/hooks/respire.py" Stop +import json, sys +print(json.dumps({"hook_event_name": "Stop", "stop_hook_active": sys.argv[1] == "true"})) +PY +} +check "Stop 存储检查续跑(decision block)" "$(run_stop false)" '含:"decision": "block"' +check "Stop 阻塞输出可过 JSON 解析" "$(run_stop false | python3 -c ' +import json,sys +d=json.loads(sys.stdin.read()) +print("OK" if d.get("decision")=="block" and "rsrs remember" in d.get("reason","") else "BAD")')" "含:OK" +check "Stop 提示含存储指令" "$(run_stop false)" "含:rsrs remember" +check "Stop 无 deny 决策(ZCode 枚举外)" "$(run_stop false | grep -qF '"deny"' && echo HAS || echo CLEAN)" "含:CLEAN" +check "Stop 防重入放行" "$(run_stop true)" "空" +CFG_NUDGE0="$TMP/config-nudge0.json" +echo '{"store_nudge_every": 0}' > "$CFG_NUDGE0" +run_stop_cfg() { # $1=config + local cfg="$1" + "$PY3" - <<'PY' | RESPITE_CONFIG="$cfg" RESPITE_LOG="$TMP/hook.log" RESPITE_NUDGE_STATE="$TMP/nudge" "$PY3" "$ROOT/hooks/respire.py" Stop +import json +print(json.dumps({"hook_event_name": "Stop", "stop_hook_active": False})) +PY +} +check "Stop 节流关闭静默" "$(run_stop_cfg "$CFG_NUDGE0")" "空" +check "Stop 引擎近期落库不催" "$(run_stop_cfg "$CFG_FRESH")" "空" +check "Stop 引擎陈旧落库仍催" "$(run_stop false)" '含:"decision": "block"' +check "引擎放行留痕 src=engine" "$(grep -q 'stored_recent_skip.*src=engine' "$TMP/hook.log" && echo YES)" "含:YES" +printf '%s\n' '{"ts": "'"$(date +%Y-%m-%dT%H:%M:%S)"'", "ev": "store", "out": "ok", "d": "feed"}' >> "$TMP/hook.log" +check "Stop 已存放行放行" "$(run_stop false)" "空" +CFG_OFFE="$TMP/config-disabled.json" +echo '{"enabled": false}' > "$CFG_OFFE" +check "enabled=false 全线静默" "$(run SessionStart "" "$CFG_OFFE")" "空" +check "enabled=false 落库也静默" "$(run UserPromptSubmit "记:关断时不应落库" "$CFG_OFFE")" "空" + +out=$(run UserPromptSubmit "rsrs 信封 JSON 结构解析取哪层") +check "相关 prompt 触发召回" "$out" "非空" +check "召回也走 additionalContext" "$out" "含:{\"additionalContext\"" +check "注入为标题档无正文" "$out" "不含:|" +check "默认直出无阈值拦截" "$out" "含:rsrs 直出" +check "召回头行与条数一致" "$(printf '%s' "$out" | python3 -c ' +import re,json,sys +s=json.loads(sys.stdin.read())["additionalContext"]; head=s.split("\n",1)[0] +n=sum(1 for l in s.split("\n") if l.startswith("- [")) +m=re.search(r"命中 (\d+) 条",head) +print("OK" if m and int(m.group(1))==n else "BAD")')" "含:OK" + +check "无关 prompt 不注入" "$(run UserPromptSubmit "今晚吃什么菜好呢")" "空" +check "单字 prompt 跳过" "$(run UserPromptSubmit "嗯")" "空" +check "/nomem 关闭召回" "$(run UserPromptSubmit "/nomem rsrs 信封结构")" "空" +check "技能指认轮剥前缀仍召回" "$(run UserPromptSubmit '$skill: 笔痕 rsrs 信封 JSON 结构解析取哪层')" "含:自动召回" +echo '{"session_rules": true, "skip_prefixes": ["你正在为宿主写 Recap"]}' > "$TMP/config-skip.json" +check "skip_prefixes 白名单跳过" "$(run UserPromptSubmit "你正在为宿主写 Recap:无事" "$TMP/config-skip.json")" "空" +check "坏 stdin 静默" "$(printf 'not-json{{{' | RESPITE_CONFIG="$CFG_ON" "$PY3" "$ROOT/hooks/respire.py" UserPromptSubmit)" "空" +check "未知事件静默" "$(run PreToolUse "rsrs 信封")" "空" + +# ---- title 规则抽取(纯函数,零污染) ---- +check "整句优先取完整句" "$(python3 -c ' +import sys,importlib.util +spec=importlib.util.spec_from_file_location("r","'"$ROOT"'/hooks/respire.py"); m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +print(m.extract_title("respire 插件路径迁至 respire-zcode 下。后续以仓库为准。"))')" "含:respire 插件路径迁至 respire-zcode 下。" +check "无句号退分隔符不切半词" "$(python3 -c ' +import sys,importlib.util +spec=importlib.util.spec_from_file_location("r","'"$ROOT"'/hooks/respire.py"); m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +t=m.extract_title("这是一条没有句号的长事实句,讲清楚何时谁做了什么事,以及结果如何,后面还有别的内容") +print(t)')" "含:讲清楚何时谁做了什么事," +check "长串不超70字" "$(python3 -c ' +import sys,importlib.util +spec=importlib.util.spec_from_file_location("r","'"$ROOT"'/hooks/respire.py"); m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +print(len(m.extract_title("默" * 200)))')" "非空" + +# ---- 写探针(--store 显式开启;写真库→断言→forget 清理) ---- +if [ "$WITH_STORE" = 1 ]; then + # 内容须与全库零语义关联:纯随机无义串(域词会撞相似走待决态) + MARK="zq$(date +%s)$RANDOM kw7f2 随机标记无义串" + out=$(run UserPromptSubmit "记:$MARK") + check "落库成功回执" "$out" "含:已存入" + check "回执约束AI抽事实句retitle" "$out" "含:rsrs retitle" + SID=$(printf '%s' "$out" | sed -n 's/.*(\([0-9a-f]\{8\}\)).*/\1/p') + check "回执带短 id" "$SID" "非空" + if [ -n "$SID" ]; then + rc=$(rsrs forget "$SID" --json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status","err"))') + check "探针清理(status=ok)" "$rc" "含:ok" + fi + # 判重待决:复述库中稳定条目 → 大概率撞高相似 → pending 未写入。 + # 断言依赖实时库内容(教训:零命中必 FAIL)——判直写则兜底清理后记 SKIP,不硬失败。 + out=$(run UserPromptSubmit "记:1memory 更名 respire:吸即存呼即取,材料全线改(复述既有条目触发判重待决)") + if printf '%s' "$out" | grep -qF "已存入"; then + BID=$(printf '%s' "$out" | sed -n 's/.*(\([0-9a-f]\{8\}\)).*/\1/p') + [ -n "$BID" ] && rsrs forget "$BID" --json >/dev/null 2>&1 + PASS=$((PASS+1)); echo "SKIP 判重待决转述候选(库况变化判为直写,已清理 $BID)" + else + check "判重待决转述候选" "$out" "含:落库待决" + check "待决列出候选与决断法" "$out" "含:--force" + fi +else + echo "SKIP 写探针(默认只读;加 --store 开启,写真库并自动 forget 清理)" +fi + +echo "----" +echo "PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/marketplace.json b/marketplace.json index 350c942..13efd73 100644 --- a/marketplace.json +++ b/marketplace.json @@ -10,7 +10,10 @@ "url": "https://z.ai" }, "unlistedPlugins": [ - { "name": "openpencil", "source": "./external_plugins/openpencil" } + { + "name": "openpencil", + "source": "./external_plugins/openpencil" + } ], "plugins": [ { @@ -793,7 +796,9 @@ }, { "name": "excalidraw", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "Excalidraw", "displayName_i18n": { "en": "Excalidraw", @@ -816,7 +821,9 @@ "name": "photocraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/photocraft/icon.png", "source": "./external_plugins/photocraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PhotoCraft", "displayName_i18n": { "en": "PhotoCraft", @@ -843,7 +850,9 @@ "name": "vectorcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/vectorcraft/icon.png", "source": "./external_plugins/vectorcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "VectorCraft", "displayName_i18n": { "en": "VectorCraft", @@ -870,7 +879,9 @@ "name": "pdfcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/pdfcraft/icon.png", "source": "./external_plugins/pdfcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PdfCraft", "displayName_i18n": { "en": "PdfCraft", @@ -897,7 +908,9 @@ "name": "designcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/designcraft/icon.png", "source": "./external_plugins/designcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DesignCraft", "displayName_i18n": { "en": "DesignCraft", @@ -924,7 +937,9 @@ "name": "wordcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/wordcraft/icon.png", "source": "./external_plugins/wordcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "WordCraft", "displayName_i18n": { "en": "WordCraft", @@ -951,7 +966,9 @@ "name": "gridcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/gridcraft/icon.png", "source": "./external_plugins/gridcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "GridCraft", "displayName_i18n": { "en": "GridCraft", @@ -978,7 +995,9 @@ "name": "deckcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/deckcraft/icon.png", "source": "./external_plugins/deckcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DeckCraft", "displayName_i18n": { "en": "DeckCraft", @@ -1005,7 +1024,9 @@ "name": "cadcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/cadcraft/icon.png", "source": "./external_plugins/cadcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "CADCraft", "displayName_i18n": { "en": "CADCraft", @@ -1032,7 +1053,9 @@ "name": "soundcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/soundcraft/icon.png", "source": "./external_plugins/soundcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "SoundCraft", "displayName_i18n": { "en": "SoundCraft", @@ -1115,7 +1138,9 @@ }, "category": "developer-tools", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/kicad/icon.png", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "KiCad", "displayName_i18n": { "en": "KiCad", @@ -1386,6 +1411,35 @@ "unity-cli", "skills" ] + }, + { + "name": "respire", + "displayName": "respire", + "displayName_i18n": { + "en": "respire", + "zh-CN": "respire 记忆" + }, + "source": "./external_plugins/respire", + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/respire/icon.png", + "description": "respire local encrypted memory: every-round title-tier recall, one-prefix store, Stop storage check. Fully usable without the sidecar model.", + "description_i18n": { + "en": "respire local encrypted memory: every-round title-tier recall, one-prefix store, Stop storage check. Fully usable without the sidecar model.", + "zh-CN": "respire 本地加密记忆:每轮标题档自动召回、一句「记:」落库、Stop 存储检查,不装模型也完全可用。" + }, + "version": "0.1.0", + "author": { + "name": "risense-ai", + "url": "https://github.com/risense-ai" + }, + "homepage": "https://github.com/risense-ai/respire-zcode", + "category": "productivity", + "keywords": [ + "memory", + "recall", + "hooks", + "local-first", + "rsrs" + ] } ] }