Compare commits
131
Commits
d6f9c4956b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e969b88f69 | ||
|
|
a202b76bb2 | ||
|
|
ec6fc5f50b | ||
|
|
3f2279c3a3 | ||
|
|
1195f856e2 | ||
|
|
be8ff3f4d2 | ||
|
|
971cc58d14 | ||
|
|
0d3bd72de3 | ||
|
|
e17589ad04 | ||
|
|
3dccddf055 | ||
|
|
815f34377b | ||
|
|
d2ffbdae17 | ||
|
|
37022854b8 | ||
|
|
62d2b8b436 | ||
|
|
0bb0fa402e | ||
|
|
b66ee5bade | ||
|
|
93ec62db9c | ||
|
|
7d784bf729 | ||
|
|
e0ebd17492 | ||
|
|
aa9baf491a | ||
|
|
b6810f4c49 | ||
|
|
ce22a83a1b | ||
|
|
ee8a70d7b7 | ||
|
|
5ee39ee2e3 | ||
|
|
220f23234a | ||
|
|
3ff864397a | ||
|
|
e683a70e95 | ||
|
|
c1995944d9 | ||
|
|
93757b3036 | ||
|
|
b75060bb3b | ||
|
|
e47f4873c7 | ||
|
|
69f9ca29a6 | ||
|
|
e43f303ff6 | ||
|
|
9512293dc9 | ||
|
|
adc5521302 | ||
|
|
70d0426366 | ||
|
|
2cbd4b5ba5 | ||
|
|
7b3b4006fa | ||
|
|
8a4ca14b7c | ||
|
|
4281694126 | ||
|
|
32a5104c36 | ||
|
|
fca908edfa | ||
|
|
d376930ee5 | ||
|
|
b9b6bb57d0 | ||
|
|
6d1a25eeab | ||
|
|
c6b6c48b46 | ||
|
|
784ef07518 | ||
|
|
4600379307 | ||
|
|
711a434cac | ||
|
|
cde79e3d3f | ||
|
|
46caabe4d8 | ||
|
|
4a06d770b6 | ||
|
|
e250fcbfce | ||
|
|
627f8e6b90 | ||
|
|
32ed9ab004 | ||
|
|
4dd249f184 | ||
|
|
9bf32a21d2 | ||
|
|
cfa44dea7f | ||
|
|
3c0214a927 | ||
|
|
3b1c466202 | ||
|
|
35010bf8f1 | ||
|
|
c24f6deaed | ||
|
|
afb29b6bc0 | ||
|
|
f3a62e6176 | ||
|
|
a757f9e4b0 | ||
|
|
a30fccc627 | ||
|
|
40c2d2ae46 | ||
|
|
f76915bc87 | ||
|
|
241261087b | ||
|
|
fa62d1fce2 | ||
|
|
e4c8cfc046 | ||
|
|
b36fb12416 | ||
|
|
a190463b50 | ||
|
|
7d41343560 | ||
|
|
97c76bc035 | ||
|
|
70efcd55f2 | ||
|
|
e10865ced7 | ||
|
|
12e7d61114 | ||
|
|
48dc9ea74a | ||
|
|
bf599e81a5 | ||
|
|
c323c65b56 | ||
|
|
2225b91766 | ||
|
|
54628d8b2c | ||
|
|
db492e6ba7 | ||
|
|
c9468fda59 | ||
|
|
932aacf8f0 | ||
|
|
28c2b69800 | ||
|
|
4c5e7034ff | ||
|
|
131cda2978 | ||
|
|
d14ca6556c | ||
|
|
0ebaf8991b | ||
|
|
3c7c3d16b0 | ||
|
|
f302adea1b | ||
|
|
12430131f4 | ||
|
|
a9375e8563 | ||
|
|
0b9e153cda | ||
|
|
c904f4bb9b | ||
|
|
1f365efa02 | ||
|
|
10bae7b6f4 | ||
|
|
f70c010cc5 | ||
|
|
e477e0d1a4 | ||
|
|
177cc9b2bc | ||
|
|
2149e7a3c2 | ||
|
|
9978562092 | ||
|
|
b0c93441a1 | ||
|
|
16125e77e8 | ||
|
|
945c310551 | ||
|
|
bb48a77dd4 | ||
|
|
6d5c4e3f02 | ||
|
|
65426f0c35 | ||
|
|
c713359fb4 | ||
|
|
9a38f15262 | ||
|
|
3748d2e61a | ||
|
|
32f2872dd1 | ||
|
|
294a2c1e1c | ||
|
|
ac8023861d | ||
|
|
f0018fd2a0 | ||
|
|
485f506a28 | ||
|
|
a3fcd62a4c | ||
|
|
3a3114b8b3 | ||
|
|
78f4086a48 | ||
|
|
57a493e612 | ||
|
|
fec3e1d0cb | ||
|
|
e71f165e9a | ||
|
|
716fd2c26e | ||
|
|
c7b7e684a5 | ||
|
|
0fae7e1723 | ||
|
|
d54d6ed8e2 | ||
|
|
d91c293666 | ||
|
|
32a94cb6df | ||
|
|
6ba3a4d4cf |
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "generic",
|
"name": "shared",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "jsc",
|
"name": "jsc-shared",
|
||||||
"source": {
|
"source": {
|
||||||
"source": "url",
|
"source": "url",
|
||||||
"url": "https://gitea.jsc.idv.tw/plugins/generic.git"
|
"url": "https://gitea.jsc.idv.tw/plugins/shared.git"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "generic",
|
"name": "shared",
|
||||||
"description": "JSC 跨 AI 助理共用規範 skills 的 Claude Code marketplace。",
|
"description": "JSC 跨 AI 助理共用規範 skills 的 Claude Code marketplace。",
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "JSC"
|
"name": "JSC"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "jsc",
|
"name": "jsc-shared",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"description": "JSC 共用規範 skills(跨 AI 助理)"
|
"description": "JSC 共用規範 skills(跨 AI 助理),另含整組 plugin 的安裝管理 plugins-install / plugins-uninstall"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc",
|
"name": "jsc-shared",
|
||||||
"version": "0.0.4",
|
"version": "0.0.8",
|
||||||
"description": "JSC 跨 AI 助理共用規範 plugin(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc: 前綴呼叫。",
|
"description": "JSC 跨 AI 助理共用規範 plugin(Claude Code / Codex / GitHub Copilot CLI / Antigravity / OpenCode),並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準;於 Claude Code 以 /jsc-shared: 前綴呼叫。",
|
||||||
"skills": "./skills",
|
"skills": "./skills",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "JSC"
|
"name": "JSC"
|
||||||
},
|
},
|
||||||
"homepage": "https://gitea.jsc.idv.tw/plugins/generic",
|
"homepage": "https://gitea.jsc.idv.tw/plugins/shared",
|
||||||
"repository": "https://gitea.jsc.idv.tw/plugins/generic.git",
|
"repository": "https://gitea.jsc.idv.tw/plugins/shared.git",
|
||||||
"keywords": ["spec", "skills", "cross-tool", "jsc"]
|
"keywords": ["spec", "skills", "cross-tool", "jsc"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc",
|
"name": "jsc-shared",
|
||||||
"version": "0.0.4",
|
"version": "0.0.8",
|
||||||
"description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。",
|
"description": "JSC 跨 AI 助理共用規範 skills plugin,`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準。",
|
||||||
"skills": "./skills"
|
"skills": "./skills/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# jsc — 共用 Skills(跨 AI 助理)
|
# jsc-shared — 共用 Skills(跨 AI 助理)
|
||||||
|
|
||||||
本 repo 是一組以 **Agent Skills(`SKILL.md`)** 標準撰寫的共用 skills,可同時被 Claude Code、Codex、Antigravity、OpenCode 使用。
|
本 repo 是一組以 **Agent Skills(`SKILL.md`)** 標準撰寫的共用 skills,可同時被 Claude Code、Codex、Antigravity、OpenCode 使用。
|
||||||
|
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
- 所有可用的 skills 位於本 repo 的 `skills/<name>/SKILL.md`。
|
- 所有可用的 skills 位於本 repo 的 `skills/<name>/SKILL.md`。
|
||||||
- 在處理任務前,先比對使用者需求與各 skill `SKILL.md` frontmatter 的 `description`,若相符請載入並依其步驟執行。
|
- 在處理任務前,先比對使用者需求與各 skill `SKILL.md` frontmatter 的 `description`,若相符請載入並依其步驟執行。
|
||||||
- **呼叫慣例**:在 Claude Code 與 Antigravity 中,這些 skill 以 `/jsc:<name>` 呼叫;Codex 以 `$<name>`、OpenCode 由模型依描述自動觸發 — 兩者沒有 `/jsc:` 前綴,不需強制加。
|
- **呼叫慣例**:在 Claude Code 與 Antigravity 中,這些 skill 以 `/jsc-shared:<name>` 呼叫;Codex 以 `$<name>`、OpenCode 由模型依描述自動觸發 — 兩者沒有 `/jsc-shared:` 前綴,不需強制加。
|
||||||
- 完整清單與每個 skill 的用途,請見 `README.md` 的「Skills 目錄」。
|
- 完整清單與每個 skill 的用途,請見 `README.md` 的「Skills 目錄」。
|
||||||
- 部分 skill 帶可執行元件(`scripts/`)或 hook(`hooks/hooks.json`),**並非四家助理都適用**;載入前請看該 skill `description` 標示的支援範圍與 `README.md` 的「元件對各助理的適用範圍」。`hooks/hooks.json` 只有 Claude Code 會讀;以複製 `skills/` 目錄安裝的環境(OpenCode)不會帶入 `scripts/`,依賴腳本的 skill 一律不可用。
|
- 本 repo 只保留純 `skills/` 內容;載入前請看各 skill `description` 標示的支援範圍與 `README.md` 的「Skills 目錄」。沒有 plugin 匯入指令、但可使用 skill 的助理,統一先 clone 技能組到工具專屬資料夾,再依 `README.md` 的指定位置匯入。
|
||||||
|
|
||||||
## 慣例
|
## 慣例
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
# jsc — 跨 AI 助理共用規範 Plugin
|
# jsc-shared — 跨 AI 助理共用規範 Plugin
|
||||||
|
|
||||||
一個可同時被 **Claude Code、Codex、Antigravity、OpenCode、GitHub Copilot** 使用的共用規範 plugin。
|
一個可同時被 **Claude Code、Codex、Antigravity、OpenCode、GitHub Copilot** 使用的共用規範 plugin。
|
||||||
核心是以 [Agent Skills(`SKILL.md`)](https://agentskills.io) 標準撰寫的共用 skills(唯一真實來源放在 `skills/`),
|
核心是以 [Agent Skills(`SKILL.md`)](https://agentskills.io) 標準撰寫的共用 skills(唯一真實來源放在 `skills/`),
|
||||||
搭配各助理各自的 plugin manifest,讓**同一個 repo** 可用各家**原生 plugin CLI** 安裝。
|
搭配各助理各自的 plugin manifest,讓**同一個 repo** 可用各家**原生 plugin CLI** 安裝。
|
||||||
在 Claude Code 與 Antigravity 中,skill 以 **`/jsc:` 前綴**呼叫(例如 `/jsc:spec-output`)。
|
在 Claude Code 與 Antigravity 中,skill 以 **`/jsc-shared:` 前綴**呼叫(例如 `/jsc-shared:spec-output`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 前綴與呼叫方式
|
## 前綴與呼叫方式
|
||||||
|
|
||||||
| 助理 | 安裝方式 | 呼叫 | `/jsc:` 前綴 |
|
| 助理 | 安裝方式 | 呼叫 | `/jsc-shared:` 前綴 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Claude Code | `claude plugin`(marketplace) | `/jsc:<name>` 或自動觸發 | ✅ |
|
| Claude Code | `claude plugin`(marketplace) | `/jsc-shared:<name>` 或自動觸發 | ✅ |
|
||||||
| Codex | `codex plugin`(marketplace) | `$<name>` 或 `/skills` 選單 | ❌(用 `$name`) |
|
| Codex | `codex plugin`(marketplace) | `$<name>` 或 `/skills` 選單 | ❌(用 `$name`) |
|
||||||
| Antigravity | `agy plugin install` | `/jsc:<name>` 或自動觸發 | ✅ |
|
| Antigravity | `agy plugin install` | `/jsc-shared:<name>` 或自動觸發 | ✅ |
|
||||||
| OpenCode | skills 目錄(複製/clone) | 描述需求自動觸發 | ❌(依名稱) |
|
| OpenCode | skills 目錄(先 clone 到工具專屬資料夾,再依 README 匯入) | 描述需求自動觸發 | ❌(依名稱) |
|
||||||
| GitHub Copilot CLI | `copilot plugin`(marketplace) | 自然語言或 plugin skills | ❌(無 `/jsc:` 前綴) |
|
| GitHub Copilot CLI | `copilot plugin`(marketplace) | 自然語言或 plugin skills | ❌(無 `/jsc-shared:` 前綴) |
|
||||||
|
|
||||||
> Codex 不支援自訂前綴(skill 以 `$name` 呼叫);OpenCode 由模型依描述自動呼叫;Copilot CLI 透過原生 plugin 安裝後以自然語言或 plugin skills 使用。三者皆**不強制**前綴。
|
> Codex 不支援自訂前綴(skill 以 `$name` 呼叫);OpenCode 由模型依描述自動呼叫;Copilot CLI 透過原生 plugin 安裝後以自然語言或 plugin skills 使用。三者皆**不強制**前綴。
|
||||||
|
|
||||||
@@ -26,117 +26,120 @@
|
|||||||
同一個 repo 同時帶四種 manifest,彼此以路徑隔離、互不干擾;各助理都讀同一份 `skills/`。
|
同一個 repo 同時帶四種 manifest,彼此以路徑隔離、互不干擾;各助理都讀同一份 `skills/`。
|
||||||
|
|
||||||
```
|
```
|
||||||
generic/
|
shared/
|
||||||
├── .claude-plugin/
|
├── .claude-plugin/
|
||||||
│ ├── plugin.json # Claude 外掛定義(name: "jsc")
|
│ ├── plugin.json # Claude 外掛定義(name: "jsc-shared")
|
||||||
│ └── marketplace.json # Claude marketplace(name: "generic",source 指向本 repo)
|
│ └── marketplace.json # Claude marketplace(name: "shared",source 指向本 repo)
|
||||||
├── .codex-plugin/
|
├── .codex-plugin/
|
||||||
│ └── plugin.json # Codex 外掛定義(name: "jsc",skills: "./skills")
|
│ └── plugin.json # Codex 外掛定義(name: "jsc-shared",skills: "./skills")
|
||||||
├── .agents/plugins/
|
├── .agents/plugins/
|
||||||
│ └── marketplace.json # Codex marketplace(name: "generic",url source 指向本 repo)
|
│ └── marketplace.json # Codex marketplace(name: "shared",url source 指向本 repo)
|
||||||
├── plugin.json # Antigravity 外掛定義(name: "jsc",skills: "./skills/")
|
├── plugin.json # Antigravity 外掛定義(name: "jsc-shared",skills: "./skills/")
|
||||||
├── hooks/
|
|
||||||
│ └── hooks.json # hook 定義(SessionStart 載入角色、Stop 記錄記憶)
|
|
||||||
├── scripts/
|
|
||||||
│ └── role/ # role skill 的可執行元件(腳本一律不放進 skills/)
|
|
||||||
├── skills/ # ★ 唯一真實來源:所有 skills
|
├── skills/ # ★ 唯一真實來源:所有 skills
|
||||||
│ ├── spec-*/SKILL.md # 共用規範 skills(一規範一目錄)
|
│ ├── spec-*/SKILL.md # 共用規範 skills(一規範一目錄)
|
||||||
│ └── role/SKILL.md # 角色人格與長期記憶
|
│ ├── plugins-install/ # 一次安裝/更新 jsc-code、jsc-doc、jsc-persona
|
||||||
|
│ └── plugins-uninstall/ # 一次移除 jsc-code、jsc-doc、jsc-persona、jsc-shared
|
||||||
├── AGENTS.md # 跨助理共用指引
|
├── AGENTS.md # 跨助理共用指引
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
> generic 的定位是「**共用規範**」:`skills/spec-*` 是 code/doc plugins 共用的流程與安全規範。工作紀錄自動化 `worklog` 已移到 `doc` plugin。
|
> shared 的定位是「**共用規範**」:`skills/spec-*` 是 code/doc plugins 共用的流程與安全規範。工作紀錄自動化 `worklog` 已移到 `doc` plugin。
|
||||||
> 例外是 `role`:它是跨助理共用的**角色與記憶**能力,帶 `hooks/` 與 `scripts/`,適用範圍見下方「元件對各助理的適用範圍」。
|
> 例外只有 `plugins-install`/`plugins-uninstall` 兩類:它們是**整組 plugin 的安裝管理**,一次處理所有 JSC plugin,不必逐個 repo 翻 README。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安裝 / 更新 / 移除(各助理)
|
## 安裝 / 更新 / 移除(各助理)
|
||||||
|
|
||||||
> 指令中的 repo 網址換成你的:`https://gitea.jsc.idv.tw/plugins/generic.git`
|
> 指令中的 repo 網址換成你的:`https://gitea.jsc.idv.tw/plugins/shared.git`
|
||||||
>
|
>
|
||||||
> **Claude / Codex 從 git URL 安裝(會 clone 遠端),請先把本 repo `push` 到 gitea。**
|
> **Claude / Codex 從 git URL 安裝(會 clone 遠端),請先把本 repo `push` 到 gitea。**
|
||||||
> **Antigravity 的 `agy plugin install <url>` 目前只支援 github.com**;gitea 請改用「clone + 本地路徑」(見 Antigravity 節)。
|
> **Antigravity 的 `agy plugin install <url>` 目前只支援 github.com**;gitea 請依本節改用「從遠端重新抓取到暫存目錄,再用本地路徑安裝」。
|
||||||
> 本機/離線:Claude 可用本地路徑加 marketplace;Antigravity 用本地路徑安裝。
|
> 本 repo 的安裝/更新/移除流程一律以 Gitea 遠端檔案與下方 README 章節為準,不依賴既有本機存取庫。
|
||||||
|
|
||||||
### Claude Code
|
### Claude Code
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安裝
|
# 安裝
|
||||||
claude plugin marketplace add https://gitea.jsc.idv.tw/plugins/generic.git
|
claude plugin marketplace add https://gitea.jsc.idv.tw/plugins/shared.git
|
||||||
claude plugin install jsc@generic
|
claude plugin install jsc-shared@shared
|
||||||
|
|
||||||
# 更新
|
# 更新
|
||||||
claude plugin marketplace update generic
|
claude plugin marketplace update shared
|
||||||
claude plugin update jsc@generic
|
claude plugin update jsc-shared@shared
|
||||||
|
|
||||||
# 移除
|
# 移除
|
||||||
claude plugin uninstall jsc@generic
|
claude plugin uninstall jsc-shared@shared
|
||||||
claude plugin marketplace remove generic
|
claude plugin marketplace remove shared
|
||||||
```
|
```
|
||||||
|
|
||||||
- 工作階段內 slash 版(等價):把 `claude plugin` 換成 `/plugin`。
|
- 工作階段內 slash 版(等價):把 `claude plugin` 換成 `/plugin`。
|
||||||
- 本機開發(免 push):`claude plugin marketplace add C:\Users\h3285\source\repos.plugins\generic`(本地路徑)後再 install。
|
- 本機開發(免 push)仍可用本地路徑,但正式安裝請以 Gitea 遠端 README 為準。
|
||||||
- **呼叫**:`/jsc:<name>`(例 `/jsc:spec-output`)。
|
- **呼叫**:`/jsc-shared:<name>`(例 `/jsc-shared:spec-output`)。
|
||||||
|
|
||||||
### Codex
|
### Codex
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安裝
|
# 安裝
|
||||||
codex plugin marketplace add https://gitea.jsc.idv.tw/plugins/generic.git
|
codex plugin marketplace add https://gitea.jsc.idv.tw/plugins/shared.git
|
||||||
codex plugin add jsc@generic
|
codex plugin add jsc-shared@shared
|
||||||
|
|
||||||
# 更新(重新抓取 marketplace 的 git 快照)
|
# 更新(重新抓取 marketplace 的 git 快照)
|
||||||
codex plugin marketplace upgrade generic
|
codex plugin marketplace upgrade shared
|
||||||
|
|
||||||
# 移除
|
# 移除
|
||||||
codex plugin remove jsc@generic
|
codex plugin remove jsc-shared@shared
|
||||||
codex plugin marketplace remove generic
|
codex plugin marketplace remove shared
|
||||||
```
|
```
|
||||||
|
|
||||||
- 安裝 token `jsc@generic` = plugin 名(`.codex-plugin/plugin.json` 的 `name`)@ marketplace 名(`.agents/plugins/marketplace.json` 的 `name`)。
|
- 安裝 token `jsc-shared@shared` = plugin 名(`.codex-plugin/plugin.json` 的 `name`)@ marketplace 名(`.agents/plugins/marketplace.json` 的 `name`)。
|
||||||
- 本 repo 的 Codex marketplace 以 `url` 來源指向自己,故 Codex **一律從 gitea 安裝**(需先 push);安裝後重啟 Codex。
|
- 本 repo 的 Codex marketplace 以 `url` 來源指向自己,故 Codex **一律從 gitea 安裝**(需先 push);安裝後重啟 Codex。
|
||||||
- **呼叫**:`$<name>`(例 `$spec-output`),或用 `/skills` 選單。
|
- **呼叫**:`$<name>`(例 `$spec-output`),或用 `/skills` 選單。
|
||||||
|
|
||||||
### Antigravity(`agy`)
|
### Antigravity(`agy`)
|
||||||
|
|
||||||
> `agy plugin install <url>` 目前**只支援 github.com**;gitea 等自架 git 不支援 URL 安裝,請先 `git clone` 再用**本地路徑**安裝。
|
> `agy plugin install <url>` 目前**只支援 github.com**;gitea 等自架 git 不支援 URL 安裝,請先從遠端抓到暫存目錄,再用**本地路徑**安裝。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安裝:clone 後用本地路徑
|
# 安裝:先從遠端抓到暫存目錄,再用本地路徑
|
||||||
git clone https://gitea.jsc.idv.tw/plugins/generic.git ~/plugins/generic
|
git clone --depth 1 https://gitea.jsc.idv.tw/plugins/shared.git /tmp/jsc-shared
|
||||||
agy plugin install ~/plugins/generic
|
agy plugin install /tmp/jsc-shared
|
||||||
|
|
||||||
# 更新(agy 無 update 子指令 → git pull 後重裝)
|
# 更新(重新抓取遠端後重裝)
|
||||||
git -C ~/plugins/generic pull
|
git clone --depth 1 https://gitea.jsc.idv.tw/plugins/shared.git /tmp/jsc-shared
|
||||||
agy plugin uninstall jsc
|
agy plugin uninstall jsc-shared
|
||||||
agy plugin install ~/plugins/generic
|
agy plugin install /tmp/jsc-shared
|
||||||
|
|
||||||
# 移除
|
# 移除
|
||||||
agy plugin uninstall jsc
|
agy plugin uninstall jsc-shared
|
||||||
```
|
```
|
||||||
|
|
||||||
- 若把 skills 放到 GitHub,則可直接 `agy plugin install https://github.com/<owner>/<repo>`。
|
- 若把 skills 放到 GitHub,則可直接 `agy plugin install https://github.com/<owner>/<repo>`。
|
||||||
- 其他:`agy plugin list`、`agy plugin enable jsc` / `disable jsc`、`agy plugin validate <path>`。安裝後重啟工作階段。
|
- 不讀取既有本機 repo;若需要對照 README,只用遠端 checkout 的暫存工作區。
|
||||||
- **呼叫**:`/jsc:<name>`(例 `/jsc:spec-output`)或依描述自動觸發。
|
- 其他:`agy plugin list`、`agy plugin enable jsc-shared` / `disable jsc-shared`、`agy plugin validate <path>`。安裝後重啟工作階段。
|
||||||
|
- **呼叫**:`/jsc-shared:<name>`(例 `/jsc-shared:spec-output`)或依描述自動觸發。
|
||||||
|
|
||||||
### OpenCode
|
### 無 plugin 指令但可使用 skill 的助理
|
||||||
|
|
||||||
OpenCode 的「plugin」是 TypeScript/npm 套件,不適用於 skill 包;skills 改用**目錄安裝**。
|
以 OpenCode 為例,這類工具不走原生 plugin install / uninstall,而是:
|
||||||
OpenCode 會讀 `~/.config/opencode/skills/`(也會讀 `~/.claude/skills/`、`~/.agents/skills/`)。
|
|
||||||
|
1. 先把整組 skill clone 到工具的專屬資料夾。
|
||||||
|
2. 再依技能組自己的 `README.md`,把技能匯入到 README 指定的位置。
|
||||||
|
3. 移除時則反向刪除 README 指定的那些 skill 目錄。
|
||||||
|
|
||||||
|
OpenCode 會讀 `~/.config/opencode/skills/`(也會讀 `~/.claude/skills/`、`~/.agents/skills/`),因此可把技能匯入這些位置。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安裝
|
# 安裝
|
||||||
git clone https://gitea.jsc.idv.tw/plugins/generic.git ~/plugins/generic
|
git clone --depth 1 https://gitea.jsc.idv.tw/plugins/shared.git /tmp/jsc-shared
|
||||||
mkdir -p ~/.config/opencode/skills
|
mkdir -p ~/.config/opencode/skills
|
||||||
cp -r ~/plugins/generic/skills/* ~/.config/opencode/skills/
|
cp -r /tmp/jsc-shared/skills/* ~/.config/opencode/skills/
|
||||||
|
|
||||||
# 更新
|
# 更新
|
||||||
git -C ~/plugins/generic pull
|
git clone --depth 1 https://gitea.jsc.idv.tw/plugins/shared.git /tmp/jsc-shared
|
||||||
cp -r ~/plugins/generic/skills/* ~/.config/opencode/skills/
|
cp -r /tmp/jsc-shared/skills/* ~/.config/opencode/skills/
|
||||||
|
|
||||||
# 移除(逐一移除本 plugin 帶入的 skill 目錄;勿只清 spec-*,否則其他 skill 會殘留)
|
# 移除(逐一移除本 plugin 帶入的 skill 目錄;勿只清 spec-*,否則其他 skill 會殘留)
|
||||||
for s in ~/plugins/generic/skills/*/; do rm -rf "$HOME/.config/opencode/skills/$(basename "$s")"; done
|
for s in /tmp/jsc-shared/skills/*/; do rm -rf "$HOME/.config/opencode/skills/$(basename "$s")"; done
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Windows PowerShell**:`cp -r A B` → `Copy-Item A B -Recurse -Force`、`rm -rf X` → `Remove-Item X -Recurse -Force`、`~` → `$HOME`。
|
> **Windows PowerShell**:`cp -r A B` → `Copy-Item A B -Recurse -Force`、`rm -rf X` → `Remove-Item X -Recurse -Force`、`~` → `$HOME`。
|
||||||
@@ -149,19 +152,19 @@ Copilot CLI 支援與 Claude Code 類似的原生 plugin / marketplace 指令,
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安裝
|
# 安裝
|
||||||
copilot plugin marketplace add https://gitea.jsc.idv.tw/plugins/generic.git
|
copilot plugin marketplace add https://gitea.jsc.idv.tw/plugins/shared.git
|
||||||
copilot plugin install jsc@generic
|
copilot plugin install jsc-shared@shared
|
||||||
|
|
||||||
# 更新
|
# 更新
|
||||||
copilot plugin marketplace update generic
|
copilot plugin marketplace update shared
|
||||||
copilot plugin update jsc@generic
|
copilot plugin update jsc-shared@shared
|
||||||
|
|
||||||
# 移除
|
# 移除
|
||||||
copilot plugin uninstall jsc@generic
|
copilot plugin uninstall jsc-shared@shared
|
||||||
copilot plugin marketplace remove generic
|
copilot plugin marketplace remove shared
|
||||||
```
|
```
|
||||||
|
|
||||||
- 安裝 token `jsc@generic` = plugin 名(plugin manifest 的 `name`)@ marketplace 名。
|
- 安裝 token `jsc-shared@shared` = plugin 名(plugin manifest 的 `name`)@ marketplace 名。
|
||||||
- `copilot plugin marketplace add` 支援 GitHub `owner/repo`、git URL 與本地路徑;Gitea repo 可用上方 HTTPS URL。
|
- `copilot plugin marketplace add` 支援 GitHub `owner/repo`、git URL 與本地路徑;Gitea repo 可用上方 HTTPS URL。
|
||||||
- **呼叫**:在 Copilot CLI 中用自然語言描述需求,例如 `copilot -i "請使用 spec-output 說明輸出規範"`。
|
- **呼叫**:在 Copilot CLI 中用自然語言描述需求,例如 `copilot -i "請使用 spec-output 說明輸出規範"`。
|
||||||
|
|
||||||
@@ -173,16 +176,16 @@ copilot plugin marketplace remove generic
|
|||||||
|
|
||||||
| 助理 | headless 指令 | 執行 `spec-output` skill |
|
| 助理 | headless 指令 | 執行 `spec-output` skill |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Claude Code | `claude -p "<prompt>"` | `claude -p "/jsc:spec-output"` |
|
| Claude Code | `claude -p "<prompt>"` | `claude -p "/jsc-shared:spec-output"` |
|
||||||
| Codex | `codex exec "<prompt>"` | `codex exec '$spec-output'` |
|
| Codex | `codex exec "<prompt>"` | `codex exec '$spec-output'` |
|
||||||
| Antigravity | `agy -p "<prompt>"` | `agy -p "/jsc:spec-output"` |
|
| Antigravity | `agy -p "<prompt>"` | `agy -p "/jsc-shared:spec-output"` |
|
||||||
| OpenCode | `opencode run "<message>"` | `opencode run "說明 JSC 共用輸出規範的內容"` |
|
| OpenCode | `opencode run "<message>"` | `opencode run "說明 JSC 共用輸出規範的內容"` |
|
||||||
| GitHub Copilot CLI | `copilot -p "<message>"` | `copilot -p "說明 JSC 共用輸出規範的內容"` |
|
| GitHub Copilot CLI | `copilot -p "<message>"` | `copilot -p "說明 JSC 共用輸出規範的內容"` |
|
||||||
|
|
||||||
- Claude / Antigravity 支援 `/jsc:` 前綴,直接 `-p "/jsc:<name>"` 即可。
|
- Claude / Antigravity 支援 `/jsc-shared:` 前綴,直接 `-p "/jsc-shared:<name>"` 即可。
|
||||||
- Codex 以 `$<name>` 觸發;在 shell 請用**單引號**避免 `$` 被展開:`codex exec '$spec-output'`。
|
- Codex 以 `$<name>` 觸發;在 shell 請用**單引號**避免 `$` 被展開:`codex exec '$spec-output'`。
|
||||||
- OpenCode 與 Copilot 沒有前綴,用自然語言描述需求;Copilot CLI 會讀取已安裝 plugin 提供的 skills。
|
- OpenCode 與 Copilot 沒有前綴,用自然語言描述需求;Copilot CLI 會讀取已安裝 plugin 提供的 skills。
|
||||||
- 帶引數就接在後面,例如 `claude -p "/jsc:spec-output 參數"`、`codex exec '$spec-output 參數'`。
|
- 帶引數就接在後面,例如 `claude -p "/jsc-shared:spec-output 參數"`、`codex exec '$spec-output 參數'`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -195,7 +198,7 @@ copilot plugin marketplace remove generic
|
|||||||
|
|
||||||
### 共用規範(spec-*)
|
### 共用規範(spec-*)
|
||||||
|
|
||||||
以下 skills 是 **code/doc plugins 各 skill 引用的共用規範**:其他 skill 內文以 `/jsc:spec-<name>` 引用時載入;也可單獨呼叫查看規範內容。
|
以下 skills 是 **code/doc plugins 各 skill 引用的共用規範**:其他 skill 內文以 `/jsc-shared:spec-<name>` 引用時載入;也可單獨呼叫查看規範內容。
|
||||||
|
|
||||||
| Skill | 類型 | 內容 |
|
| Skill | 類型 | 內容 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -207,16 +210,19 @@ copilot plugin marketplace remove generic
|
|||||||
| `spec-action-params` | Action 參數 | action 參數來源優先序(context/環境變數 → inputs)、secrets/vars 一律視為不可用 |
|
| `spec-action-params` | Action 參數 | action 參數來源優先序(context/環境變數 → inputs)、secrets/vars 一律視為不可用 |
|
||||||
| `spec-dockerfile` | Dockerfile | 六步流程(參數→安裝→複製→執行→縮小→入口)、多階段建置、固定版號、對外契約不動、自我檢查 |
|
| `spec-dockerfile` | Dockerfile | 六步流程(參數→安裝→複製→執行→縮小→入口)、多階段建置、固定版號、對外契約不動、自我檢查 |
|
||||||
| `spec-project-board` | Gitea 看板 | 進度欄位語意對應、建議欄位規則、GET 探測(404/501 不支援)、不往回移、不得新建欄位 |
|
| `spec-project-board` | Gitea 看板 | 進度欄位語意對應、建議欄位規則、GET 探測(404/501 不支援)、不往回移、不得新建欄位 |
|
||||||
| `spec-doc-funcs-handoff` | 文件化串接 | code 類 skill 完成後完整執行 /jsc:doc-funcs 的標準流程與統一時間戳 |
|
| `spec-doc-funcs-handoff` | 文件化串接 | code 類 skill 完成後完整執行 /jsc-doc:funcs 的標準流程與統一時間戳 |
|
||||||
| `spec-plugin-version` | 版號規則 | 三 manifest 同步 bump、對照 master 確保單調遞增、新 plugin 首發 0.0.1、chore(plugin 版本) commit |
|
| `spec-plugin-version` | 版號規則 | 三 manifest 同步 bump、以 master 為基準計算同一 PR 的最終版本、新 plugin 首發 0.0.1、patch 到 9 後進位 minor、chore(plugin 版本) commit |
|
||||||
|
|
||||||
### 角色與記憶
|
### 整組 plugin 安裝管理
|
||||||
|
|
||||||
|
一次操作所有 JSC plugin,不必逐個 repo 翻 README 的安裝章節。四家原生 plugin CLI(`claude`/`codex`/`copilot`/`agy`)都支援,OpenCode 走複製/刪除 skills 目錄。
|
||||||
|
|
||||||
| Skill | 用途 | 使用方法 |
|
| Skill | 用途 | 使用方法 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `role` | 讓 CLI 以固定角色(name/nature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:啟動時自動載入角色與記憶,睡眠時段(預設 22:00–06:00)由排程整理、去重、標籤化、壓縮歸檔並適當遺忘 | `/jsc:role --new` 建立或更新角色、`--use <名稱>` 切換、`--list`、`--sleep` 立即整理、`--status` 診斷、`--install-cron` 安裝排程 |
|
| `plugins-install` | 一次**安裝或更新** `jsc-code`、`jsc-doc`、`jsc-persona`、`jsc-shared`:先盤點每個 plugin 已安裝或未安裝,未安裝就安裝、已安裝就更新到最新,最後以表格回報動作、位置、結果與版本。`agy` 走 clone+本地路徑安裝;OpenCode 這類**沒有 plugin 匯入指令、但可使用 skill** 的助理先把技能組 clone 到工具專屬資料夾,再依技能組 `README.md` 匯入到指定位置,**已安裝就在該路徑就地更新、未安裝才放進工具的全域資料夾** | `/jsc-shared:plugins-install`;可帶 `--assistant claude\|codex\|copilot\|agy\|opencode`、`--plugins code,doc,persona,shared`、`--host <gitea 主機>`、`--clone-dir <目錄>`、`--yes` |
|
||||||
|
| `plugins-uninstall` | 一次**移除** `jsc-code`、`jsc-doc`、`jsc-persona`、`jsc-shared`:動手前先列出將被移除的項目與不會被碰的資料請使用者確認,移除順序固定把 `jsc-shared` 放最後(本 skill 就住在裡面)。人格倉庫與記憶目錄一律不刪;OpenCode 這類**沒有 plugin 匯入指令、但可使用 skill** 的助理則依技能組 `README.md` 反向刪除匯入位置 | `/jsc-shared:plugins-uninstall`;可帶 `--assistant …`、`--plugins code,doc,persona,shared`、`--keep-marketplace`、`--yes` |
|
||||||
|
|
||||||
`role` 的自動路徑由 hook 與 cron 完成,**建立角色後重開工作階段即生效**;沒有建立過角色的人完全不受影響(`~/.roles/.active` 不存在時 hook 立即結束)。細節見 `skills/role/SKILL.md`。
|
> `plugins-install` 與 `plugins-uninstall` 都會處理 `jsc-shared`;移除時一定放最後一步。
|
||||||
|
|
||||||
<!-- JSC-SKILLS:END -->
|
<!-- JSC-SKILLS:END -->
|
||||||
|
|
||||||
@@ -224,17 +230,15 @@ copilot plugin marketplace remove generic
|
|||||||
|
|
||||||
## 元件對各助理的適用範圍
|
## 元件對各助理的適用範圍
|
||||||
|
|
||||||
`skills/` 各助理都能用;`hooks/` 與 `scripts/` 則否。
|
`skills/` 各助理都能用。
|
||||||
|
|
||||||
| 元件 | Claude Code | Codex | Antigravity | OpenCode | GitHub Copilot |
|
| 元件 | Claude Code | Codex | Antigravity | OpenCode | GitHub Copilot |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `skills/spec-*`(純規範) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| `skills/spec-*`(純規範) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| `skills/role` 的手動模式 | ✅ | ⚠️ 需保留 `scripts/` | ⚠️ 同左 | ❌ 只複製 `skills/` | ⚠️ 同左 |
|
| `skills/plugins-install`、`plugins-uninstall` | ✅ | ✅ | ✅ | ⚠️ 只能操作 OpenCode 自己 | ✅ |
|
||||||
| `hooks/hooks.json`:`SessionStart` 載入角色 | ✅ | ⚠️ 需該版本支援 | ❌ | ❌ | ❌ |
|
|
||||||
| `hooks/hooks.json`:`Stop` 記錄記憶 | ✅ | ✅ | ❌ | ❌ | ❌ |
|
|
||||||
| cron 睡眠整理(系統排程) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| cron 睡眠整理(系統排程) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
> **OpenCode 以複製 `skills/` 目錄安裝**,不會帶入 `scripts/` 與 `hooks/`,凡依賴腳本的 skill 一律不可用。
|
> **OpenCode 以複製 `skills/` 目錄安裝**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -242,21 +246,16 @@ copilot plugin marketplace remove generic
|
|||||||
|
|
||||||
1. 複製既有 skill 作範本:`cp -r skills/spec-output skills/<your-skill-name>`
|
1. 複製既有 skill 作範本:`cp -r skills/spec-output skills/<your-skill-name>`
|
||||||
2. 編輯 `skills/<your-skill-name>/SKILL.md` 的 frontmatter:
|
2. 編輯 `skills/<your-skill-name>/SKILL.md` 的 frontmatter:
|
||||||
- `name`:小寫、數字、連字號(`-`),最長 64 字元。**這就是 Claude Code / Antigravity 的 `/jsc:<name>`**。
|
- `name`:小寫、數字、連字號(`-`),最長 64 字元。**這就是 Claude Code / Antigravity 的 `/jsc-shared:<name>`**。
|
||||||
- `description`:第三人稱,寫清楚「何時用、何時不用」與觸發關鍵字 — 這是各助理自動載入的唯一依據。
|
- `description`:第三人稱,寫清楚「何時用、何時不用」與觸發關鍵字 — 這是各助理自動載入的唯一依據。
|
||||||
3. 在內文寫下 skill 的具體步驟。
|
3. 在內文寫下 skill 的具體步驟。
|
||||||
4. 手動把這個 skill 補進上方「Skills 目錄」區塊。
|
4. 手動把這個 skill 補進上方「Skills 目錄」區塊。
|
||||||
5. **bump 版本並 push**:各助理都以 git 內容/版本判斷更新,請把 `.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`plugin.json` 三個 manifest 的 `version` 一起 bump(規則見 `skills/spec-plugin-version/`),commit 後 push 到 gitea。
|
5. **bump 版本並 push**:各助理都以 git 內容/版本判斷更新,請把 `.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`plugin.json` 三個 manifest 的 `version` 一起 bump(規則見 `skills/spec-plugin-version/`),commit 後 push 到 gitea。
|
||||||
6. 讓各助理更新:
|
6. 讓各助理更新:
|
||||||
- Claude:`claude plugin update jsc@generic`
|
- Claude:`claude plugin update jsc-shared@shared`
|
||||||
- Codex:`codex plugin marketplace upgrade generic`
|
- Codex:`codex plugin marketplace upgrade shared`
|
||||||
- Antigravity:`git -C ~/plugins/generic pull && agy plugin uninstall jsc && agy plugin install ~/plugins/generic`(路徑與上方 Antigravity 安裝節一致)
|
- Antigravity:重新從 Gitea 遠端抓取到暫存目錄後再 `agy plugin uninstall jsc-shared && agy plugin install <temp-dir>/shared`
|
||||||
- OpenCode:`git pull` 後重新複製 `skills/`
|
- OpenCode:重新從 Gitea 遠端抓取到暫存目錄後再複製 `skills/`
|
||||||
- Copilot:`copilot plugin marketplace update generic && copilot plugin update jsc@generic`
|
- Copilot:`copilot plugin marketplace update shared && copilot plugin update jsc-shared@shared`
|
||||||
|
|
||||||
> **skill 帶可執行元件時**(腳本、hook)額外注意:
|
> 本 repo 只放純 `SKILL.md` 內容,不含可執行腳本或 hook。
|
||||||
>
|
|
||||||
> - 腳本放 `scripts/<skill-name>/`,**不要**放進 `skills/`;hook 定義放 `hooks/hooks.json`,command 用 `${CLAUDE_PLUGIN_ROOT}/...` 絕對路徑。
|
|
||||||
> - 腳本要有執行權限並確實入 git(`git ls-files -s` 應顯示 `100755`)。
|
|
||||||
> - `SKILL.md` **不可用相對路徑呼叫腳本** —— skill 執行時的工作目錄是使用者的專案目錄;請以 `${CLAUDE_PLUGIN_ROOT}`(其他助理用 skill base directory 往上兩層)組出絕對路徑。
|
|
||||||
> - 在 `SKILL.md` 的 `description` 與上方適用範圍表標明支援哪幾家;OpenCode 因只複製 `skills/`,凡依賴 `scripts/` 的 skill 一律不支援。
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"SessionStart": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ]; then exec \"$root/scripts/role/role_load.sh\"; fi; script=$(find \"$HOME/.codex/plugins/cache/generic/jsc\" -path '*/scripts/role/role_load.sh' -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$script\" ]; then exec \"$script\"; fi; exit 0",
|
|
||||||
"timeout": 20
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"Stop": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ]; then exec \"$root/scripts/role/role_capture.sh\"; fi; script=$(find \"$HOME/.codex/plugins/cache/generic/jsc\" -path '*/scripts/role/role_capture.sh' -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$script\" ]; then exec \"$script\"; fi; exit 0",
|
|
||||||
"timeout": 60
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc",
|
"name": "jsc-shared",
|
||||||
"version": "0.0.4",
|
"version": "0.0.8",
|
||||||
"description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc: 前綴呼叫。",
|
"description": "JSC 跨 AI 助理共用規範 skills plugin,`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準。",
|
||||||
"skills": "./skills/"
|
"skills": "./skills/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,690 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
|
|
||||||
# inbox,(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理
|
|
||||||
# 素材並套用整理結果(分類/去重/標籤/總結/壓縮歸檔),(4) 依使用頻率
|
|
||||||
# 遺忘日常與其他類記憶。
|
|
||||||
# 更新時間:2026/07/28 00:00:00
|
|
||||||
# 相依:Python 3 標準庫。
|
|
||||||
# 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import gzip
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 常數:分類、載入策略、遺忘規則
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 六個分類的目錄名(英文,跨平台安全)與中文標籤
|
|
||||||
CATEGORIES = ["important", "interest", "news", "skill", "daily", "other"]
|
|
||||||
CATEGORY_LABELS = {
|
|
||||||
"important": "重要",
|
|
||||||
"interest": "興趣",
|
|
||||||
"news": "新知",
|
|
||||||
"skill": "技能",
|
|
||||||
"daily": "日常",
|
|
||||||
"other": "其他",
|
|
||||||
}
|
|
||||||
# 中文分類名反查(模型可能直接輸出中文)
|
|
||||||
LABEL_TO_CATEGORY = {label: key for key, label in CATEGORY_LABELS.items()}
|
|
||||||
|
|
||||||
# 載入策略:重要與興趣載入全文,其餘僅載入總結與標籤
|
|
||||||
FULL_CATEGORIES = ["important", "interest"]
|
|
||||||
# 摘要載入順序:技能 → 新知 → 日常 → 其他
|
|
||||||
DIGEST_CATEGORIES = ["skill", "news", "daily", "other"]
|
|
||||||
|
|
||||||
# 遺忘規則:(未更新天數門檻, 命中次數上限);只套用於日常與其他
|
|
||||||
FORGET_RULES = {"daily": (14, 1), "other": (7, 1)}
|
|
||||||
|
|
||||||
# 單次睡眠整理最多處理的 inbox 筆數,其餘留待下個睡眠週期
|
|
||||||
SLEEP_BATCH = 60
|
|
||||||
# 送進模型的素材字元上限
|
|
||||||
COLLECT_LIMIT = 40000
|
|
||||||
# 單則記憶壓縮後的內容字元上限
|
|
||||||
CONTENT_LIMIT = 1200
|
|
||||||
|
|
||||||
TAIPEI = timezone(timedelta(hours=8))
|
|
||||||
STAMP_FORMAT = "%Y/%m/%d %H:%M:%S"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 路徑與時間
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def now_stamp():
|
|
||||||
"""回傳台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串。"""
|
|
||||||
return datetime.now(TAIPEI).strftime(STAMP_FORMAT)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_stamp(value):
|
|
||||||
"""把 yyyy/MM/dd HH:mm:ss 字串解析成帶時區的 datetime,失敗回 None。"""
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.strptime(value.strip(), STAMP_FORMAT).replace(tzinfo=TAIPEI)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def memory_root(role):
|
|
||||||
"""回傳指定角色的記憶根目錄(可用 ROLE_MEMORY_HOME 覆寫預設 ~/.memory)。"""
|
|
||||||
base = os.environ.get("ROLE_MEMORY_HOME") or os.path.join(os.path.expanduser("~"), ".memory")
|
|
||||||
return os.path.join(base, role)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_layout(role):
|
|
||||||
"""建立角色記憶目錄結構(inbox、六個分類、archive),回傳根目錄。"""
|
|
||||||
root = memory_root(role)
|
|
||||||
for sub in ["inbox", "archive/raw", "archive/forgotten"] + CATEGORIES:
|
|
||||||
os.makedirs(os.path.join(root, sub), exist_ok=True)
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def state_path(role):
|
|
||||||
"""回傳角色記憶狀態檔(state.json)的路徑。"""
|
|
||||||
return os.path.join(memory_root(role), "state.json")
|
|
||||||
|
|
||||||
|
|
||||||
def read_state(role):
|
|
||||||
"""讀取狀態檔;不存在或損壞時回空 dict。"""
|
|
||||||
try:
|
|
||||||
with open(state_path(role), encoding="utf-8") as fh:
|
|
||||||
data = json.load(fh)
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def write_state(role, patch):
|
|
||||||
"""把 patch 併入狀態檔後寫回(整份覆寫,內容極小)。"""
|
|
||||||
state = read_state(role)
|
|
||||||
state.update(patch)
|
|
||||||
ensure_layout(role)
|
|
||||||
with open(state_path(role), "w", encoding="utf-8") as fh:
|
|
||||||
json.dump(state, fh, ensure_ascii=False, indent=2)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 記憶檔格式:YAML 風格 frontmatter + 內文
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_category(value):
|
|
||||||
"""把模型輸出的分類(英文或中文)正規化為分類鍵;無法判定時回 other。"""
|
|
||||||
raw = (value or "").strip().lower()
|
|
||||||
if raw in CATEGORIES:
|
|
||||||
return raw
|
|
||||||
return LABEL_TO_CATEGORY.get((value or "").strip(), "other")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_tags(value):
|
|
||||||
"""把標籤(list 或逗號分隔字串)正規化為去重後的小寫標籤 list,最多 6 個。"""
|
|
||||||
if isinstance(value, str):
|
|
||||||
parts = re.split(r"[,、|]", value)
|
|
||||||
elif isinstance(value, list):
|
|
||||||
parts = [str(item) for item in value]
|
|
||||||
else:
|
|
||||||
parts = []
|
|
||||||
tags = []
|
|
||||||
for part in parts:
|
|
||||||
tag = part.strip().strip("[]#").strip()
|
|
||||||
if tag and tag.lower() not in [t.lower() for t in tags]:
|
|
||||||
tags.append(tag)
|
|
||||||
return tags[:6]
|
|
||||||
|
|
||||||
|
|
||||||
def one_line(value, limit=120):
|
|
||||||
"""把文字壓成單行並截斷,用於 summary 欄位。"""
|
|
||||||
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
|
||||||
return text[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def dump_memory(meta, content):
|
|
||||||
"""把 meta 與內文組成記憶檔全文(frontmatter + 內文)。"""
|
|
||||||
lines = ["---"]
|
|
||||||
for key in ["id", "category", "summary", "tags", "created", "updated", "hits", "sources"]:
|
|
||||||
if key not in meta:
|
|
||||||
continue
|
|
||||||
value = meta[key]
|
|
||||||
if isinstance(value, list):
|
|
||||||
value = "[" + ", ".join(str(item) for item in value) + "]"
|
|
||||||
lines.append(f"{key}: {value}")
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(content.strip())
|
|
||||||
lines.append("")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def load_memory(path):
|
|
||||||
"""讀取單一記憶檔,回傳 (meta dict, 內文);讀取失敗回 (None, "")。"""
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as fh:
|
|
||||||
raw = fh.read()
|
|
||||||
except OSError:
|
|
||||||
return None, ""
|
|
||||||
|
|
||||||
meta = {"path": path, "hits": 0, "tags": []}
|
|
||||||
body = raw
|
|
||||||
if raw.startswith("---"):
|
|
||||||
parts = raw.split("---", 2)
|
|
||||||
if len(parts) >= 3:
|
|
||||||
body = parts[2]
|
|
||||||
for line in parts[1].splitlines():
|
|
||||||
if ":" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition(":")
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip()
|
|
||||||
if key in ("tags", "sources"):
|
|
||||||
meta[key] = normalize_tags(value.strip("[]"))
|
|
||||||
elif key == "hits":
|
|
||||||
meta[key] = int(value) if value.isdigit() else 0
|
|
||||||
else:
|
|
||||||
meta[key] = value
|
|
||||||
meta.setdefault("id", os.path.splitext(os.path.basename(path))[0])
|
|
||||||
meta.setdefault("summary", "")
|
|
||||||
meta.setdefault("created", "")
|
|
||||||
meta.setdefault("updated", meta.get("created", ""))
|
|
||||||
return meta, body.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def new_id(seed):
|
|
||||||
"""以時間與內容雜湊產生記憶 id,確保同一秒多筆也不碰撞。"""
|
|
||||||
digest = hashlib.sha1(seed.encode("utf-8", "replace")).hexdigest()[:6]
|
|
||||||
return f"{datetime.now(TAIPEI).strftime('%Y%m%d-%H%M%S')}-{digest}"
|
|
||||||
|
|
||||||
|
|
||||||
def list_memories(role, category):
|
|
||||||
"""列出某分類下的所有記憶(依 updated 新到舊排序)。"""
|
|
||||||
directory = os.path.join(memory_root(role), category)
|
|
||||||
items = []
|
|
||||||
if not os.path.isdir(directory):
|
|
||||||
return items
|
|
||||||
for name in sorted(os.listdir(directory)):
|
|
||||||
if not name.endswith(".md"):
|
|
||||||
continue
|
|
||||||
meta, content = load_memory(os.path.join(directory, name))
|
|
||||||
if meta is None:
|
|
||||||
continue
|
|
||||||
meta["category"] = category
|
|
||||||
items.append((meta, content))
|
|
||||||
items.sort(key=lambda item: item[0].get("updated") or "", reverse=True)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def list_inbox(role):
|
|
||||||
"""列出 inbox 內尚未整理的記憶(依檔名,即時間先後排序)。"""
|
|
||||||
directory = os.path.join(memory_root(role), "inbox")
|
|
||||||
items = []
|
|
||||||
if not os.path.isdir(directory):
|
|
||||||
return items
|
|
||||||
for name in sorted(os.listdir(directory)):
|
|
||||||
if not name.endswith(".md"):
|
|
||||||
continue
|
|
||||||
meta, content = load_memory(os.path.join(directory, name))
|
|
||||||
if meta is not None:
|
|
||||||
items.append((meta, content))
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def find_memory(role, memory_id):
|
|
||||||
"""依 id 在六個分類中尋找記憶檔,回傳 (meta, 內文);找不到回 (None, "")。"""
|
|
||||||
for category in CATEGORIES:
|
|
||||||
path = os.path.join(memory_root(role), category, f"{memory_id}.md")
|
|
||||||
if os.path.isfile(path):
|
|
||||||
meta, content = load_memory(path)
|
|
||||||
if meta is not None:
|
|
||||||
meta["category"] = category
|
|
||||||
return meta, content
|
|
||||||
return None, ""
|
|
||||||
|
|
||||||
|
|
||||||
def archive_file(path, destination_dir):
|
|
||||||
"""把檔案 gzip 後搬到歸檔目錄,原檔刪除;失敗時保留原檔。"""
|
|
||||||
os.makedirs(destination_dir, exist_ok=True)
|
|
||||||
target = os.path.join(destination_dir, os.path.basename(path) + ".gz")
|
|
||||||
try:
|
|
||||||
with open(path, "rb") as src, gzip.open(target, "wb") as dst:
|
|
||||||
shutil.copyfileobj(src, dst)
|
|
||||||
os.remove(path)
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:write —— 由 Stop hook 寫入一則未整理記憶
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
FIELD_PATTERN = re.compile(r"^\s*(CATEGORY|SUMMARY|TAGS|CONTENT)\s*[::]\s*(.*)$", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_capture(text):
|
|
||||||
"""
|
|
||||||
解析 Stop hook 濃縮器輸出的四欄格式(CATEGORY/SUMMARY/TAGS/CONTENT)。
|
|
||||||
|
|
||||||
模型可能夾帶前後贅字,故逐行掃描欄位標記,CONTENT 之後的所有內容視為內文。
|
|
||||||
"""
|
|
||||||
category = summary = ""
|
|
||||||
tags = []
|
|
||||||
content_lines = []
|
|
||||||
in_content = False
|
|
||||||
for line in text.splitlines():
|
|
||||||
match = FIELD_PATTERN.match(line)
|
|
||||||
if match and not (in_content and match.group(1).upper() != "CONTENT"):
|
|
||||||
field = match.group(1).upper()
|
|
||||||
value = match.group(2)
|
|
||||||
if field == "CATEGORY":
|
|
||||||
category = value
|
|
||||||
elif field == "SUMMARY":
|
|
||||||
summary = value
|
|
||||||
elif field == "TAGS":
|
|
||||||
tags = normalize_tags(value)
|
|
||||||
elif field == "CONTENT":
|
|
||||||
in_content = True
|
|
||||||
if value.strip():
|
|
||||||
content_lines.append(value)
|
|
||||||
continue
|
|
||||||
if in_content:
|
|
||||||
content_lines.append(line)
|
|
||||||
return category, summary, tags, "\n".join(content_lines).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_write(args):
|
|
||||||
"""把 stdin 的濃縮結果寫成一則 inbox 記憶。"""
|
|
||||||
raw = sys.stdin.read()
|
|
||||||
category, summary, tags, content = parse_capture(raw)
|
|
||||||
if not content and not summary:
|
|
||||||
return 1
|
|
||||||
if not content:
|
|
||||||
content = summary
|
|
||||||
content = content[:CONTENT_LIMIT]
|
|
||||||
stamp = now_stamp()
|
|
||||||
meta = {
|
|
||||||
"id": new_id(content + stamp),
|
|
||||||
"category": normalize_category(category),
|
|
||||||
"summary": one_line(summary) or one_line(content),
|
|
||||||
"tags": tags,
|
|
||||||
"created": stamp,
|
|
||||||
"updated": stamp,
|
|
||||||
"hits": 1,
|
|
||||||
}
|
|
||||||
if args.project:
|
|
||||||
meta["sources"] = [args.project]
|
|
||||||
ensure_layout(args.role)
|
|
||||||
path = os.path.join(memory_root(args.role), "inbox", f"{meta['id']}.md")
|
|
||||||
with open(path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(dump_memory(meta, content))
|
|
||||||
sys.stdout.write(meta["id"])
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:load —— 產生 SessionStart 要注入的記憶區塊
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_load(args):
|
|
||||||
"""
|
|
||||||
組出載入用記憶區塊:重要與興趣載入全文,其餘依技能→新知→日常→其他只載總結與標籤。
|
|
||||||
|
|
||||||
超過字元上限時截斷並標明,避免佔滿 context。
|
|
||||||
"""
|
|
||||||
limit = args.limit
|
|
||||||
blocks = []
|
|
||||||
total_full = 0
|
|
||||||
for category in FULL_CATEGORIES:
|
|
||||||
items = list_memories(args.role, category)
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
lines = [f"### {CATEGORY_LABELS[category]}記憶(全文)"]
|
|
||||||
for meta, content in items:
|
|
||||||
tags = "、".join(meta.get("tags") or []) or "無標籤"
|
|
||||||
lines.append(f"- **{meta.get('summary') or '(無總結)'}**(標籤:{tags})")
|
|
||||||
for line in content.splitlines():
|
|
||||||
if line.strip():
|
|
||||||
lines.append(f" {line.strip()}")
|
|
||||||
total_full += 1
|
|
||||||
blocks.append("\n".join(lines))
|
|
||||||
|
|
||||||
digest_lines = []
|
|
||||||
digest_count = 0
|
|
||||||
for category in DIGEST_CATEGORIES:
|
|
||||||
items = list_memories(args.role, category)
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
digest_lines.append(f"### {CATEGORY_LABELS[category]}記憶(總結)")
|
|
||||||
for meta, _ in items:
|
|
||||||
tags = "、".join(meta.get("tags") or []) or "無標籤"
|
|
||||||
digest_lines.append(f"- {meta.get('summary') or '(無總結)'}(標籤:{tags})")
|
|
||||||
digest_count += 1
|
|
||||||
if digest_lines:
|
|
||||||
blocks.append("\n".join(digest_lines))
|
|
||||||
|
|
||||||
pending = len(list_inbox(args.role))
|
|
||||||
if pending:
|
|
||||||
blocks.append(f"> 尚有 {pending} 則未整理記憶,將於下次睡眠時段歸檔。")
|
|
||||||
|
|
||||||
if not blocks:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
text = "\n\n".join(blocks)
|
|
||||||
if len(text) > limit:
|
|
||||||
text = text[:limit] + f"\n\n> (記憶內容超過 {limit} 字元已截斷,完整記憶仍保存在磁碟)"
|
|
||||||
sys.stdout.write(text)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:collect —— 睡眠整理前輸出待整理素材
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_collect(args):
|
|
||||||
"""輸出送進模型的整理素材:inbox 待整理項目 + 既有記憶索引(供去重比對)。"""
|
|
||||||
inbox = list_inbox(args.role)[:SLEEP_BATCH]
|
|
||||||
if not inbox:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
lines = ["=== INBOX(待整理,每則以 id 標識)==="]
|
|
||||||
for meta, content in inbox:
|
|
||||||
lines.append(f"--- id: {meta['id']} | 時間: {meta.get('created', '-')} ---")
|
|
||||||
lines.append(f"初判分類: {CATEGORY_LABELS.get(meta.get('category', 'other'), '其他')}")
|
|
||||||
lines.append(f"初判總結: {meta.get('summary', '')}")
|
|
||||||
lines.append(f"初判標籤: {'、'.join(meta.get('tags') or []) or '無'}")
|
|
||||||
lines.append("內容:")
|
|
||||||
lines.append(content)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("=== EXISTING(既有記憶索引,供去重與合併判斷)===")
|
|
||||||
existing = 0
|
|
||||||
for category in CATEGORIES:
|
|
||||||
for meta, _ in list_memories(args.role, category):
|
|
||||||
tags = "、".join(meta.get("tags") or []) or "無"
|
|
||||||
lines.append(
|
|
||||||
f"- id: {meta['id']} | 分類: {CATEGORY_LABELS[category]} | 標籤: {tags} | 總結: {meta.get('summary', '')}"
|
|
||||||
)
|
|
||||||
existing += 1
|
|
||||||
if not existing:
|
|
||||||
lines.append("(尚無既有記憶)")
|
|
||||||
|
|
||||||
text = "\n".join(lines)
|
|
||||||
if len(text) > COLLECT_LIMIT:
|
|
||||||
text = text[:COLLECT_LIMIT] + "\n…(素材過長已截斷,其餘留待下個睡眠週期)…"
|
|
||||||
sys.stdout.write(text)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:apply —— 套用睡眠整理結果
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def extract_json(text):
|
|
||||||
"""從模型輸出中取出第一個 JSON 物件(容忍 code fence 與前後贅字)。"""
|
|
||||||
stripped = text.strip()
|
|
||||||
fence = re.search(r"```(?:json)?\s*(.*?)```", stripped, re.DOTALL)
|
|
||||||
if fence:
|
|
||||||
stripped = fence.group(1).strip()
|
|
||||||
start = stripped.find("{")
|
|
||||||
end = stripped.rfind("}")
|
|
||||||
if start < 0 or end <= start:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(stripped[start : end + 1])
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_apply(args):
|
|
||||||
"""
|
|
||||||
讀取 stdin 的整理結果 JSON,寫入分類記憶並歸檔對應的 inbox 原始檔。
|
|
||||||
|
|
||||||
action 支援 new(新建)/merge(併入既有記憶)/drop(判定無保存價值)。
|
|
||||||
未被提及的 inbox 檔一律保留,留待下個睡眠週期,避免整理失敗造成記憶遺失。
|
|
||||||
"""
|
|
||||||
data = extract_json(sys.stdin.read())
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
sys.stderr.write("整理結果非合法 JSON\n")
|
|
||||||
return 1
|
|
||||||
entries = data.get("memories")
|
|
||||||
if not isinstance(entries, list) or not entries:
|
|
||||||
sys.stderr.write("整理結果不含 memories\n")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
ensure_layout(args.role)
|
|
||||||
root = memory_root(args.role)
|
|
||||||
stamp = now_stamp()
|
|
||||||
counts = {"new": 0, "merge": 0, "drop": 0}
|
|
||||||
consumed = []
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
action = str(entry.get("action") or "new").strip().lower()
|
|
||||||
sources = [str(item).strip() for item in (entry.get("from") or []) if str(item).strip()]
|
|
||||||
|
|
||||||
if action == "drop":
|
|
||||||
consumed.extend(sources)
|
|
||||||
counts["drop"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
content = str(entry.get("content") or "").strip()[:CONTENT_LIMIT]
|
|
||||||
summary = one_line(entry.get("summary"))
|
|
||||||
tags = normalize_tags(entry.get("tags"))
|
|
||||||
if not content and not summary:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if action == "merge":
|
|
||||||
target_id = str(entry.get("target") or "").strip()
|
|
||||||
meta, old_content = find_memory(args.role, target_id)
|
|
||||||
if meta is None:
|
|
||||||
action = "new"
|
|
||||||
else:
|
|
||||||
category = normalize_category(entry.get("category") or meta.get("category"))
|
|
||||||
merged_tags = normalize_tags((meta.get("tags") or []) + tags)
|
|
||||||
new_meta = {
|
|
||||||
"id": meta["id"],
|
|
||||||
"category": category,
|
|
||||||
"summary": summary or meta.get("summary", ""),
|
|
||||||
"tags": merged_tags,
|
|
||||||
"created": meta.get("created") or stamp,
|
|
||||||
"updated": stamp,
|
|
||||||
"hits": int(meta.get("hits") or 0) + 1,
|
|
||||||
}
|
|
||||||
old_path = meta["path"]
|
|
||||||
new_path = os.path.join(root, category, f"{meta['id']}.md")
|
|
||||||
with open(new_path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(dump_memory(new_meta, content or old_content))
|
|
||||||
if os.path.abspath(old_path) != os.path.abspath(new_path):
|
|
||||||
try:
|
|
||||||
os.remove(old_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
consumed.extend(sources)
|
|
||||||
counts["merge"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
category = normalize_category(entry.get("category"))
|
|
||||||
meta = {
|
|
||||||
"id": new_id(content + summary + stamp),
|
|
||||||
"category": category,
|
|
||||||
"summary": summary or one_line(content),
|
|
||||||
"tags": tags,
|
|
||||||
"created": stamp,
|
|
||||||
"updated": stamp,
|
|
||||||
"hits": 1,
|
|
||||||
}
|
|
||||||
with open(os.path.join(root, category, f"{meta['id']}.md"), "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(dump_memory(meta, content or summary))
|
|
||||||
consumed.extend(sources)
|
|
||||||
counts["new"] += 1
|
|
||||||
|
|
||||||
archived = 0
|
|
||||||
month_dir = os.path.join(root, "archive", "raw", datetime.now(TAIPEI).strftime("%Y-%m"))
|
|
||||||
for source_id in set(consumed):
|
|
||||||
path = os.path.join(root, "inbox", f"{source_id}.md")
|
|
||||||
if os.path.isfile(path) and archive_file(path, month_dir):
|
|
||||||
archived += 1
|
|
||||||
|
|
||||||
write_state(args.role, {"last_sleep": stamp, "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())})
|
|
||||||
sys.stdout.write(
|
|
||||||
f"新增 {counts['new']} 則、合併 {counts['merge']} 則、捨棄 {counts['drop']} 則、歸檔原始記憶 {archived} 則"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:forget —— 依使用頻率遺忘日常與其他
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_forget(args):
|
|
||||||
"""把日常/其他分類中久未更新且命中次數低的記憶壓縮到 archive/forgotten 後移除。"""
|
|
||||||
root = ensure_layout(args.role)
|
|
||||||
now = datetime.now(TAIPEI)
|
|
||||||
forgotten = []
|
|
||||||
for category, (days, max_hits) in FORGET_RULES.items():
|
|
||||||
for meta, _ in list_memories(args.role, category):
|
|
||||||
updated = parse_stamp(meta.get("updated")) or parse_stamp(meta.get("created"))
|
|
||||||
if updated is None:
|
|
||||||
continue
|
|
||||||
if (now - updated).days < days:
|
|
||||||
continue
|
|
||||||
if int(meta.get("hits") or 0) > max_hits:
|
|
||||||
continue
|
|
||||||
if args.dry_run:
|
|
||||||
forgotten.append(f"{CATEGORY_LABELS[category]}|{meta.get('summary', '')}")
|
|
||||||
continue
|
|
||||||
if archive_file(meta["path"], os.path.join(root, "archive", "forgotten")):
|
|
||||||
forgotten.append(f"{CATEGORY_LABELS[category]}|{meta.get('summary', '')}")
|
|
||||||
|
|
||||||
if not forgotten:
|
|
||||||
sys.stdout.write("沒有符合遺忘條件的記憶")
|
|
||||||
return 0
|
|
||||||
prefix = "(預覽)" if args.dry_run else ""
|
|
||||||
sys.stdout.write(f"{prefix}遺忘 {len(forgotten)} 則:\n" + "\n".join(f"- {item}" for item in forgotten))
|
|
||||||
if not args.dry_run:
|
|
||||||
write_state(args.role, {"last_forget": now_stamp()})
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:stats —— 供 skill 與診斷顯示記憶概況
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_stats(args):
|
|
||||||
"""輸出記憶統計(各分類筆數、待整理筆數、上次整理時間)。"""
|
|
||||||
state = read_state(args.role)
|
|
||||||
rows = [f"| 分類 | 筆數 |", "| --- | --- |"]
|
|
||||||
for category in CATEGORIES:
|
|
||||||
rows.append(f"| {CATEGORY_LABELS[category]} | {len(list_memories(args.role, category))} |")
|
|
||||||
rows.append(f"| 待整理(inbox) | {len(list_inbox(args.role))} |")
|
|
||||||
rows.append("")
|
|
||||||
rows.append(f"- 記憶目錄:{memory_root(args.role)}")
|
|
||||||
rows.append(f"- 上次睡眠整理:{state.get('last_sleep', '尚未整理')}")
|
|
||||||
rows.append(f"- 上次遺忘:{state.get('last_forget', '尚未執行')}")
|
|
||||||
sys.stdout.write("\n".join(rows))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 子命令:need-sleep —— 判斷是否需要補跑睡眠整理
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_mark_sleep(args):
|
|
||||||
"""把本次睡眠週期標記為已整理(inbox 為空、無素材可整理時使用)。"""
|
|
||||||
write_state(args.role, {"last_sleep": now_stamp(), "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())})
|
|
||||||
sys.stdout.write("已更新上次整理時間")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_need_sleep(args):
|
|
||||||
"""
|
|
||||||
判斷是否需要補跑整理:距上次整理超過門檻小時數且 inbox 有內容。
|
|
||||||
|
|
||||||
輸出 yes/no,供 shell 直接判斷(不用解析 JSON)。
|
|
||||||
"""
|
|
||||||
if not list_inbox(args.role):
|
|
||||||
sys.stdout.write("no")
|
|
||||||
return 0
|
|
||||||
state = read_state(args.role)
|
|
||||||
last = parse_stamp(state.get("last_sleep"))
|
|
||||||
if last is None:
|
|
||||||
sys.stdout.write("yes")
|
|
||||||
return 0
|
|
||||||
hours = (datetime.now(TAIPEI) - last).total_seconds() / 3600
|
|
||||||
sys.stdout.write("yes" if hours >= args.hours else "no")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# CLI
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser():
|
|
||||||
"""建立子命令解析器。"""
|
|
||||||
parser = argparse.ArgumentParser(description="角色記憶儲存引擎")
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
write = sub.add_parser("write", help="自 stdin 讀濃縮結果寫入 inbox")
|
|
||||||
write.add_argument("--role", required=True)
|
|
||||||
write.add_argument("--project", default="")
|
|
||||||
write.set_defaults(func=cmd_write)
|
|
||||||
|
|
||||||
load = sub.add_parser("load", help="輸出 SessionStart 要注入的記憶區塊")
|
|
||||||
load.add_argument("--role", required=True)
|
|
||||||
load.add_argument("--limit", type=int, default=int(os.environ.get("ROLE_LOAD_LIMIT", "8000")))
|
|
||||||
load.set_defaults(func=cmd_load)
|
|
||||||
|
|
||||||
collect = sub.add_parser("collect", help="輸出睡眠整理素材")
|
|
||||||
collect.add_argument("--role", required=True)
|
|
||||||
collect.set_defaults(func=cmd_collect)
|
|
||||||
|
|
||||||
apply_cmd = sub.add_parser("apply", help="自 stdin 讀整理結果 JSON 並套用")
|
|
||||||
apply_cmd.add_argument("--role", required=True)
|
|
||||||
apply_cmd.set_defaults(func=cmd_apply)
|
|
||||||
|
|
||||||
forget = sub.add_parser("forget", help="依使用頻率遺忘日常與其他記憶")
|
|
||||||
forget.add_argument("--role", required=True)
|
|
||||||
forget.add_argument("--dry-run", action="store_true")
|
|
||||||
forget.set_defaults(func=cmd_forget)
|
|
||||||
|
|
||||||
stats = sub.add_parser("stats", help="輸出記憶統計")
|
|
||||||
stats.add_argument("--role", required=True)
|
|
||||||
stats.set_defaults(func=cmd_stats)
|
|
||||||
|
|
||||||
mark = sub.add_parser("mark-sleep", help="標記本次睡眠週期已整理")
|
|
||||||
mark.add_argument("--role", required=True)
|
|
||||||
mark.set_defaults(func=cmd_mark_sleep)
|
|
||||||
|
|
||||||
need = sub.add_parser("need-sleep", help="判斷是否需要補跑睡眠整理")
|
|
||||||
need.add_argument("--role", required=True)
|
|
||||||
need.add_argument("--hours", type=float, default=20.0)
|
|
||||||
need.set_defaults(func=cmd_need_sleep)
|
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
|
||||||
"""CLI 進入點。"""
|
|
||||||
args = build_parser().parse_args(argv)
|
|
||||||
return args.func(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:Stop hook 主程式。每輪對話結束後抽出本輪內容 → 呼叫 headless CLI 濃縮成
|
|
||||||
# 一則記憶(分類/總結/標籤/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/,
|
|
||||||
# 等待睡眠時段整理。睡眠時段雖不載入角色,對話仍照常記錄。
|
|
||||||
# 更新時間:2026/07/28 00:00:00
|
|
||||||
# 相依:bash、python3、任一 headless CLI、同目錄的 role_lib.sh/memory.py/transcript.py。
|
|
||||||
# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.py redact 遮蔽一次。
|
|
||||||
# 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
ROLE_STAGE="role-capture"
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=./role_lib.sh
|
|
||||||
. "${SCRIPT_DIR}/role_lib.sh"
|
|
||||||
|
|
||||||
role_is_child && exit 0
|
|
||||||
role_enabled || exit 0
|
|
||||||
command -v python3 >/dev/null 2>&1 || role_quit "找不到 python3,略過記憶記錄" "WRN"
|
|
||||||
|
|
||||||
ROLE="$(role_resolve_name)"
|
|
||||||
[ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄"
|
|
||||||
[ -f "$(role_file "$ROLE")" ] || role_quit "找不到角色定義檔,略過記憶記錄" "WRN"
|
|
||||||
|
|
||||||
CLI="$(role_select_cli)" || exit 0
|
|
||||||
[ -n "$CLI" ] || exit 0
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 讀取 hook 傳入的 JSON(session_id/transcript_path/cwd/stop_hook_active)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
HOOK_INPUT="$(cat)"
|
|
||||||
[ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN"
|
|
||||||
|
|
||||||
read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD <<EOF_HOOK
|
|
||||||
$(printf '%s' "$HOOK_INPUT" | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
d = json.load(sys.stdin)
|
|
||||||
except ValueError:
|
|
||||||
d = {}
|
|
||||||
print(
|
|
||||||
d.get("session_id") or d.get("thread_id") or d.get("conversation_id") or "-",
|
|
||||||
d.get("transcript_path") or d.get("session_path") or d.get("conversation_path") or d.get("path") or "-",
|
|
||||||
"1" if d.get("stop_hook_active") else "0",
|
|
||||||
d.get("cwd", "") or "-",
|
|
||||||
)
|
|
||||||
')
|
|
||||||
EOF_HOOK
|
|
||||||
|
|
||||||
[ "$STOP_ACTIVE" = "1" ] && role_quit "stop_hook_active 為 true,避免迴圈不重複記錄"
|
|
||||||
role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}"
|
|
||||||
|
|
||||||
if [ ! -f "$TRANSCRIPT_PATH" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then
|
|
||||||
TRANSCRIPT_PATH="$(find "${HOME}/.codex/sessions" -type f -name "*${CODEX_THREAD_ID}.jsonl" -print -quit 2>/dev/null)"
|
|
||||||
[ -n "$TRANSCRIPT_PATH" ] || TRANSCRIPT_PATH="-"
|
|
||||||
fi
|
|
||||||
[ -f "$TRANSCRIPT_PATH" ] || role_quit "找不到 transcript:${TRANSCRIPT_PATH}" "WRN"
|
|
||||||
|
|
||||||
TURN="$(python3 "${SCRIPT_DIR}/transcript.py" extract "$TRANSCRIPT_PATH" 2>/dev/null)"
|
|
||||||
[ -n "$TURN" ] || role_quit "本輪無可記錄內容"
|
|
||||||
|
|
||||||
PROJECT="$(role_project_name "$HOOK_CWD")"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 濃縮:產出一則記憶(四欄固定格式),交由 memory.py 落檔
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
PROMPT="$(cat <<EOF_PROMPT
|
|
||||||
你是角色「${ROLE}」的記憶記錄器。輸入是這位角色與使用者的一段對話(含工具呼叫)。
|
|
||||||
請把這段對話濃縮成「一則記憶」,規則:
|
|
||||||
|
|
||||||
已判定專案:${PROJECT}
|
|
||||||
|
|
||||||
1. 只輸出下列四個欄位,欄位名稱與順序固定,不要標題、不要前言、不要結語、不要 code fence:
|
|
||||||
CATEGORY: <六選一:important/interest/news/skill/daily/other>
|
|
||||||
SUMMARY: <一句話總結,40 字內>
|
|
||||||
TAGS: <2 至 4 個標籤,以逗號分隔>
|
|
||||||
CONTENT: <3 至 6 行要點,每行以「- 」開頭>
|
|
||||||
2. 分類判準:
|
|
||||||
- important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。
|
|
||||||
- interest(興趣):使用者反覆關注、主動深入的主題與喜好。
|
|
||||||
- news(新知):這輪學到的新事實、新工具、新版本、外部資訊。
|
|
||||||
- skill(技能):可重複套用的做法、指令、流程、除錯手法。
|
|
||||||
- daily(日常):一次性的例行工作與雜項處理。
|
|
||||||
- other(其他):不屬於上述任何一類。
|
|
||||||
3. 記憶主體是「使用者與這段互動」,不是流水帳:寫值得下次記起來的事,不要抄程式碼、不要貼指令全文。
|
|
||||||
4. 使用繁體中文(台灣用語),保留關鍵事實:檔案/專案/指令/數量/分支/議題編號。
|
|
||||||
5. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
|
||||||
6. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論),只輸出一行:SKIP
|
|
||||||
|
|
||||||
對話片段:
|
|
||||||
${TURN}
|
|
||||||
EOF_PROMPT
|
|
||||||
)"
|
|
||||||
|
|
||||||
RESULT="$(role_run_cli "$CLI" "$PROMPT" 45)"
|
|
||||||
if [ -z "$RESULT" ]; then
|
|
||||||
role_log "WRN" "記憶濃縮產出為空(CLI ${CLI}),略過本輪"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
printf '%s' "$RESULT" | grep -qiE '^\s*SKIP\s*$' && role_quit "判定本輪無值得記住的內容"
|
|
||||||
|
|
||||||
# 第二道防線:對模型輸出再遮蔽一次機密與個資
|
|
||||||
RESULT="$(printf '%s' "$RESULT" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/dev/null)"
|
|
||||||
|
|
||||||
MEMORY_ID="$(printf '%s' "$RESULT" | python3 "${SCRIPT_DIR}/memory.py" write --role "$ROLE" --project "$PROJECT" 2>/dev/null)"
|
|
||||||
if [ -n "$MEMORY_ID" ]; then
|
|
||||||
role_log "INF" "已記錄記憶 ${MEMORY_ID}(角色 ${ROLE},專案 ${PROJECT},CLI ${CLI})"
|
|
||||||
else
|
|
||||||
role_log "WRN" "記憶寫入失敗或內容不足(角色 ${ROLE})"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、
|
|
||||||
# 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。
|
|
||||||
# 本檔僅供 source,不可直接執行。
|
|
||||||
# 更新時間:2026/07/28 00:00:00
|
|
||||||
# 相依:bash、python3;摘要路徑需 README 定義的任一 headless CLI。
|
|
||||||
# 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
ROLE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROLE_STAGE="${ROLE_STAGE:-role}"
|
|
||||||
ROLE_SUPPORTED_CLIS="claude codex agy opencode copilot"
|
|
||||||
ROLE_FALLBACK_MODEL="claude-haiku-4-5-20251001"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 共用輸出
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
role_now() {
|
|
||||||
# 取得台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串
|
|
||||||
TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S'
|
|
||||||
}
|
|
||||||
|
|
||||||
role_log() {
|
|
||||||
# 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr
|
|
||||||
local level="$1" message="$2" stamp
|
|
||||||
stamp="$(role_now)"
|
|
||||||
printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >&2
|
|
||||||
if [ -n "${ROLE_ERRLOG:-}" ] && [ "$level" = "ERR" ]; then
|
|
||||||
printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >> "${ROLE_ERRLOG}" 2>/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
role_quit() {
|
|
||||||
# 記錄原因後以 0 結束:hook 絕不可阻斷使用者流程
|
|
||||||
role_log "${2:-DBG}" "$1"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 路徑與啟用判斷
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
role_home() {
|
|
||||||
# 角色定義目錄(預設 ~/.roles)
|
|
||||||
printf '%s' "${ROLE_HOME:-${HOME}/.roles}"
|
|
||||||
}
|
|
||||||
|
|
||||||
role_memory_home() {
|
|
||||||
# 記憶根目錄(預設 ~/.memory),實際記憶放在 <root>/<角色>/
|
|
||||||
printf '%s' "${ROLE_MEMORY_HOME:-${HOME}/.memory}"
|
|
||||||
}
|
|
||||||
|
|
||||||
role_is_child() {
|
|
||||||
# 判斷本次執行是否來自摘要用的子 CLI 行程,避免 hook 遞迴
|
|
||||||
[ -n "${ROLE_CHILD:-}" ] || [ -n "${WORKLOG_CHILD:-}" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
role_resolve_name() {
|
|
||||||
# 角色決定順序:ROLE_NAME 環境變數 → <角色目錄>/.active;皆無則輸出空字串
|
|
||||||
local name="" active
|
|
||||||
if [ -n "${ROLE_NAME:-}" ]; then
|
|
||||||
name="${ROLE_NAME}"
|
|
||||||
else
|
|
||||||
active="$(role_home)/.active"
|
|
||||||
[ -f "$active" ] && name="$(head -n 1 "$active" 2>/dev/null | tr -d '[:space:]')"
|
|
||||||
fi
|
|
||||||
printf '%s' "$name"
|
|
||||||
}
|
|
||||||
|
|
||||||
role_file() {
|
|
||||||
# 指定角色的定義檔路徑
|
|
||||||
printf '%s/%s.md' "$(role_home)" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
role_enabled() {
|
|
||||||
# 總開關:ROLE_ENABLED=0 強制停用;=1 強制啟用;未設定時「有可解析且存在的角色」才啟用
|
|
||||||
case "${ROLE_ENABLED:-}" in
|
|
||||||
0|false|no) return 1 ;;
|
|
||||||
1|true|yes) return 0 ;;
|
|
||||||
esac
|
|
||||||
local name
|
|
||||||
name="$(role_resolve_name)"
|
|
||||||
[ -n "$name" ] && [ -f "$(role_file "$name")" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
role_in_scope() {
|
|
||||||
# ROLE_SCOPE 為冒號分隔的路徑前綴,未設定則所有目錄都適用
|
|
||||||
local cwd="$1" scope
|
|
||||||
[ -n "${ROLE_SCOPE:-}" ] || return 0
|
|
||||||
IFS=':' read -r -a scopes <<< "${ROLE_SCOPE}"
|
|
||||||
for scope in "${scopes[@]}"; do
|
|
||||||
[ -n "$scope" ] || continue
|
|
||||||
case "$cwd" in "${scope%/}"*) return 0 ;; esac
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 睡眠時段
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
role_time_to_minutes() {
|
|
||||||
# 把 HH:MM 轉成當日分鐘數;格式不合法時回傳空字串
|
|
||||||
local value="$1" hour minute
|
|
||||||
case "$value" in
|
|
||||||
[0-9][0-9]:[0-9][0-9]) ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
hour="${value%%:*}"
|
|
||||||
minute="${value##*:}"
|
|
||||||
printf '%s' "$((10#${hour} * 60 + 10#${minute}))"
|
|
||||||
}
|
|
||||||
|
|
||||||
role_sleep_start() { printf '%s' "${ROLE_SLEEP_START:-22:00}"; }
|
|
||||||
role_sleep_end() { printf '%s' "${ROLE_SLEEP_END:-06:00}"; }
|
|
||||||
|
|
||||||
role_in_sleep_window() {
|
|
||||||
# 判斷現在是否落在睡眠時段(預設 22:00 至隔日 06:00,跨午夜)
|
|
||||||
local start end now
|
|
||||||
start="$(role_time_to_minutes "$(role_sleep_start)")" || return 1
|
|
||||||
end="$(role_time_to_minutes "$(role_sleep_end)")" || return 1
|
|
||||||
now="$(role_time_to_minutes "$(TZ='Asia/Taipei' date +'%H:%M')")" || return 1
|
|
||||||
if [ "$start" -lt "$end" ]; then
|
|
||||||
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
|
|
||||||
else
|
|
||||||
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# AI 行程偵測(睡眠排程的前置檢查)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
role_ai_running() {
|
|
||||||
# 偵測是否有 AI CLI 正在執行;偵測到任何一個即回傳成功(代表「還不能睡」)
|
|
||||||
local cli pid cmd self="$$"
|
|
||||||
for cli in $ROLE_SUPPORTED_CLIS; do
|
|
||||||
for pid in $(pgrep -x "$cli" 2>/dev/null); do
|
|
||||||
[ "$pid" = "$self" ] && continue
|
|
||||||
return 0
|
|
||||||
done
|
|
||||||
done
|
|
||||||
for pid in $(pgrep -f '(^|/)(claude|codex|agy|opencode|copilot)([[:space:]]|$)' 2>/dev/null); do
|
|
||||||
if [ "$pid" = "$self" ] || [ "$pid" = "$PPID" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
cmd="$(ps -o args= -p "$pid" 2>/dev/null)"
|
|
||||||
case "$cmd" in
|
|
||||||
*role_sleep.sh*|*role_capture.sh*|*role_load.sh*|*pgrep*) continue ;;
|
|
||||||
esac
|
|
||||||
return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 摘要 CLI 選擇與呼叫
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
role_detect_current_cli() {
|
|
||||||
# 判斷實際觸發本次執行的助理環境,避免 auto 因 PATH 順序誤選其他 CLI
|
|
||||||
if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ -n "${CODEX_MANAGED_PACKAGE_ROOT:-}" ]; then
|
|
||||||
printf 'codex'; return 0
|
|
||||||
fi
|
|
||||||
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDE_CODE_SSE_PORT:-}" ]; then
|
|
||||||
printf 'claude'; return 0
|
|
||||||
fi
|
|
||||||
if [ -n "${AGY_SESSION_ID:-}" ] || [ -n "${AGY_WORKSPACE_ID:-}" ]; then
|
|
||||||
printf 'agy'; return 0
|
|
||||||
fi
|
|
||||||
if [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_CONFIG:-}" ]; then
|
|
||||||
printf 'opencode'; return 0
|
|
||||||
fi
|
|
||||||
if [ -n "${COPILOT_AGENT_ID:-}" ] || [ -n "${GITHUB_COPILOT_TOKEN:-}" ]; then
|
|
||||||
printf 'copilot'; return 0
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
role_select_cli() {
|
|
||||||
# 選擇摘要/整理用的 headless CLI;可用 ROLE_CLI 強制指定,預設 auto
|
|
||||||
local requested="${ROLE_CLI:-auto}" cli current
|
|
||||||
if [ "$requested" != "auto" ]; then
|
|
||||||
case " ${ROLE_SUPPORTED_CLIS} " in
|
|
||||||
*" ${requested} "*) ;;
|
|
||||||
*) role_log "WRN" "ROLE_CLI 不支援:${requested}(可用:auto ${ROLE_SUPPORTED_CLIS})"; return 1 ;;
|
|
||||||
esac
|
|
||||||
command -v "$requested" >/dev/null 2>&1 || { role_log "WRN" "找不到 ${requested} CLI"; return 1; }
|
|
||||||
printf '%s' "$requested"; return 0
|
|
||||||
fi
|
|
||||||
current="$(role_detect_current_cli)"
|
|
||||||
if [ -n "$current" ] && command -v "$current" >/dev/null 2>&1; then
|
|
||||||
printf '%s' "$current"; return 0
|
|
||||||
fi
|
|
||||||
for cli in $ROLE_SUPPORTED_CLIS; do
|
|
||||||
if command -v "$cli" >/dev/null 2>&1; then
|
|
||||||
printf '%s' "$cli"; return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
role_log "WRN" "找不到可用 CLI(需要其一:${ROLE_SUPPORTED_CLIS})"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
role_run_cli() {
|
|
||||||
# 呼叫選定 CLI 執行提示詞;子行程一律帶 ROLE_CHILD=1 阻斷 hook 遞迴
|
|
||||||
local cli="$1" prompt="$2" seconds="${3:-45}" model="${ROLE_MODEL:-}"
|
|
||||||
[ "$cli" = "claude" ] && [ -z "$model" ] && model="$ROLE_FALLBACK_MODEL"
|
|
||||||
case "$cli" in
|
|
||||||
claude) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" claude -p "$prompt" --model "$model" 2>/dev/null ;;
|
|
||||||
codex) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" codex exec "$prompt" 2>/dev/null ;;
|
|
||||||
agy) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" agy -p "$prompt" 2>/dev/null ;;
|
|
||||||
opencode) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" opencode run "$prompt" 2>/dev/null ;;
|
|
||||||
copilot) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" copilot -p "$prompt" 2>/dev/null ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 記憶目錄鎖:避免睡眠整理與對話寫入同時改動同一份記憶
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
role_lock_acquire() {
|
|
||||||
# 以 mkdir 取得鎖(原子操作);逾時視為前次殘留鎖並強制接手
|
|
||||||
local role="$1" lock="$(role_memory_home)/$1/.lock" age
|
|
||||||
mkdir -p "$(dirname "$lock")" 2>/dev/null
|
|
||||||
if mkdir "$lock" 2>/dev/null; then
|
|
||||||
printf '%s' "$$" > "$lock/pid" 2>/dev/null
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
age="$(find "$lock" -maxdepth 0 -mmin +30 2>/dev/null)"
|
|
||||||
if [ -n "$age" ]; then
|
|
||||||
role_log "WRN" "偵測到超過 30 分鐘的殘留鎖,強制接手:${lock}"
|
|
||||||
rm -rf "$lock" 2>/dev/null
|
|
||||||
mkdir "$lock" 2>/dev/null && { printf '%s' "$$" > "$lock/pid" 2>/dev/null; return 0; }
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
role_lock_release() {
|
|
||||||
# 釋放記憶目錄鎖
|
|
||||||
rm -rf "$(role_memory_home)/$1/.lock" 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
role_project_name() {
|
|
||||||
# 專案判定:git remote 的 <owner>/<repo> 優先,其次目錄名
|
|
||||||
local cwd="$1" origin cleaned owner_repo
|
|
||||||
[ -d "$cwd" ] || { printf '-'; return 0; }
|
|
||||||
local project
|
|
||||||
project="$(basename "$cwd")"
|
|
||||||
if git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
origin="$(git -C "$cwd" remote get-url origin 2>/dev/null)"
|
|
||||||
if [ -n "$origin" ]; then
|
|
||||||
cleaned="${origin%.git}"
|
|
||||||
cleaned="${cleaned##*://}"
|
|
||||||
cleaned="${cleaned#*@}"
|
|
||||||
owner_repo="$(printf '%s' "$cleaned" | awk -F/ 'NF>=2 {print $(NF-1)"/"$NF}')"
|
|
||||||
[ -n "$owner_repo" ] && project="$owner_repo"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
printf '%s' "$project"
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:SessionStart hook 主程式。CLI 工具啟動時載入角色設定與記憶:
|
|
||||||
# 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤;
|
|
||||||
# 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。
|
|
||||||
# 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。
|
|
||||||
# 更新時間:2026/07/28 00:00:00
|
|
||||||
# 相依:bash、python3、同目錄的 role_lib.sh 與 memory.py。
|
|
||||||
# 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
ROLE_STAGE="role-load"
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=./role_lib.sh
|
|
||||||
. "${SCRIPT_DIR}/role_lib.sh"
|
|
||||||
|
|
||||||
role_is_child && exit 0
|
|
||||||
role_enabled || exit 0
|
|
||||||
command -v python3 >/dev/null 2>&1 || role_quit "找不到 python3,略過角色載入" "WRN"
|
|
||||||
|
|
||||||
ROLE="$(role_resolve_name)"
|
|
||||||
[ -n "$ROLE" ] || role_quit "未指定角色(ROLE_NAME 與 .active 皆無),略過角色載入"
|
|
||||||
ROLE_DEF="$(role_file "$ROLE")"
|
|
||||||
[ -f "$ROLE_DEF" ] || role_quit "找不到角色定義檔:${ROLE_DEF}" "WRN"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 讀取 hook 輸入(cwd/source),並套用 ROLE_SCOPE 範圍限制
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
HOOK_INPUT="$(cat 2>/dev/null)"
|
|
||||||
HOOK_CWD="$PWD"
|
|
||||||
if [ -n "$HOOK_INPUT" ]; then
|
|
||||||
HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
data = json.load(sys.stdin)
|
|
||||||
except ValueError:
|
|
||||||
data = {}
|
|
||||||
print(data.get("cwd") or "")
|
|
||||||
' 2>/dev/null)"
|
|
||||||
[ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD"
|
|
||||||
fi
|
|
||||||
role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}"
|
|
||||||
|
|
||||||
emit_context() {
|
|
||||||
# 以 JSON 輸出 additionalContext(由 python 負責跳脫,避免內容含引號或換行破壞格式)
|
|
||||||
printf '%s' "$1" | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
context = sys.stdin.read()
|
|
||||||
print(json.dumps(
|
|
||||||
{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}},
|
|
||||||
ensure_ascii=False,
|
|
||||||
))
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
||||||
SLEEP_START="$(role_sleep_start)"
|
|
||||||
SLEEP_END="$(role_sleep_end)"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 睡眠時段:不載入角色,只說明目前狀態
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
if role_in_sleep_window; then
|
|
||||||
emit_context "$(cat <<EOF_SLEEP
|
|
||||||
# 角色狀態:睡眠中(${SLEEP_START}–${SLEEP_END})
|
|
||||||
|
|
||||||
角色「${ROLE}」正在睡覺,本次工作階段**不載入角色人格與記憶**,請以一般助理身分回應,
|
|
||||||
不要自稱該角色、不要使用角色語氣或簽名 emoji。若使用者詢問角色,說明角色在睡眠時段整理記憶,
|
|
||||||
${SLEEP_END} 之後會恢復。本階段的對話仍會被記錄成記憶,於下個睡眠時段整理。
|
|
||||||
EOF_SLEEP
|
|
||||||
)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 非睡眠時段:組出角色定義 + 記憶
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
DEFINITION="$(cat "$ROLE_DEF" 2>/dev/null)"
|
|
||||||
[ -n "$DEFINITION" ] || role_quit "角色定義檔為空:${ROLE_DEF}" "WRN"
|
|
||||||
|
|
||||||
MEMORY="$(python3 "${SCRIPT_DIR}/memory.py" load --role "$ROLE" 2>/dev/null)"
|
|
||||||
|
|
||||||
# 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理
|
|
||||||
CATCHUP_NOTE=""
|
|
||||||
if [ "$(python3 "${SCRIPT_DIR}/memory.py" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then
|
|
||||||
nohup "${SCRIPT_DIR}/role_sleep.sh" --catchup >/dev/null 2>&1 &
|
|
||||||
CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n'
|
|
||||||
role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE})"
|
|
||||||
fi
|
|
||||||
|
|
||||||
CONTEXT="$(cat <<EOF_CONTEXT
|
|
||||||
# 角色載入:${ROLE}
|
|
||||||
|
|
||||||
以下是本次工作階段要扮演的角色設定與既有記憶。請**全程以此角色的身分、語氣與簽名 emoji 回應**,
|
|
||||||
角色設定與使用者的實際指令衝突時,以使用者指令為準(角色只影響表達方式,不影響工作正確性)。
|
|
||||||
|
|
||||||
${DEFINITION}
|
|
||||||
|
|
||||||
# 你對這位使用者的記憶
|
|
||||||
|
|
||||||
${MEMORY:-(尚無已整理的記憶。)}
|
|
||||||
${CATCHUP_NOTE}
|
|
||||||
> 記憶載入規則:重要與興趣記憶載入全文;技能、新知、日常、其他僅載入總結與標籤,
|
|
||||||
> 需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。
|
|
||||||
|
|
||||||
> 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事,
|
|
||||||
> 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔):
|
|
||||||
>
|
|
||||||
> \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | python3 "${SCRIPT_DIR}/memory.py" write --role "${ROLE}"\`
|
|
||||||
>
|
|
||||||
> CATEGORY 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。
|
|
||||||
EOF_CONTEXT
|
|
||||||
)"
|
|
||||||
|
|
||||||
emit_context "$CONTEXT"
|
|
||||||
role_log "INF" "已載入角色 ${ROLE}(記憶 $(printf '%s' "$MEMORY" | wc -c) 位元組)"
|
|
||||||
exit 0
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run),
|
|
||||||
# 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶:
|
|
||||||
# 分類(重要/興趣/新知/技能/日常/其他)→ 去重合併 → 設標籤與一句話
|
|
||||||
# 總結 → 壓縮內容歸檔 → 日常與其他依使用頻率遺忘。
|
|
||||||
# 另提供 --catchup(cron 未執行時的補跑)、--force(手動立即整理)、
|
|
||||||
# --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。
|
|
||||||
# 更新時間:2026/07/28 00:00:00
|
|
||||||
# 相依:bash、python3、任一 headless CLI、crontab(僅排程安裝需要)、
|
|
||||||
# 同目錄的 role_lib.sh 與 memory.py。
|
|
||||||
# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
ROLE_STAGE="role-sleep"
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=./role_lib.sh
|
|
||||||
. "${SCRIPT_DIR}/role_lib.sh"
|
|
||||||
|
|
||||||
CRON_MARKER="# jsc-role-sleep"
|
|
||||||
SLEEP_TIMEOUT="${ROLE_SLEEP_TIMEOUT:-180}"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
# 印出用法
|
|
||||||
cat <<'EOF_USAGE'
|
|
||||||
用法:role_sleep.sh <模式>
|
|
||||||
|
|
||||||
--run cron 觸發:在睡眠時段內且無 AI 運行時整理記憶
|
|
||||||
--catchup 補跑:cron 未執行時,由 SessionStart hook 於背景呼叫
|
|
||||||
--force 立即整理一次(忽略時段與 AI 運行檢查)
|
|
||||||
--install-cron 安裝/更新睡眠排程(每小時檢查一次)
|
|
||||||
--remove-cron 移除睡眠排程
|
|
||||||
--status 顯示角色、睡眠時段、排程與記憶統計
|
|
||||||
EOF_USAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
require_role() {
|
|
||||||
# 解析角色並確認定義檔存在,取不到時中止
|
|
||||||
ROLE="$(role_resolve_name)"
|
|
||||||
[ -n "$ROLE" ] || { role_log "WRN" "未指定角色(ROLE_NAME 與 .active 皆無)"; exit 1; }
|
|
||||||
[ -f "$(role_file "$ROLE")" ] || { role_log "WRN" "找不到角色定義檔:$(role_file "$ROLE")"; exit 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 整理主流程
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sleep_cycle() {
|
|
||||||
# 執行一次完整記憶整理:收集素材 → 模型分類去重 → 落檔歸檔 → 遺忘
|
|
||||||
local reason="$1" cli material prompt result applied forgotten
|
|
||||||
command -v python3 >/dev/null 2>&1 || { role_log "ERR" "找不到 python3,無法整理記憶"; return 1; }
|
|
||||||
|
|
||||||
if ! role_lock_acquire "$ROLE"; then
|
|
||||||
role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE})"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
trap 'role_lock_release "$ROLE"' EXIT
|
|
||||||
|
|
||||||
material="$(python3 "${SCRIPT_DIR}/memory.py" collect --role "$ROLE" 2>/dev/null)"
|
|
||||||
if [ -z "$material" ]; then
|
|
||||||
role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason})"
|
|
||||||
python3 "${SCRIPT_DIR}/memory.py" mark-sleep --role "$ROLE" >/dev/null 2>&1
|
|
||||||
forgotten="$(python3 "${SCRIPT_DIR}/memory.py" forget --role "$ROLE" 2>/dev/null)"
|
|
||||||
role_log "INF" "遺忘檢查:${forgotten}"
|
|
||||||
role_lock_release "$ROLE"
|
|
||||||
trap - EXIT
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
cli="$(role_select_cli)" || { role_lock_release "$ROLE"; trap - EXIT; return 1; }
|
|
||||||
|
|
||||||
prompt="$(cat <<EOF_PROMPT
|
|
||||||
你是角色「${ROLE}」的睡眠記憶整理器。輸入包含兩段:INBOX(本次待整理的記憶)與 EXISTING(既有記憶索引)。
|
|
||||||
請把 INBOX 整理成歸檔用的記憶,規則:
|
|
||||||
|
|
||||||
1. 只輸出一個 JSON 物件,不要前言、不要結語、不要 code fence,格式為:
|
|
||||||
{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"content":"- 要點\n- 要點","from":["inbox 的 id"]}]}
|
|
||||||
2. action 三選一:
|
|
||||||
- new:新的一則記憶。多則 INBOX 講同一件事時合成一筆,from 列出全部來源 id。
|
|
||||||
- merge:內容已被 EXISTING 中某則涵蓋或重複,填 target 為該既有 id,content 寫合併後的完整內容。
|
|
||||||
- drop:純雜訊、無保存價值,只需填 from。
|
|
||||||
3. category 六選一:important(重要)/interest(興趣)/news(新知)/skill(技能)/daily(日常)/other(其他)。
|
|
||||||
important 放長期偏好、規範、決策與身分背景;interest 放反覆關注的主題;news 放新事實與外部資訊;
|
|
||||||
skill 放可重複套用的做法;daily 放一次性例行工作;其餘歸 other。
|
|
||||||
4. **每一則 INBOX 的 id 都必須出現在某一筆的 from 中**,沒被提及的會留到下個睡眠週期重做。
|
|
||||||
5. content 壓縮成 5 行以內要點(每行以「- 」開頭),總長不超過 400 字,去除重複敘述與流水帳。
|
|
||||||
6. summary 一句話 40 字內;tags 2 至 4 個。全部使用繁體中文(台灣用語)。
|
|
||||||
7. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
|
||||||
|
|
||||||
素材:
|
|
||||||
${material}
|
|
||||||
EOF_PROMPT
|
|
||||||
)"
|
|
||||||
|
|
||||||
result="$(role_run_cli "$cli" "$prompt" "$SLEEP_TIMEOUT")"
|
|
||||||
if [ -z "$result" ]; then
|
|
||||||
role_log "ERR" "整理結果為空(CLI ${cli}),保留待整理記憶到下個週期"
|
|
||||||
role_lock_release "$ROLE"
|
|
||||||
trap - EXIT
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
result="$(printf '%s' "$result" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/dev/null)"
|
|
||||||
applied="$(printf '%s' "$result" | python3 "${SCRIPT_DIR}/memory.py" apply --role "$ROLE" 2>/dev/null)"
|
|
||||||
if [ -z "$applied" ]; then
|
|
||||||
role_log "ERR" "整理結果無法套用(角色 ${ROLE}),保留待整理記憶到下個週期"
|
|
||||||
role_lock_release "$ROLE"
|
|
||||||
trap - EXIT
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}"
|
|
||||||
|
|
||||||
forgotten="$(python3 "${SCRIPT_DIR}/memory.py" forget --role "$ROLE" 2>/dev/null | tr '\n' ';')"
|
|
||||||
role_log "INF" "遺忘檢查:${forgotten}"
|
|
||||||
|
|
||||||
role_lock_release "$ROLE"
|
|
||||||
trap - EXIT
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 排程安裝:cron 環境沒有互動 shell 的環境變數,需把必要變數與 PATH 一併寫入
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
cron_quote() {
|
|
||||||
# 把值包成單引號:PATH 等變數常含空白(例如 /mnt/c/Program Files),
|
|
||||||
# 未加引號會被 cron 的 sh 拆成指令;% 是 cron 的換行符號,一律跳脫。
|
|
||||||
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g; s/%/\\\\%/g")"
|
|
||||||
}
|
|
||||||
|
|
||||||
cron_line() {
|
|
||||||
# 組出 crontab 條目:睡眠時段內每小時檢查一次
|
|
||||||
local env_prefix="PATH=$(cron_quote "$PATH")"
|
|
||||||
local var
|
|
||||||
for var in ROLE_ENABLED ROLE_NAME ROLE_HOME ROLE_MEMORY_HOME ROLE_CLI ROLE_MODEL ROLE_SLEEP_START ROLE_SLEEP_END ROLE_SCOPE; do
|
|
||||||
if [ -n "${!var:-}" ]; then
|
|
||||||
env_prefix="${env_prefix} ${var}=$(cron_quote "${!var}")"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
printf '0 %s * * * %s %s --run >> %s 2>&1 %s\n' \
|
|
||||||
"$(cron_hours)" "$env_prefix" "$(cron_quote "${SCRIPT_DIR}/role_sleep.sh")" \
|
|
||||||
"$(cron_quote "$(sleep_log_path)")" "$CRON_MARKER"
|
|
||||||
}
|
|
||||||
|
|
||||||
cron_hours() {
|
|
||||||
# 依睡眠時段換算 cron 小時欄位(每小時檢查一次,讓 AI 運行中的情況能在下個小時重試)
|
|
||||||
local start end hour hours=""
|
|
||||||
start="$(role_time_to_minutes "$(role_sleep_start)")" || { printf '22-23,0-5'; return 0; }
|
|
||||||
end="$(role_time_to_minutes "$(role_sleep_end)")" || { printf '22-23,0-5'; return 0; }
|
|
||||||
start=$((start / 60))
|
|
||||||
end=$((end / 60))
|
|
||||||
hour="$start"
|
|
||||||
while [ "$hour" != "$end" ]; do
|
|
||||||
hours="${hours}${hours:+,}${hour}"
|
|
||||||
hour=$(((hour + 1) % 24))
|
|
||||||
done
|
|
||||||
printf '%s' "${hours:-22,23,0,1,2,3,4,5}"
|
|
||||||
}
|
|
||||||
|
|
||||||
sleep_log_path() {
|
|
||||||
# 排程輸出的 log 路徑(只記狀態訊息,不含記憶內容)
|
|
||||||
printf '%s/sleep.log' "$(role_home)"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_cron() {
|
|
||||||
# 安裝或更新睡眠排程;以 marker 註解辨識自己的條目,不動使用者其他排程
|
|
||||||
command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab,無法安裝排程"; return 1; }
|
|
||||||
mkdir -p "$(role_home)" 2>/dev/null
|
|
||||||
local current new
|
|
||||||
current="$(crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER")"
|
|
||||||
new="$(printf '%s\n%s' "$current" "$(cron_line)" | sed '/^$/d')"
|
|
||||||
printf '%s\n' "$new" | crontab - || { role_log "ERR" "寫入 crontab 失敗"; return 1; }
|
|
||||||
role_log "INF" "已安裝睡眠排程:每日 $(cron_hours) 時整點檢查(角色 ${ROLE},時段 $(role_sleep_start)–$(role_sleep_end))"
|
|
||||||
role_log "INF" "排程輸出:$(sleep_log_path)"
|
|
||||||
if ! pgrep -x cron >/dev/null 2>&1 && ! pgrep -x crond >/dev/null 2>&1; then
|
|
||||||
role_log "WRN" "系統 cron 服務未執行(WSL 常見),排程不會觸發;SessionStart 的背景補跑仍會運作"
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
remove_cron() {
|
|
||||||
# 移除本 skill 安裝的排程條目
|
|
||||||
command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab"; return 1; }
|
|
||||||
crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER" | crontab -
|
|
||||||
role_log "INF" "已移除睡眠排程"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
show_status() {
|
|
||||||
# 以表格輸出目前角色與記憶狀態(供 skill 的 --status 使用)
|
|
||||||
local cron_state="未安裝" cron_service="未執行" window="否"
|
|
||||||
crontab -l 2>/dev/null | grep -qF "$CRON_MARKER" && cron_state="已安裝"
|
|
||||||
{ pgrep -x cron >/dev/null 2>&1 || pgrep -x crond >/dev/null 2>&1; } && cron_service="執行中"
|
|
||||||
role_in_sleep_window && window="是"
|
|
||||||
printf '| 項目 | 值 |\n| --- | --- |\n'
|
|
||||||
printf '| 角色 | %s |\n' "$ROLE"
|
|
||||||
printf '| 角色定義檔 | %s |\n' "$(role_file "$ROLE")"
|
|
||||||
printf '| 睡眠時段 | %s–%s |\n' "$(role_sleep_start)" "$(role_sleep_end)"
|
|
||||||
printf '| 目前是否睡眠中 | %s |\n' "$window"
|
|
||||||
printf '| cron 排程 | %s |\n' "$cron_state"
|
|
||||||
printf '| cron 服務 | %s |\n' "$cron_service"
|
|
||||||
printf '| 摘要 CLI | %s |\n' "$(role_select_cli 2>/dev/null || printf '找不到可用 CLI')"
|
|
||||||
printf '\n'
|
|
||||||
python3 "${SCRIPT_DIR}/memory.py" stats --role "$ROLE" 2>/dev/null
|
|
||||||
printf '\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 進入點
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MODE="${1:---status}"
|
|
||||||
case "$MODE" in
|
|
||||||
--run)
|
|
||||||
role_enabled || exit 0
|
|
||||||
require_role
|
|
||||||
if ! role_in_sleep_window; then
|
|
||||||
role_log "DBG" "目前不在睡眠時段($(role_sleep_start)–$(role_sleep_end)),略過"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if role_ai_running; then
|
|
||||||
role_log "INF" "偵測到 AI 正在運行,本小時不進入睡眠,下個整點再檢查"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep_cycle "cron"
|
|
||||||
;;
|
|
||||||
--catchup)
|
|
||||||
role_enabled || exit 0
|
|
||||||
require_role
|
|
||||||
if [ "$(python3 "${SCRIPT_DIR}/memory.py" need-sleep --role "$ROLE" 2>/dev/null)" != "yes" ]; then
|
|
||||||
role_log "DBG" "不需補跑整理"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep_cycle "補跑"
|
|
||||||
;;
|
|
||||||
--force)
|
|
||||||
require_role
|
|
||||||
sleep_cycle "手動"
|
|
||||||
;;
|
|
||||||
--install-cron)
|
|
||||||
require_role
|
|
||||||
install_cron
|
|
||||||
;;
|
|
||||||
--remove-cron)
|
|
||||||
remove_cron
|
|
||||||
;;
|
|
||||||
--status)
|
|
||||||
require_role
|
|
||||||
show_status
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
usage
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:角色記憶的 transcript 處理工具。負責 (1) 從 Claude Code/Codex
|
|
||||||
# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
|
|
||||||
# (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII),
|
|
||||||
# 作為寫入記憶檔前的第二道防線。
|
|
||||||
# 更新時間:2026/07/28 00:00:00
|
|
||||||
# 相依:Python 3 標準庫。抽取與遮蔽全程僅走 stdin/stdout,本檔不寫任何檔案。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
# 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入
|
|
||||||
TOOL_RESULT_LIMIT = 200
|
|
||||||
TOOL_INPUT_LIMIT = 160
|
|
||||||
TOTAL_LIMIT = 24000
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 機密遮蔽規則:命中一律換成 ***
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
REDACT_PATTERNS = [
|
|
||||||
(r"[A-Za-z0-9_\-]*:[A-Za-z0-9_\-]{16,}@", "***@"), # URL 內嵌憑證 user:token@
|
|
||||||
(r"\b[0-9a-f]{40}\b", "***"), # Gitea 40 字元 token
|
|
||||||
(r"\bgh[pousr]_[A-Za-z0-9_]{16,}\b", "***"), # GitHub token
|
|
||||||
(r"\bsk-[A-Za-z0-9\-_]{16,}\b", "***"), # API key
|
|
||||||
(r"(?i)\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+", r"\1=***"),
|
|
||||||
(r"(?i)Authorization:\s*(token|bearer)\s+\S+", r"Authorization: \1 ***"),
|
|
||||||
(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", "***"), # Email
|
|
||||||
(r"\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b", "***"), # 台灣手機
|
|
||||||
(r"\b[A-Z][12]\d{8}\b", "***"), # 身分證字號
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def redact(text):
|
|
||||||
"""對文字套用全部機密遮蔽規則,回傳遮蔽後的結果。"""
|
|
||||||
for pattern, replacement in REDACT_PATTERNS:
|
|
||||||
text = re.sub(pattern, replacement, text)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _is_real_user_message(entry):
|
|
||||||
"""判斷 transcript 條目是否為真正的使用者輸入(排除工具回填與環境注入)。"""
|
|
||||||
payload = entry.get("payload")
|
|
||||||
if isinstance(payload, dict) and entry.get("type") == "event_msg":
|
|
||||||
return payload.get("type") == "user_message" and bool(str(payload.get("message") or "").strip())
|
|
||||||
|
|
||||||
if entry.get("type") != "user":
|
|
||||||
return False
|
|
||||||
content = entry.get("message", {}).get("content")
|
|
||||||
if isinstance(content, str):
|
|
||||||
return bool(content.strip())
|
|
||||||
if isinstance(content, list):
|
|
||||||
return any(b.get("type") == "text" for b in content if isinstance(b, dict))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _blocks(entry):
|
|
||||||
"""取出條目的 content blocks,統一為 list 形式。"""
|
|
||||||
content = entry.get("message", {}).get("content")
|
|
||||||
if isinstance(content, str):
|
|
||||||
return [{"type": "text", "text": content}]
|
|
||||||
return content if isinstance(content, list) else []
|
|
||||||
|
|
||||||
|
|
||||||
def _payload_text_blocks(content):
|
|
||||||
"""把 Codex response_item 的 content blocks 轉成純文字片段。"""
|
|
||||||
if isinstance(content, str):
|
|
||||||
return [content]
|
|
||||||
if not isinstance(content, list):
|
|
||||||
return []
|
|
||||||
texts = []
|
|
||||||
for block in content:
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
if block.get("type") in ("input_text", "output_text", "text"):
|
|
||||||
text = (block.get("text") or "").strip()
|
|
||||||
if text:
|
|
||||||
texts.append(text)
|
|
||||||
return texts
|
|
||||||
|
|
||||||
|
|
||||||
def _render_codex_payload(entry):
|
|
||||||
"""將 Codex session JSONL 的 payload 格式轉為摘要輸入用純文字。"""
|
|
||||||
payload = entry.get("payload")
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return []
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
entry_type = entry.get("type")
|
|
||||||
payload_type = payload.get("type")
|
|
||||||
|
|
||||||
if entry_type == "event_msg":
|
|
||||||
if payload_type == "user_message":
|
|
||||||
message = (payload.get("message") or "").strip()
|
|
||||||
if message:
|
|
||||||
lines.append(f"[user] {message}")
|
|
||||||
elif payload_type == "agent_message":
|
|
||||||
message = (payload.get("message") or "").strip()
|
|
||||||
if message:
|
|
||||||
phase = payload.get("phase") or "assistant"
|
|
||||||
lines.append(f"[assistant:{phase}] {message}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
if entry_type != "response_item":
|
|
||||||
return lines
|
|
||||||
|
|
||||||
if payload_type == "message":
|
|
||||||
role = payload.get("role") or "assistant"
|
|
||||||
if role in ("system", "developer"):
|
|
||||||
return lines
|
|
||||||
for text in _payload_text_blocks(payload.get("content")):
|
|
||||||
# Codex 會把 skill 內容以 user role 注入;避免把整份 SKILL.md 當成本輪工作。
|
|
||||||
if role == "user" and text.lstrip().startswith("<skill>"):
|
|
||||||
continue
|
|
||||||
if role == "user" and text.lstrip().startswith("<environment_context>"):
|
|
||||||
continue
|
|
||||||
lines.append(f"[{role}] {text}")
|
|
||||||
elif payload_type == "function_call":
|
|
||||||
name = payload.get("name") or "?"
|
|
||||||
raw = str(payload.get("arguments") or "").strip().replace("\n", " ")
|
|
||||||
lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}")
|
|
||||||
elif payload_type == "function_call_output":
|
|
||||||
raw = str(payload.get("output") or "").strip().replace("\n", " ")
|
|
||||||
if raw:
|
|
||||||
lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}")
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _render(entry):
|
|
||||||
"""將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。"""
|
|
||||||
codex_lines = _render_codex_payload(entry)
|
|
||||||
if codex_lines:
|
|
||||||
return codex_lines
|
|
||||||
|
|
||||||
role = entry.get("type")
|
|
||||||
lines = []
|
|
||||||
for block in _blocks(entry):
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
kind = block.get("type")
|
|
||||||
if kind == "text":
|
|
||||||
text = (block.get("text") or "").strip()
|
|
||||||
if text:
|
|
||||||
lines.append(f"[{role}] {text}")
|
|
||||||
elif kind == "tool_use":
|
|
||||||
name = block.get("name", "?")
|
|
||||||
raw = json.dumps(block.get("input", {}), ensure_ascii=False)
|
|
||||||
lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}")
|
|
||||||
elif kind == "tool_result":
|
|
||||||
raw = block.get("content")
|
|
||||||
if isinstance(raw, list):
|
|
||||||
raw = " ".join(
|
|
||||||
b.get("text", "") for b in raw if isinstance(b, dict) and b.get("type") == "text"
|
|
||||||
)
|
|
||||||
raw = str(raw or "").strip().replace("\n", " ")
|
|
||||||
if raw:
|
|
||||||
lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _read_entries(path):
|
|
||||||
"""讀取 transcript JSONL,忽略無法解析的列。"""
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as fh:
|
|
||||||
entries = []
|
|
||||||
for line in fh:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
entries.append(json.loads(line))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def _turn_start_index(entries):
|
|
||||||
"""找出本輪起點:最後一筆真正使用者訊息的位置。"""
|
|
||||||
start = 0
|
|
||||||
for index in range(len(entries) - 1, -1, -1):
|
|
||||||
if _is_real_user_message(entries[index]):
|
|
||||||
start = index
|
|
||||||
break
|
|
||||||
return start
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_timestamp(value):
|
|
||||||
"""解析常見 transcript timestamp 格式,失敗回 None。"""
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
return None
|
|
||||||
raw = value.strip()
|
|
||||||
if raw.endswith("Z"):
|
|
||||||
raw = raw[:-1] + "+00:00"
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(raw)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
def _entry_timestamp(entry):
|
|
||||||
"""取出 transcript 條目的時間欄位。"""
|
|
||||||
for key in ("timestamp", "created_at", "time"):
|
|
||||||
dt = _parse_timestamp(entry.get(key))
|
|
||||||
if dt:
|
|
||||||
return dt
|
|
||||||
message = entry.get("message")
|
|
||||||
if isinstance(message, dict):
|
|
||||||
for key in ("timestamp", "created_at", "time"):
|
|
||||||
dt = _parse_timestamp(message.get(key))
|
|
||||||
if dt:
|
|
||||||
return dt
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds):
|
|
||||||
"""把秒數格式化為精簡中文耗時。"""
|
|
||||||
if seconds < 0:
|
|
||||||
return "未判定"
|
|
||||||
minutes = int(round(seconds / 60))
|
|
||||||
if minutes <= 0:
|
|
||||||
return "1 分鐘內"
|
|
||||||
hours, mins = divmod(minutes, 60)
|
|
||||||
if hours and mins:
|
|
||||||
return f"{hours} 小時 {mins} 分鐘"
|
|
||||||
if hours:
|
|
||||||
return f"{hours} 小時"
|
|
||||||
return f"{mins} 分鐘"
|
|
||||||
|
|
||||||
|
|
||||||
def turn_duration(path):
|
|
||||||
"""
|
|
||||||
估算本輪花費時間:取本輪起點到最後一筆可解析 timestamp 的差距。
|
|
||||||
|
|
||||||
transcript 無時間欄位或本輪少於兩個時間點時回「未判定」,避免臆測。
|
|
||||||
"""
|
|
||||||
entries = _read_entries(path)
|
|
||||||
if not entries:
|
|
||||||
return "未判定"
|
|
||||||
start = _turn_start_index(entries)
|
|
||||||
stamps = [dt for dt in (_entry_timestamp(e) for e in entries[start:]) if dt]
|
|
||||||
if len(stamps) < 2:
|
|
||||||
return "未判定"
|
|
||||||
return format_duration((max(stamps) - min(stamps)).total_seconds())
|
|
||||||
|
|
||||||
|
|
||||||
def extract_turn(path):
|
|
||||||
"""
|
|
||||||
從 transcript JSONL 抽出本輪內容:最後一筆真正使用者訊息(含該筆)之後的全部條目。
|
|
||||||
|
|
||||||
不需任何狀態檔即可界定「本輪」,符合工作內容不落地的要求。
|
|
||||||
回傳純文字字串;讀取失敗或無內容時回空字串。
|
|
||||||
"""
|
|
||||||
entries = _read_entries(path)
|
|
||||||
if not entries:
|
|
||||||
return ""
|
|
||||||
start = _turn_start_index(entries)
|
|
||||||
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
for entry in entries[start:]:
|
|
||||||
lines.extend(_render(entry))
|
|
||||||
|
|
||||||
text = "\n".join(lines).strip()
|
|
||||||
if len(text) > TOTAL_LIMIT:
|
|
||||||
head = text[: TOTAL_LIMIT // 2]
|
|
||||||
tail = text[-TOTAL_LIMIT // 2 :]
|
|
||||||
text = f"{head}\n…(中段省略)…\n{tail}"
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
USAGE = """用法:transcript.py <子命令> [參數]
|
|
||||||
|
|
||||||
extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
|
|
||||||
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
|
|
||||||
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
|
||||||
"""CLI 進入點:解析子命令並執行抽取或遮蔽。"""
|
|
||||||
if not argv or argv[0] in ("-h", "--help"):
|
|
||||||
print(USAGE)
|
|
||||||
return 0
|
|
||||||
if argv[0] == "extract":
|
|
||||||
if len(argv) < 2:
|
|
||||||
return 2
|
|
||||||
text = extract_turn(argv[1])
|
|
||||||
if not text:
|
|
||||||
return 1
|
|
||||||
sys.stdout.write(redact(text))
|
|
||||||
return 0
|
|
||||||
if argv[0] == "duration":
|
|
||||||
if len(argv) < 2:
|
|
||||||
return 2
|
|
||||||
sys.stdout.write(turn_duration(argv[1]))
|
|
||||||
return 0
|
|
||||||
if argv[0] == "redact":
|
|
||||||
sys.stdout.write(redact(sys.stdin.read()))
|
|
||||||
return 0
|
|
||||||
print(USAGE)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
---
|
||||||
|
name: plugins-install
|
||||||
|
description: 一次把 JSC 的四個 plugin(jsc-code/jsc-doc/jsc-persona/jsc-shared)安裝或更新到一個或多個 AI 助理,可同時處理 Claude Code、Codex、GitHub Copilot CLI、Antigravity 四家原生 plugin CLI 與沒有 plugin 匯入指令、但可使用 skill 的助理;沒指定時偵測本機裝了哪些 CLI 並讓使用者多選,每個 plugin 先判斷已安裝或未安裝,未安裝就安裝、已安裝就更新到最新。非指令助理先把技能組 clone 到工具專屬資料夾,再依技能組 README.md 將技能匯入到指定位置:已安裝就在 README 指到的路徑就地更新,未安裝才放進工具的預設資料夾,最後以「助理 × plugin」的表格回報動作、位置、結果與版本。當使用者說要安裝所有 jsc plugin、一次更新全部 skill 套件、把 code/doc/persona/shared 都裝起來、要同時更新好幾個 CLI、換新機器要把 plugin 都補齊、技能匯入到錯的地方、要更新專案自己那份 skills、或問怎麼一次更新所有 plugin 時觸發。不適用於:移除 plugin(用 /jsc-shared:plugins-uninstall)、只處理單一 plugin(直接照該 plugin README 的安裝章節)、安裝非 JSC 的第三方 plugin。
|
||||||
|
argument-hint: "[--assistant <助理清單,逗號分隔,或 all>] [--plugins code,doc,persona,shared] [--host <gitea 主機>] [--clone-dir <目錄>] [--yes]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# plugins-install — 一次安裝/更新所有 JSC plugin
|
||||||
|
|
||||||
|
四階段 skill:先**決定助理與目標清單**,再**逐一判斷已安裝或未安裝**,接著**安裝或更新**,最後**回報結果並提醒重啟工作階段**。
|
||||||
|
|
||||||
|
| 階段 | 動作 |
|
||||||
|
| --- | --- |
|
||||||
|
| A. 前置設定 | 決定要操作哪些助理(`--assistant` 可帶多個或 all/偵測本機有哪些 CLI/多個就讓使用者多選)→ 決定 gitea 主機 → 決定 plugin 清單(預設 code、doc、persona、shared) |
|
||||||
|
| B. 現況盤點 | 對每個 plugin 查詢 marketplace 與 plugin 是否已存在,決定「安裝」或「更新」 |
|
||||||
|
| C. 安裝/更新 | 依助理的原生指令逐一執行;Antigravity 走 clone+本地路徑,預設把本機 clone 收在 `~/.gemini/plugins`(避免共用開發中工作區),沒有 plugin 匯入指令但可使用 skill 的助理則先 clone 技能組到工具專屬資料夾,再依 README.md 匯入到指定位置 |
|
||||||
|
| D. 回報 | 以表格列出每個 plugin 的動作、結果與版本,並提醒重啟工作階段 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 共用規範(shared plugin,必要前置)
|
||||||
|
|
||||||
|
執行本 skill 前,先以 Skill 工具載入下列共用規範並全程遵守:
|
||||||
|
|
||||||
|
- `/jsc-shared:spec-output`:繁體中文(台灣用語)、UTF-8(不含 BOM)無亂碼、表格呈現。
|
||||||
|
- `/jsc-shared:spec-execution`:自動執行原則(必要決策才中斷)、不臆測/需人工確認。
|
||||||
|
- `/jsc-shared:spec-git-safety`:Antigravity/其他會動到本機 clone 的工具路徑有未提交變更時不得強制更新。
|
||||||
|
|
||||||
|
本 skill 特有補充:
|
||||||
|
|
||||||
|
- **不移除任何東西**。更新時就算需要「先移除再安裝」(Antigravity 沒有 update 子指令),也只針對該 plugin 自己,且移除後必須立刻重裝成功。
|
||||||
|
- **必要決策**(會中斷詢問):無法判斷目前是哪個助理、指定的助理 CLI 不存在、本機 clone 有未提交變更、安裝失敗且原因需要使用者裁示。
|
||||||
|
- **可處理 `jsc-shared` 自己**:但更新目前正在執行本 skill 的助理時,將 `shared` 放在該助理的最後處理,並在回報中提醒重啟工作階段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 參數
|
||||||
|
|
||||||
|
- `--assistant <清單>`:要操作的助理,**可以多個**,以逗號分隔(`claude,codex,copilot,agy,opencode`),或用 `all` 代表本機找得到的全部。**省略時**依階段 A1 判斷(只有一個就直接用,多個就讓使用者多選)。
|
||||||
|
- `--plugins code,doc,persona,shared`:要處理的 plugin(以逗號分隔,用 repo 短名)。**省略時預設四個全做**。
|
||||||
|
- `--host <gitea 主機>`:gitea 主機,省略時預設 `gitea.jsc.idv.tw`。
|
||||||
|
- `--clone-dir <目錄>`:Antigravity/其他會用到本機 clone 的工具的根目錄。`agy` 預設用 `~/.gemini/plugins`,`opencode` 預設用 `~/plugins`;若兩者同時選中而且要共用同一個 clone root,必須由使用者明確指定 `--clone-dir`。
|
||||||
|
- `--yes`:全自動,不做確認式詢問(必要決策仍會中斷)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## plugin 對照表(安裝識別的唯一依據)
|
||||||
|
|
||||||
|
| repo 短名 | plugin 名 | marketplace 名 | 安裝 token | repo 網址 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `code` | `jsc-code` | `code` | `jsc-code@code` | `https://<host>/plugins/code.git` |
|
||||||
|
| `doc` | `jsc-doc` | `doc` | `jsc-doc@doc` | `https://<host>/plugins/doc.git` |
|
||||||
|
| `persona` | `jsc-persona` | `persona` | `jsc-persona@persona` | `https://<host>/plugins/persona.git` |
|
||||||
|
| `shared` | `jsc-shared` | `shared` | `jsc-shared@shared` | `https://<host>/plugins/shared.git` |
|
||||||
|
|
||||||
|
> 指令一律照這張表帶,不要用 repo 短名去猜;若 CLI 回報 marketplace 宣告名稱與表格不一致,先記錄差異,再用 CLI 實際接受的名稱完成同一個 plugin。
|
||||||
|
|
||||||
|
各 plugin 帶入的 skill 目錄(OpenCode 路徑會用到):
|
||||||
|
|
||||||
|
| repo 短名 | skill 目錄 |
|
||||||
|
| --- | --- |
|
||||||
|
| `code` | `action-composite`、`action-docker`、`action-node`、`image`、`issues`、`nuget`、`review-resolve`、`sync`、`target` |
|
||||||
|
| `doc` | `docker`、`funcs`、`issues-analyze`、`issues-analyze-to-file`、`issues-sync`、`worklog` |
|
||||||
|
| `persona` | `persona-anime`、`persona-chat`、`persona-create`、`persona-icon`、`persona-invite`、`persona-memory`、`persona-relation`、`persona-sleep`、`persona-status`、`persona-sync`、`persona-therapist`、`persona-transfer` |
|
||||||
|
| `shared` | `plugins-install`、`plugins-uninstall`、`spec-action-params`、`spec-doc-funcs-handoff`、`spec-dockerfile`、`spec-execution`、`spec-git-safety`、`spec-gitea`、`spec-output`、`spec-plugin-version`、`spec-project-board`、`spec-time-log` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 階段 A:前置設定
|
||||||
|
|
||||||
|
### A1. 決定助理(可以一次多個)
|
||||||
|
|
||||||
|
`--assistant` 收的是**清單**,不是單一值:`--assistant claude,codex,copilot`、或 `--assistant all`。
|
||||||
|
最終得到的是一組助理,後面每個階段都對**這組的每一個**各跑一遍。
|
||||||
|
|
||||||
|
依序判斷,**第一個成立的就採用**:
|
||||||
|
|
||||||
|
1. 有帶 `--assistant` → 照它。`all` 代表「本機找得到的全部」(等同下面第 3 點的偵測結果)。
|
||||||
|
2. 沒帶 → 逐一檢查哪些 CLI 存在(`command -v claude codex copilot agy opencode`):
|
||||||
|
- 找到 **1 個** → 直接用它。
|
||||||
|
- 找到 **多個** → 列出來讓使用者**多選**(預設全選)。帶 `--yes` 時不問,直接全做。
|
||||||
|
3. 一個都沒有 → 回報「找不到任何支援的助理 CLI」並停止。
|
||||||
|
|
||||||
|
> 目前正在執行本 skill 的那個助理,如果也在清單裡,**放到最後處理**;該助理內若包含 `shared`,再把 `shared` 放在該助理的最後一個 plugin 處理——更新它自己會需要重啟工作階段。
|
||||||
|
|
||||||
|
### A2. 決定 gitea 主機與 clone 根目錄
|
||||||
|
|
||||||
|
- 主機:`--host` → `$GITEA_HOST` → 預設 `gitea.jsc.idv.tw`。
|
||||||
|
- clone 根目錄(只有 `agy`/`opencode` 用得到):`--clone-dir` → `agy` 預設 `~/.gemini/plugins`、`opencode` 預設 `~/plugins`。若兩者同時選中且未指定 `--clone-dir`,先判斷是否要拆成兩個根目錄;不要默認共用同一份開發工作區。目錄不存在就建立。
|
||||||
|
|
||||||
|
### A3. 決定 plugin 清單
|
||||||
|
|
||||||
|
`--plugins` 指定則照它,否則 `code,doc,persona,shared` 四個都做。清單中出現對照表以外的名稱 → 回報並略過該項,其餘照做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 階段 B:現況盤點
|
||||||
|
|
||||||
|
對清單中每個 plugin,先查現況再決定動作(**先查再做,不要盲目重裝**):
|
||||||
|
|
||||||
|
| 助理 | 查詢指令 | 判定 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Claude Code | `claude plugin marketplace list`、`claude plugin list` | 兩者都有 → 更新;缺 marketplace → 先 add;缺 plugin → install |
|
||||||
|
| Codex | `codex plugin marketplace list`、`codex plugin list` | 同上 |
|
||||||
|
| GitHub Copilot CLI | `copilot plugin marketplace list`、`copilot plugin list` | 同上 |
|
||||||
|
| Antigravity | `agy plugin list`,並看 `~/.gemini/plugins/<repo>` 是否存在(除非使用者明確指定 `--clone-dir`) | 目錄在且已安裝 → 更新;否則安裝 |
|
||||||
|
| 無 plugin 匯入指令但可使用 skill 的助理 | 先依工具設定或 README.md 找出**所有**候選匯入位置,再看哪個底下已有該 plugin 的 skill 目錄 | 有 → **更新該位置**(重新複製;注意不是覆蓋,見階段 C);都沒有 → 安裝到工具預設資料夾 |
|
||||||
|
|
||||||
|
盤點的迴圈是**助理 × plugin**:階段 A1 選定的每個助理,都要對每個 plugin 各判定一次,結果分開記。
|
||||||
|
|
||||||
|
指令不存在或子指令不被支援(舊版 CLI)時,**不要中斷整批**:記下那一格為「跳過(CLI 不支援)」,繼續下一個,最後在階段 D 一起回報。同一個助理連續失敗(例如 CLI 存在但每個子指令都不支援)就整個助理標記為跳過,換下一個助理,不要卡住整批。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 階段 C:安裝/更新
|
||||||
|
|
||||||
|
以下 `<url>`、`<plugin>`、`<marketplace>`、`<token>` 一律取自對照表。
|
||||||
|
|
||||||
|
### Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安裝(marketplace 尚未加入)
|
||||||
|
claude plugin marketplace add <url>
|
||||||
|
claude plugin install <token>
|
||||||
|
|
||||||
|
# 更新(已安裝)
|
||||||
|
claude plugin marketplace update <marketplace>
|
||||||
|
claude plugin update <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安裝
|
||||||
|
codex plugin marketplace add <url>
|
||||||
|
codex plugin add <token>
|
||||||
|
|
||||||
|
# 更新(重新抓取 marketplace 的 git 快照)
|
||||||
|
codex plugin marketplace upgrade <marketplace>
|
||||||
|
```
|
||||||
|
|
||||||
|
### GitHub Copilot CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安裝
|
||||||
|
copilot plugin marketplace add <url>
|
||||||
|
copilot plugin install <token>
|
||||||
|
|
||||||
|
# 更新
|
||||||
|
copilot plugin marketplace update <marketplace>
|
||||||
|
copilot plugin update <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Antigravity(`agy`)
|
||||||
|
|
||||||
|
> `agy plugin install <url>` 目前只支援 github.com;gitea 一律走「clone + 本地路徑」。`agy` 沒有 update 子指令,更新=`git pull` 後重裝。**預設 clone root 在 `~/.gemini/plugins`,不要偷用目前工作目錄或 `~/plugins` 的開發工作區。**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 取得或更新本機 clone(只看 clone root 內的 repo;新 clone 用 master,已存在就把 master 拉到最新)
|
||||||
|
if [ -d "<clone-dir>/<repo>/.git" ]; then
|
||||||
|
git -C <clone-dir>/<repo> switch master
|
||||||
|
git -C <clone-dir>/<repo> pull --ff-only origin master
|
||||||
|
else
|
||||||
|
git clone --branch master --single-branch <url> <clone-dir>/<repo>
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 安裝
|
||||||
|
agy plugin install <clone-dir>/<repo>
|
||||||
|
|
||||||
|
# 更新(agy 沒有 update 子指令,只能重裝)
|
||||||
|
agy plugin uninstall <plugin>
|
||||||
|
agy plugin install <clone-dir>/<repo>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **不可無條件 `git clone`**:clone 目錄已經存在(很常見——開發者自己就 clone 在那裡)時,`git clone` 會以 `fatal: destination path already exists` 中止。階段 B 的判定只看「有沒有裝進 agy」,所以「目錄在、但 agy 沒裝」這個狀態會落進安裝分支,必須靠上面的 `if` 擋掉。
|
||||||
|
- `git -C <clone-dir>/<repo> switch master` 或 `git pull --ff-only origin master` 失敗(本機有未提交變更、分支分岔,或本機 repo 無法切到 master)→ **停在該 plugin**,回報現況讓使用者裁示,不得 `reset --hard`/`clean`,其餘 plugin 照常繼續。
|
||||||
|
- **先確認 clone 在哪個分支**:`git -C <clone-dir>/<repo> branch --show-current`。這裡檢查的是 `clone root` 裡的 repo,不是目前工作目錄的任何專案。`agy` 的本機 clone 應以 `master` 為準;若不是,先切回 `master`,再把 `master` 拉到最新。
|
||||||
|
|
||||||
|
### 無 plugin 匯入指令但可使用 skill 的助理
|
||||||
|
|
||||||
|
> 這類助理沒有可用的 plugin 匯入指令,但可以使用 skill,因此改用**目錄安裝**。先把技能組 clone 到工具專屬資料夾,再依技能組 `README.md` 的匯入說明,把技能放到指定位置。
|
||||||
|
> 這類助理**沒有 plugin CLI 可查安裝清單**,所以不能像四家原生 CLI 那樣「問 CLI 裝了沒」,也**不可預設就往全域目錄倒**——先讀設定或 README.md 找出它實際掛在哪,再決定要更新誰。
|
||||||
|
|
||||||
|
#### C-1. 先判定安裝位置(讀設定,不要臆測)
|
||||||
|
|
||||||
|
候選位置由近到遠如下,**只有實際存在於磁碟的才納入候選**:
|
||||||
|
|
||||||
|
| 順位 | 候選 skills 目錄 | 判定依據 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `<專案根>/.opencode/skills/` | 從目前工作目錄往上找到第一個含 `opencode.json`/`opencode.jsonc`/`.opencode/` 的目錄,即為專案根 |
|
||||||
|
| 2 | `${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/` | 工具全域資料夾(**未安裝時的預設安裝目標**) |
|
||||||
|
| 3 | `$HOME/.claude/skills/`、`$HOME/.agents/skills/` | OpenCode 也會讀的相容來源 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 全域設定目錄(工具資料夾)
|
||||||
|
OC_HOME="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
|
||||||
|
|
||||||
|
# 從目前工作目錄往上找專案根
|
||||||
|
proj=""; d="$PWD"
|
||||||
|
while [ "$d" != "/" ]; do
|
||||||
|
if [ -f "$d/opencode.json" ] || [ -f "$d/opencode.jsonc" ] || [ -d "$d/.opencode" ]; then proj="$d"; break; fi
|
||||||
|
d="$(dirname "$d")"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 列出實際存在的候選 skills 目錄
|
||||||
|
for c in ${proj:+"$proj/.opencode/skills"} "$OC_HOME/skills" "$HOME/.claude/skills" "$HOME/.agents/skills"; do
|
||||||
|
[ -d "$c" ] && echo "$c"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
判定「這個 plugin 有沒有裝在某個候選位置」,用**對照表列出的 skill 目錄名**去看:候選底下只要出現該 plugin 的任一個 skill 目錄,就算已安裝在那裡。
|
||||||
|
|
||||||
|
- 設定檔存在但**內容看不懂或解析失敗** → 不猜。記為「需人工確認」,非 `--yes` 時先問使用者要更新哪個位置。
|
||||||
|
- 若設定把 skills 指到上表以外的自訂路徑,**以設定為準**,不要改回預設目錄。
|
||||||
|
|
||||||
|
#### C-2. 已安裝 → 到該位置就地更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 取得或更新本機 clone(同 Antigravity,不要無條件 clone)
|
||||||
|
if [ -d "<clone-dir>/<repo>/.git" ]; then
|
||||||
|
git -C <clone-dir>/<repo> pull --ff-only
|
||||||
|
else
|
||||||
|
git clone <url> <clone-dir>/<repo>
|
||||||
|
fi
|
||||||
|
|
||||||
|
# <target> = C-1 / README.md 判定出「已經有這個 plugin」的那個目錄(可能是某個專案下的 skills 目錄)
|
||||||
|
cp -r <clone-dir>/<repo>/skills/* "<target>/"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **就地更新,不要另外補一份到全域目錄**:設定或 README.md 指到專案路徑就更新專案路徑;多倒一份到其他位置會造成同名 skill 兩份、之後每次更新都要記得更兩邊。
|
||||||
|
- **多個候選位置都已安裝** → 全部更新,並在階段 D **逐列列出各自的位置**,同時提醒使用者這個 plugin 被重複安裝了,建議留一份。
|
||||||
|
- **目標在某個 git 專案內** → 複製後會產生未提交變更。依 `/jsc-shared:spec-git-safety`:只回報「該專案有新增/異動檔案待處理」,**不代為 commit、不動既有變更**。
|
||||||
|
|
||||||
|
#### C-3. 未安裝 → 放進工具的資料夾
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills"
|
||||||
|
cp -r <clone-dir>/<repo>/skills/* "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/"
|
||||||
|
```
|
||||||
|
|
||||||
|
四個候選位置都沒有這個 plugin 時,才裝進工具的全域資料夾;**不要因為當下剛好在某個專案目錄,就自作主張裝進那個專案**。
|
||||||
|
|
||||||
|
> **Windows PowerShell**:`cp -r A B` → `Copy-Item A B -Recurse -Force`、`~` → `$HOME`、`${XDG_CONFIG_HOME:-$HOME/.config}` → `$env:XDG_CONFIG_HOME` 沒設就用 `$HOME\.config`。
|
||||||
|
|
||||||
|
- **`cp -r` 不是覆蓋,是合併**:同名檔案會更新,但**上游已經刪掉的檔案會原地留著**。所以 skill 改名或移除之後,OpenCode 端會同時留著新舊兩份。要乾淨更新就先刪該 plugin 帶入的目錄再複製一次(刪法見 `/jsc-shared:plugins-uninstall` 的 OpenCode 段)。回報時不要講「已覆蓋」,講「已複製,舊檔可能殘留」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 階段 D:回報
|
||||||
|
|
||||||
|
以表格回報,**一個「助理 × plugin」一列**:
|
||||||
|
|
||||||
|
| 助理 | plugin | 動作 | 位置 | 結果 | 版本 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Claude Code | `jsc-code` | 安裝/更新/跳過 | CLI 自管快取 | ✅ 成功/⚠ 需處理/❌ 失敗 | 例 `0.0.2` |
|
||||||
|
| Codex | `jsc-code` | 更新 | CLI 自管快取 | ✅ 成功 | 未知 |
|
||||||
|
| OpenCode | `jsc-code` | 更新 | `~/work/app/.opencode/skills` | ✅ 成功 | `0.0.2` |
|
||||||
|
|
||||||
|
只處理一個助理時可以省掉「助理」欄。**處理多個時一定要有**,否則使用者看不出哪一格出問題。
|
||||||
|
|
||||||
|
- 「位置」欄對**非指令助理必填**(寫出實際複製到的絕對路徑),四家原生 CLI 寫「CLI 自管快取」即可。使用者要知道這次更新的是專案那份還是全域那份。
|
||||||
|
|
||||||
|
- 版本取自該 plugin 的 `plugin.json`。**只有 Antigravity 與 OpenCode 拿得到**(它們有本機 clone 可讀);Claude/Codex/Copilot 把 plugin 放在各自 CLI 自管的快取目錄,除非該 CLI 的 `plugin list` 印得出版本,否則一律寫「未知」,不要去猜。
|
||||||
|
- 有任何一列不是 ✅ → 在表格下方逐項說明原因與建議動作。
|
||||||
|
- 最後固定提醒:**安裝或更新後要重啟工作階段**才會生效;`jsc-persona` 在 Claude Code 還要用 `/hooks` 確認六個 hook 都在。
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
---
|
||||||
|
name: plugins-uninstall
|
||||||
|
description: 一次把 JSC 的四個 plugin(jsc-code/jsc-doc/jsc-persona/jsc-shared)從一個或多個 AI 助理移除,可同時處理 Claude Code、Codex、GitHub Copilot CLI、Antigravity 四家原生 plugin CLI 與沒有 plugin 匯入指令、但可使用 skill 的助理;沒指定時偵測本機裝了哪些 CLI 並讓使用者多選;移除順序固定把 jsc-shared 放到最後(本 skill 就住在裡面,移除後即失效),並在動手前列出將被移除的項目與會受影響的本機資料讓使用者確認。當使用者說要移除所有 jsc plugin、把 skill 套件整組解除安裝、清掉 code/doc/persona/shared、重灌前先卸載、要同時從好幾個 CLI 移除、或問怎麼一次移除全部 plugin 時觸發。不適用於:安裝或更新(用 /jsc-shared:plugins-install)、只移除單一 plugin(直接照該 plugin README 的移除章節)、刪除人格資料或 Gitea 上的存取庫。
|
||||||
|
argument-hint: "[--assistant <助理清單,逗號分隔,或 all>] [--plugins code,doc,persona,shared] [--keep-marketplace] [--keep-clone] [--yes]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# plugins-uninstall — 一次移除所有 JSC plugin
|
||||||
|
|
||||||
|
四階段 skill:先**決定助理與目標清單**,再**列出將被移除的項目並確認**,接著**依固定順序移除**,最後**回報結果與殘留物**。
|
||||||
|
|
||||||
|
| 階段 | 動作 |
|
||||||
|
| --- | --- |
|
||||||
|
| A. 前置設定 | 決定要操作哪些助理(`--assistant` 可帶多個或 all/偵測本機有哪些 CLI/多個就讓使用者多選)→ 決定 plugin 清單(預設四個全移) |
|
||||||
|
| B. 盤點與確認 | 列出實際已安裝的項目、會一併移除的 marketplace 與本機 clone、以及**不會**被碰的資料,請使用者確認 |
|
||||||
|
| C. 移除 | 依 `code` → `doc` → `persona` → `shared` 的順序逐一移除 |
|
||||||
|
| D. 回報 | 表格回報每個 plugin 的結果,並列出刻意保留的殘留物 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 共用規範(shared plugin,必要前置)
|
||||||
|
|
||||||
|
執行本 skill 前,先以 Skill 工具載入下列共用規範並全程遵守:
|
||||||
|
|
||||||
|
- `/jsc-shared:spec-output`:繁體中文(台灣用語)、UTF-8(不含 BOM)無亂碼、表格呈現。
|
||||||
|
- `/jsc-shared:spec-execution`:自動執行原則(必要決策才中斷)、不臆測/需人工確認。
|
||||||
|
- `/jsc-shared:spec-git-safety`:不破壞既有工作——本機 clone 只在使用者明確同意時刪除,且**有未提交變更就一律保留**。
|
||||||
|
|
||||||
|
本 skill 特有補充:
|
||||||
|
|
||||||
|
- **移除是不可逆的動作**:階段 B 的確認是**必要決策**,除非帶 `--yes`,否則一定要問過才動手。
|
||||||
|
- **絕不刪除使用者資料**:`~/.claude/personas/`(人格倉庫)、`~/.roles/`、`~/.memory/`(角色與記憶)一律不動,Gitea 上的存取庫也不動。要清這些請使用者自己來。
|
||||||
|
- **順序不可調換**:`jsc-shared` 一定最後移除。它是本 skill 的所在地,移除後本 skill 隨之失效,後面的步驟會執行不到。
|
||||||
|
- **多個助理時的順序**:外層先跑完一個助理的四個 plugin,再換下一個助理。**目前正在執行本 skill 的那個助理排到最後**——不然它自己的 `jsc-shared` 一沒,剩下的助理就處理不到了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 參數
|
||||||
|
|
||||||
|
- `--assistant <清單>`:要操作的助理,**可以多個**,以逗號分隔(`claude,codex,copilot,agy,opencode`),或用 `all` 代表本機找得到的全部。**省略時**依階段 A1 判斷(只有一個就直接用,多個就讓使用者多選,且不預設全選)。
|
||||||
|
- `--plugins code,doc,persona,shared`:要移除的 plugin(以逗號分隔,用 repo 短名)。**省略時預設四個全移**。
|
||||||
|
- `--keep-marketplace`:只移除 plugin,保留 marketplace 登錄(之後要重裝比較快)。
|
||||||
|
- `--keep-clone`:保留 Antigravity/OpenCode 用的本機 clone 目錄(**預設就是保留**,此旗標僅用於明示)。要刪除本機 clone 必須由使用者在階段 B 明確同意。
|
||||||
|
- `--yes`:跳過階段 B 的確認(其他必要決策仍會中斷)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## plugin 對照表(移除識別的唯一依據)
|
||||||
|
|
||||||
|
| repo 短名 | plugin 名 | marketplace 名 | 移除 token |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `code` | `jsc-code` | `code` | `jsc-code@code` |
|
||||||
|
| `doc` | `jsc-doc` | `doc` | `jsc-doc@doc` |
|
||||||
|
| `persona` | `jsc-persona` | `jsc-plugins` | `jsc-persona@jsc-plugins` |
|
||||||
|
| `shared` | `jsc-shared` | `shared` | `jsc-shared@shared` |
|
||||||
|
|
||||||
|
> **`persona` 的 marketplace 名不是 repo 名**(是 `jsc-plugins`)。移除 marketplace 時特別注意別誤刪別的登錄。
|
||||||
|
|
||||||
|
各 plugin 帶入的 skill 目錄(OpenCode 路徑會用到):
|
||||||
|
|
||||||
|
| repo 短名 | skill 目錄 |
|
||||||
|
| --- | --- |
|
||||||
|
| `code` | `action-composite`、`action-docker`、`action-node`、`image`、`issues`、`nuget`、`review-resolve`、`sync`、`target` |
|
||||||
|
| `doc` | `docker`、`funcs`、`issues-analyze`、`issues-analyze-to-file`、`issues-sync`、`worklog` |
|
||||||
|
| `persona` | `persona-anime`、`persona-chat`、`persona-create`、`persona-icon`、`persona-invite`、`persona-memory`、`persona-relation`、`persona-sleep`、`persona-status`、`persona-sync`、`persona-therapist`、`persona-transfer` |
|
||||||
|
| `shared` | `plugins-install`、`plugins-uninstall`、`spec-action-params`、`spec-doc-funcs-handoff`、`spec-dockerfile`、`spec-execution`、`spec-gitea`、`spec-git-safety`、`spec-output`、`spec-plugin-version`、`spec-project-board`、`spec-time-log` |
|
||||||
|
|
||||||
|
> 上表是**寫下來當天的快照**,plugin 之後新增 skill 它不會自己更新。所以 OpenCode 的刪除**優先從本機 clone 的 `skills/` 推導清單**(見階段 C 的 OpenCode 段),clone 不在時才退回這張表,並在回報裡註明「清單可能不完整」。
|
||||||
|
> 兩種做法都**不可用萬用字元一次掃掉整個 `skills/`**——那裡可能還有別處裝進去的 skill。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 階段 A:前置設定
|
||||||
|
|
||||||
|
### A1. 決定助理(可以一次多個)
|
||||||
|
|
||||||
|
`--assistant` 收的是**清單**,不是單一值:`--assistant claude,codex,copilot`、或 `--assistant all`。
|
||||||
|
最終得到的是一組助理,階段 B 到 D 都對**這組的每一個**各跑一遍。
|
||||||
|
|
||||||
|
依序判斷,**第一個成立的就採用**:
|
||||||
|
|
||||||
|
1. 有帶 `--assistant` → 照它。`all` 代表「本機找得到的全部」(等同下面第 2 點的偵測結果)。
|
||||||
|
2. 沒帶 → 逐一檢查哪些 CLI 存在(`command -v claude codex copilot agy opencode`):
|
||||||
|
- 找到 **1 個** → 直接用它。
|
||||||
|
- 找到 **多個** → 列出來讓使用者**多選**(移除是不可逆的,**預設不全選**,要他自己勾)。
|
||||||
|
3. 一個都沒有 → 回報「找不到任何支援的助理 CLI」並停止。
|
||||||
|
|
||||||
|
> 目前正在執行本 skill 的那個助理,如果也在清單裡,**放到最後處理**——移掉它自己之後,本 skill 就不存在了。
|
||||||
|
|
||||||
|
### A2. 決定 plugin 清單
|
||||||
|
|
||||||
|
`--plugins` 指定則照它,否則四個全做。無論使用者怎麼排,**實際執行順序一律重排為 `code` → `doc` → `persona` → `shared`**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 階段 B:盤點與確認
|
||||||
|
|
||||||
|
先查出實際狀態(`claude plugin list`/`codex plugin list`/`copilot plugin list`/`agy plugin list`,或 OpenCode 的 skills 目錄),再列出三張清單給使用者看:
|
||||||
|
|
||||||
|
1. **會被移除**:已安裝的 plugin、以及(未帶 `--keep-marketplace` 時)對應的 marketplace 登錄。
|
||||||
|
2. **本機 clone**:`agy`/`opencode` 用的 clone 目錄路徑與是否有未提交變更;**預設保留**,要刪請使用者明確說。
|
||||||
|
3. **不會被碰**:`~/.claude/personas/`(人格:身分、情緒、記憶、關係圖)、`~/.roles/`、`~/.memory/`、Gitea 上的所有存取庫。
|
||||||
|
|
||||||
|
清單裡沒有任何已安裝項目 → 直接回報「沒有可移除的項目」並結束。
|
||||||
|
|
||||||
|
未帶 `--yes` 時,**在這裡停下來等使用者確認**再進入階段 C。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 階段 C:移除
|
||||||
|
|
||||||
|
以下 `<plugin>`、`<marketplace>`、`<token>` 一律取自對照表;帶 `--keep-marketplace` 時略過 `marketplace remove` 那行。
|
||||||
|
|
||||||
|
### Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude plugin uninstall <token>
|
||||||
|
claude plugin marketplace remove <marketplace>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 移除後檢查 `~/.claude/settings.json` 的 `enabledPlugins`,**清掉該 plugin 的殘留鍵**(留著會讓下次安裝的狀態對不上)。
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codex plugin remove <token>
|
||||||
|
codex plugin marketplace remove <marketplace>
|
||||||
|
```
|
||||||
|
|
||||||
|
### GitHub Copilot CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copilot plugin uninstall <token>
|
||||||
|
copilot plugin marketplace remove <marketplace>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Antigravity(`agy`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agy plugin uninstall <plugin>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 本機 clone(`~/plugins/<repo>` 之類)**預設保留**;使用者在階段 B 明確同意才刪,且該目錄有未提交變更時一律保留並回報。
|
||||||
|
|
||||||
|
### 無 plugin 匯入指令但可使用 skill 的助理
|
||||||
|
|
||||||
|
逐一刪除該 plugin 帶入的 skill 目錄。**最可靠的做法是從本機 clone 與 README.md 推導清單**,而不是照抄上表——上表是快照,plugin 新增 skill 之後就會漏:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# clone 還在:從來源目錄與 README.md 推導要刪哪些(唯一不會漏的做法)
|
||||||
|
for s in <clone-dir>/<repo>/skills/*/; do
|
||||||
|
rm -rf "$HOME/.config/opencode/skills/$(basename "$s")"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
clone 已經不在時才退回上表,且**一個一行分開刪**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf ~/.config/opencode/skills/docker
|
||||||
|
rm -rf ~/.config/opencode/skills/funcs
|
||||||
|
# …照上表逐行
|
||||||
|
```
|
||||||
|
|
||||||
|
> **不要用 `{a,b,c}` 這種 brace expansion**。它有三種會靜默失效的情況:逗號後有空格(`{a, b}`)不展開、只有一個元素(`{a}`)不展開、以及在 `dash`/`sh` 底下完全不支援。三種都是「什麼都沒刪,但 `-f` 讓結束碼還是 0」,回報會變成假的 ✅。
|
||||||
|
|
||||||
|
> **Windows PowerShell**:`rm -rf X` → `Remove-Item X -Recurse -Force`、`~` → `$HOME`。
|
||||||
|
|
||||||
|
### `jsc-shared` 的收尾
|
||||||
|
|
||||||
|
移除 `jsc-shared` 是**最後一步**。動手前先在輸出裡寫清楚:
|
||||||
|
|
||||||
|
- 本 skill 與所有 `spec-*` 共用規範會一起消失;`jsc-code`/`jsc-doc` 的 skill 內文都會引用 `spec-*`,若它們還留著,之後執行會載入不到共用規範。
|
||||||
|
- 要重新安裝,得直接照 shared 的 README(那時已經沒有 `/jsc-shared:plugins-install` 可用了):
|
||||||
|
`claude plugin marketplace add https://gitea.jsc.idv.tw/plugins/shared.git` → `claude plugin install jsc-shared@shared`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 階段 D:回報
|
||||||
|
|
||||||
|
以表格回報,**一個「助理 × plugin」一列**:
|
||||||
|
|
||||||
|
| 助理 | plugin | 動作 | 結果 | 備註 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Claude Code | `jsc-code` | 移除/跳過(未安裝) | ✅ 成功/⚠ 需處理/❌ 失敗 | 例:marketplace 已保留 |
|
||||||
|
| Codex | `jsc-code` | 跳過(未安裝) | ✅ | — |
|
||||||
|
|
||||||
|
只處理一個助理時可以省掉「助理」欄。**處理多個時一定要有**,否則使用者看不出哪一格出問題。
|
||||||
|
|
||||||
|
表格下方固定列出:
|
||||||
|
|
||||||
|
- **刻意保留的殘留物**:本機 clone 路徑、marketplace 登錄(若帶了 `--keep-marketplace`)、人格倉庫與記憶目錄。
|
||||||
|
- **重啟提醒**:移除後要重啟工作階段,指令與 skill 才會真正消失。
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
---
|
|
||||||
name: role
|
|
||||||
description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI 工具以固定角色(name/nature/vibe/簽名 emoji)回覆,並把每輪對話累積成長期記憶:搭配相容的 SessionStart hook 於啟動時載入角色與記憶、Stop hook 記錄對話,睡眠時段(預設 22:00 至隔天 06:00)由排程整理記憶(分類重要/興趣/新知/技能/日常/其他、去重、設標籤與一句話總結、壓縮歸檔,日常與其他依使用頻率遺忘)。提供 --new(新建或更新角色,更新時逐欄核對新舊)、--use(切換啟用角色)、--list、--sleep(立即整理)、--status/--diagnose、--install-cron/--remove-cron、--forget-preview 等模式。當使用者說建立角色、新增人格、切換角色、讓回覆更有特色、角色記憶、記憶整理、睡覺整理記憶、忘記舊記憶、角色沒有載入、hook 沒載入角色,或提到 .roles/.memory/ROLE_NAME/ROLE_ENABLED/ROLE_SLEEP_START/ROLE_MEMORY_HOME 時觸發。不適用於:工作紀錄寫入 Gitea wiki(用 doc plugin 的 worklog)、專案文件化(用 doc-funcs)。
|
|
||||||
---
|
|
||||||
|
|
||||||
# role — 角色人格與長期記憶
|
|
||||||
|
|
||||||
讓 CLI 工具的回覆帶固定人格,並把與使用者的對話累積成可被下次載入的長期記憶。
|
|
||||||
**載入與記錄由 hook 自動完成、不需人工觸發**;本 skill 負責自動路徑之外的人工操作:建立/更新角色、切換角色、手動整理、排程安裝與診斷。
|
|
||||||
|
|
||||||
| 元件 | 觸發者 | 職責 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `hooks/hooks.json` 的 `SessionStart` hook | harness 自動 | 啟動 CLI 時載入角色定義+記憶;睡眠時段只回報「角色睡覺中」不載入 |
|
|
||||||
| `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束抽本輪對話 → 濃縮成一則記憶 → 遮蔽 → 寫入 `inbox/` |
|
|
||||||
| cron 排程(本 skill 安裝) | 系統排程 | 睡眠時段每小時檢查一次:**有 AI 在運行就不睡**,沒有才進入睡眠整理記憶 |
|
|
||||||
| 本 skill `/jsc:role` | 使用者/助理手動 | `--new`/`--use`/`--list`/`--sleep`/`--status`/`--install-cron`/`--forget-preview` |
|
|
||||||
| `scripts/role/role_load.sh` | SessionStart hook | 角色與記憶載入(單一實作,避免漂移) |
|
|
||||||
| `scripts/role/role_capture.sh` | Stop hook | 對話 → 記憶(四欄固定格式) |
|
|
||||||
| `scripts/role/role_sleep.sh` | cron/補跑/手動 | 睡眠判斷、記憶整理、排程安裝、狀態輸出 |
|
|
||||||
| `scripts/role/memory.py` | 上述共用 | 記憶檔讀寫、分類、去重合併、壓縮歸檔、遺忘、載入組裝 |
|
|
||||||
| `scripts/role/transcript.py` | 上述共用 | 抽本輪對話片段、機密與個資遮蔽 |
|
|
||||||
| `scripts/role/role_lib.sh` | 上述共用 | log、角色解析、睡眠時段、AI 行程偵測、CLI 選擇、記憶鎖 |
|
|
||||||
|
|
||||||
### 各助理支援範圍
|
|
||||||
|
|
||||||
| 功能 | Claude Code | Codex | Antigravity | OpenCode | GitHub Copilot |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| `SessionStart` 載入角色 | ✅ | ⚠️ 需該版本支援 SessionStart hook | ❌ | ❌ | ❌ |
|
|
||||||
| `Stop` 記錄記憶 | ✅ | ✅ 需可讀 Codex session JSONL | ❌ | ❌ | ❌ |
|
|
||||||
| cron 睡眠整理 | ✅ 與助理無關(系統排程) | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| `--new`/`--use`/`--sleep` 等模式 | ✅ | ⚠️ 需 plugin 目錄保留 `scripts/` | ⚠️ 同左 | ❌ 只複製 `skills/`,無腳本 | ⚠️ 同左 |
|
|
||||||
| 濃縮/整理 CLI | `claude -p` | `codex exec` | `agy -p` | `opencode run` | `copilot -p` |
|
|
||||||
|
|
||||||
- **`hooks/hooks.json` 只有 Claude Code 一定會讀**;Codex 會從 `~/.codex/plugins/cache/generic/jsc` 找腳本。其他助理若提供等效 hook,`transcript.py` 需補對應解析器。
|
|
||||||
- 不支援 hook 的助理仍可用:cron 排程與手動模式照常運作,只是角色不會自動載入。
|
|
||||||
|
|
||||||
### 腳本路徑解析(重要)
|
|
||||||
|
|
||||||
skill 執行時的工作目錄是**使用者的專案目錄**,不是 plugin 根目錄,因此**絕不可用相對路徑呼叫腳本**:
|
|
||||||
|
|
||||||
| 環境 | plugin 根目錄 |
|
|
||||||
| --- | --- |
|
|
||||||
| Claude Code | `${CLAUDE_PLUGIN_ROOT}` |
|
|
||||||
| 其他助理 | 本 skill 載入時提示的 base directory(`.../skills/role`)往上兩層 |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ROLE_DIR="${CLAUDE_PLUGIN_ROOT}/scripts/role" # Claude Code
|
|
||||||
ROLE_DIR="<skill base directory>/../../scripts/role" # 其他助理
|
|
||||||
```
|
|
||||||
|
|
||||||
以下各模式一律以 `${ROLE_DIR}` 表示該目錄。解析不到或該目錄不存在時,回報「plugin 目錄未包含 scripts/role,本 skill 在此環境不可用」並停止,不要改用相對路徑重試。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 共用規範(必要前置)
|
|
||||||
|
|
||||||
執行本 skill 前,先以 Skill 工具載入下列共用規範並全程遵守;**任一載入不到時先詢問使用者是否安裝 generic plugin(`https://gitea.jsc.idv.tw/plugins/generic.git`),不安裝則中斷**:
|
|
||||||
|
|
||||||
- `/jsc:spec-output`:繁體中文(台灣用語)、UTF-8 無 BOM、表格與 Mermaid 優先。
|
|
||||||
- `/jsc:spec-execution`:自動執行原則(必要決策才中斷)、不臆測。
|
|
||||||
- `/jsc:spec-time-log`:時間戳固定 Asia/Taipei `yyyy/MM/dd HH:mm:ss`;訊息格式 `[時間][階段][等級]: 訊息`、一行一則。
|
|
||||||
|
|
||||||
本 skill 特有補充:
|
|
||||||
|
|
||||||
- **覆寫角色前一定要核對**:`--new` 遇到同名角色時,必須先逐欄列出新舊差異並取得使用者確認才寫入。這是本 skill 明定「一定會中斷詢問」的點,**不得被 `--yes` 略過**。
|
|
||||||
- **不臆測角色設定**:`nature`/`vibe`/`emoji` 一律問使用者,不得代填。
|
|
||||||
- **記憶只增不刪**:手動模式不得直接刪除分類記憶;淘汰一律走遺忘規則(先壓縮歸檔再移除)。
|
|
||||||
- **絕不阻斷**:hook 路徑任何失敗都以 exit 0 結束,只在 stderr 留訊息。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 環境變數
|
|
||||||
|
|
||||||
| 變數 | 必要 | 說明 | 未設定 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `ROLE_ENABLED` | | 總開關:`1` 強制啟用、`0` 強制停用 | **未設定時,只要有可解析且存在的角色就啟用**(沒建過角色的人零影響) |
|
|
||||||
| `ROLE_NAME` | | 指定本次要載入的角色 | 讀 `~/.roles/.active` |
|
|
||||||
| `ROLE_HOME` | | 角色定義目錄 | `~/.roles` |
|
|
||||||
| `ROLE_MEMORY_HOME` | | 記憶根目錄 | `~/.memory` |
|
|
||||||
| `ROLE_SLEEP_START` | | 睡眠起始 `HH:MM` | `22:00` |
|
|
||||||
| `ROLE_SLEEP_END` | | 睡眠結束 `HH:MM` | `06:00` |
|
|
||||||
| `ROLE_CLI` | | 濃縮/整理執行器:`auto`/`claude`/`codex`/`agy`/`opencode`/`copilot` | `auto`(先判斷目前 hook 環境,再 fallback 到已安裝工具) |
|
|
||||||
| `ROLE_MODEL` | | 強制指定模型(僅 `claude` CLI 使用) | 保底 `claude-haiku-4-5-20251001` |
|
|
||||||
| `ROLE_LOAD_LIMIT` | | 注入記憶的字元上限 | `8000` |
|
|
||||||
| `ROLE_SLEEP_TIMEOUT` | | 單次整理的模型逾時秒數 | `180` |
|
|
||||||
| `ROLE_SCOPE` | | 冒號分隔的路徑前綴,僅這些路徑下的 session 載入/記錄 | 全部 session |
|
|
||||||
| `ROLE_ERRLOG` | | 錯誤訊息額外寫入的檔案路徑 | 只走 stderr |
|
|
||||||
|
|
||||||
> 角色切換用 `/jsc:role --use <名稱>`(寫 `.active`)即可,一般不需要設 `ROLE_NAME`;`ROLE_NAME` 適合「單一專案固定用某角色」時寫進該環境。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 模式
|
|
||||||
|
|
||||||
### `--new`(預設模式)
|
|
||||||
|
|
||||||
建立或更新角色。缺少的資訊**一次問齊**,不得代填:
|
|
||||||
|
|
||||||
| 欄位 | 說明 | 範例 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `name` | 角色名稱,同時是檔名 `~/.roles/<name>.md` 與記憶目錄名。不得含 `/`、`\`、空白與前後點 | `小豹` |
|
|
||||||
| `nature` | 本質:這個角色是什麼、專長與行事準則 | 冷靜可靠的資深工程師,重證據、不打包票 |
|
|
||||||
| `vibe` | 氛圍:語氣、句長、稱呼、幽默感、禁忌 | 簡潔直白、偶爾吐槽,不用客套開場白 |
|
|
||||||
| `emoji` | 簽名 emoji,一到二個 | 🐆 |
|
|
||||||
|
|
||||||
流程:
|
|
||||||
|
|
||||||
1. 解析 `${ROLE_DIR}`;不存在則中止(見「腳本路徑解析」)。
|
|
||||||
2. 以 `AskUserQuestion` 或提問取得四個欄位(使用者已在指令中給的欄位不得重複問)。
|
|
||||||
3. 依「角色檔標準格式」產生新內容,`updated` 用當下時間(Asia/Taipei)。
|
|
||||||
4. **若 `~/.roles/<name>.md` 已存在**:讀舊檔,以表格逐欄列出差異後**停下來等使用者確認**:
|
|
||||||
|
|
||||||
| 欄位 | 舊值 | 新值 | 變更 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| nature | … | … | 是/否 |
|
|
||||||
| vibe | … | … | 是/否 |
|
|
||||||
| emoji | … | … | 是/否 |
|
|
||||||
| 共用行為區塊 | 版本 A | 版本 B | 是/否 |
|
|
||||||
|
|
||||||
個性欄位若使用者只想改其中一項,其餘一律沿用舊值;**共用行為區塊一律以本 skill 的最新版本覆寫**(該區塊由系統維護)。使用者不確認就不寫入。
|
|
||||||
5. 寫入 `~/.roles/<name>.md`(UTF-8 無 BOM)。
|
|
||||||
6. 建立記憶目錄:`python3 "${ROLE_DIR}/memory.py" stats --role "<name>"`(會順帶建好 `inbox/`、六個分類與 `archive/`)。
|
|
||||||
7. 若尚未有啟用角色,或使用者要求,寫入 `~/.roles/.active`(單行角色名)。
|
|
||||||
8. 執行 `ROLE_NAME="<name>" "${ROLE_DIR}/role_sleep.sh" --install-cron` 安裝睡眠排程(已安裝則更新)。
|
|
||||||
9. 回報結果並提醒:**重開 CLI 工作階段**角色才會載入;`SessionStart` hook 只在啟動時觸發。
|
|
||||||
|
|
||||||
### `--use <名稱>`
|
|
||||||
|
|
||||||
切換啟用角色:確認 `~/.roles/<名稱>.md` 存在後,把名稱寫入 `~/.roles/.active`(覆蓋單行),回報舊角色與新角色,並提醒重開工作階段。
|
|
||||||
|
|
||||||
### `--list`
|
|
||||||
|
|
||||||
列出 `~/.roles/*.md`,以表格輸出:角色、emoji、nature 摘要、更新時間、是否為 `.active`、記憶總數(可用 `memory.py stats` 取得)。
|
|
||||||
|
|
||||||
### `--sleep`
|
|
||||||
|
|
||||||
立即執行一次記憶整理(不等排程、忽略時段與 AI 運行檢查):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"${ROLE_DIR}/role_sleep.sh" --force
|
|
||||||
```
|
|
||||||
|
|
||||||
輸出整理結果(新增/合併/捨棄/歸檔筆數與遺忘清單)。
|
|
||||||
|
|
||||||
### `--forget-preview`
|
|
||||||
|
|
||||||
只預覽會被遺忘的記憶、不實際刪除:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 "${ROLE_DIR}/memory.py" forget --role "<name>" --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
### `--status`/`--diagnose`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"${ROLE_DIR}/role_sleep.sh" --status
|
|
||||||
```
|
|
||||||
|
|
||||||
輸出角色、定義檔、睡眠時段、目前是否睡眠中、cron 排程與服務狀態、摘要 CLI、各分類記憶筆數、上次整理與遺忘時間。**角色沒有載入時**再逐項檢查:
|
|
||||||
|
|
||||||
| 檢查項 | 判準 |
|
|
||||||
| --- | --- |
|
|
||||||
| 角色解析 | `ROLE_NAME` 或 `~/.roles/.active` 是否指向存在的定義檔 |
|
|
||||||
| 總開關 | `ROLE_ENABLED` 是否被設成 `0` |
|
|
||||||
| hook 註冊 | plugin 是否已啟用、`hooks/hooks.json` 是否存在(Claude Code 用 `/hooks` 檢視) |
|
|
||||||
| 工作階段 | 建立角色後是否**重開過** CLI(SessionStart 只在啟動時觸發) |
|
|
||||||
| 範圍 | `ROLE_SCOPE` 是否把目前目錄排除 |
|
|
||||||
| 時段 | 目前是否落在睡眠時段(睡眠時本來就不載入角色) |
|
|
||||||
| 依賴 | `python3` 與 `ROLE_CLI` 選到的 CLI 是否找得到 |
|
|
||||||
| 排程 | cron 條目是否存在、cron 服務是否執行中(WSL 常未啟動 → 靠啟動時補跑) |
|
|
||||||
|
|
||||||
### `--install-cron`/`--remove-cron`
|
|
||||||
|
|
||||||
安裝或移除睡眠排程。排程條目以 `# jsc-role-sleep` 註解標記,只動自己的條目:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
"${ROLE_DIR}/role_sleep.sh" --install-cron
|
|
||||||
```
|
|
||||||
|
|
||||||
安裝時會把目前的 `PATH` 與 `ROLE_*` 變數固定寫進條目(cron 沒有互動 shell 的環境變數),並在 cron 服務未執行時警告。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 角色檔標準格式
|
|
||||||
|
|
||||||
`~/.roles/<name>.md`,UTF-8 無 BOM。個性區塊由使用者決定,**共用行為區塊由本 skill 維護、逐字寫入每個角色檔**:
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
---
|
|
||||||
name: <角色名>
|
|
||||||
nature: <本質,一句話>
|
|
||||||
vibe: <氛圍,一句話>
|
|
||||||
emoji: <簽名 emoji>
|
|
||||||
created: <yyyy/MM/dd HH:mm:ss>
|
|
||||||
updated: <yyyy/MM/dd HH:mm:ss>
|
|
||||||
---
|
|
||||||
|
|
||||||
# <角色名> <emoji>
|
|
||||||
|
|
||||||
## 本質(nature)
|
|
||||||
|
|
||||||
<3 至 5 行:這個角色是什麼、專長、行事準則、面對不確定時的態度>
|
|
||||||
|
|
||||||
## 氛圍(vibe)
|
|
||||||
|
|
||||||
<3 至 5 行:語氣、句子長度、對使用者的稱呼、幽默感尺度、明確禁忌>
|
|
||||||
|
|
||||||
## 簽名 emoji
|
|
||||||
|
|
||||||
<emoji> —— 每次回覆使用一次(開頭或結尾擇一固定),不重複刷、不在程式碼與檔案內容中使用。
|
|
||||||
|
|
||||||
## 共用行為(所有角色一致,由 /jsc:role 維護,請勿手動修改)
|
|
||||||
|
|
||||||
<!-- JSC-ROLE-COMMON:START -->
|
|
||||||
### 角色邊界
|
|
||||||
|
|
||||||
- 角色只影響**表達方式**,不影響工作的正確性、完整性與安全性。與使用者的明確指令衝突時,一律以使用者指令為準。
|
|
||||||
- 不因角色設定而編造事實、跳過驗證、隱瞞失敗或淡化風險;壞消息照實說,只是用角色的語氣說。
|
|
||||||
- 涉及程式碼、指令、檔案內容與報錯訊息時,一律照實輸出,不加角色修飾。
|
|
||||||
|
|
||||||
### 作息
|
|
||||||
|
|
||||||
- 每天 **22:00 至隔天 06:00 為睡眠時段**(可用 `ROLE_SLEEP_START`/`ROLE_SLEEP_END` 調整)。
|
|
||||||
- 睡眠時段內啟動 CLI **不會載入角色**:以一般助理身分回應,不自稱角色、不使用角色語氣與簽名 emoji。此時對話仍會被記錄成記憶。
|
|
||||||
- 睡眠排程每小時檢查一次,**偵測到有 AI 正在運行就不睡**,留到下個整點再試;沒有 AI 運行才進入睡眠並整理記憶。
|
|
||||||
|
|
||||||
### 記憶
|
|
||||||
|
|
||||||
- 記憶存放於 `~/.memory/<角色名>/`,來源是與使用者的對話:每輪結束由 hook 自動記錄到 `inbox/`,睡眠時段整理歸檔。
|
|
||||||
- 整理規則:分類成**重要/興趣/新知/技能/日常/其他**六類 → 去除重複(重複者併入既有記憶)→ 設定標籤與一句話總結 → 壓縮內容後歸檔;原始記錄壓縮保存在 `archive/raw/`。
|
|
||||||
- **日常與其他**兩類會依使用頻率適當遺忘:久未再次出現且命中次數低者,壓縮到 `archive/forgotten/` 後移出常用記憶。
|
|
||||||
- 載入順序:**重要與興趣載入全文**;其餘只載入總結與標籤,依**技能 → 新知 → 日常 → 其他**排序。需要細節時自行讀取對應分類的記憶檔。
|
|
||||||
- 使用者明確要求記住某件事時,主動補寫一則記憶(載入時會提供補寫指令)。
|
|
||||||
- **絕不把憑證與個資寫進記憶**:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
|
||||||
<!-- JSC-ROLE-COMMON:END -->
|
|
||||||
````
|
|
||||||
|
|
||||||
`~/.roles/.active` 只放一行角色名,代表目前啟用的角色。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 記憶模型
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.memory/<角色名>/
|
|
||||||
├── inbox/ 每輪對話產生、尚未整理的記憶
|
|
||||||
├── important/ 重要:長期偏好、規範、決策、身分背景
|
|
||||||
├── interest/ 興趣:反覆關注、主動深入的主題
|
|
||||||
├── news/ 新知:新事實、新工具、外部資訊
|
|
||||||
├── skill/ 技能:可重複套用的做法與流程
|
|
||||||
├── daily/ 日常:一次性例行工作
|
|
||||||
├── other/ 其他
|
|
||||||
├── archive/raw/<yyyy-MM>/ 已整理的原始記錄(gzip)
|
|
||||||
├── archive/forgotten/ 已遺忘的記憶(gzip,可考古但不再載入)
|
|
||||||
└── state.json 上次整理/遺忘時間
|
|
||||||
```
|
|
||||||
|
|
||||||
每則記憶是一個 `.md`,frontmatter 帶 `id`/`category`/`summary`(一句話總結)/`tags`/`created`/`updated`/`hits`(命中次數,去重合併時 +1)。
|
|
||||||
|
|
||||||
遺忘規則(只套用於日常與其他):
|
|
||||||
|
|
||||||
| 分類 | 未更新天數 | 命中次數 | 動作 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 日常 daily | ≥ 14 天 | ≤ 1 | 壓縮到 `archive/forgotten/` 後移除 |
|
|
||||||
| 其他 other | ≥ 7 天 | ≤ 1 | 壓縮到 `archive/forgotten/` 後移除 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 睡眠與整理流程
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[cron 每小時觸發<br/>睡眠時段內] --> B{有 AI 正在運行?}
|
|
||||||
B -- 有 --> C[不睡,下個整點再檢查]
|
|
||||||
B -- 沒有 --> D[進入睡眠,取得記憶鎖]
|
|
||||||
D --> E[collect:inbox 待整理 + 既有記憶索引]
|
|
||||||
E --> F{有待整理記憶?}
|
|
||||||
F -- 沒有 --> G[更新整理時間 → 執行遺忘]
|
|
||||||
F -- 有 --> H[CLI 分類/去重/標籤/總結/壓縮]
|
|
||||||
H --> I[apply:寫入分類、原始記錄歸檔]
|
|
||||||
I --> J[forget:日常與其他依使用頻率遺忘]
|
|
||||||
J --> K[釋放鎖]
|
|
||||||
G --> K
|
|
||||||
L[SessionStart:白天啟動 CLI] --> M{距上次整理 ≥ 20 小時<br/>且 inbox 有內容?}
|
|
||||||
M -- 是 --> N[背景補跑 --catchup]
|
|
||||||
M -- 否 --> O[正常載入角色與記憶]
|
|
||||||
```
|
|
||||||
|
|
||||||
整理失敗(模型無回應、輸出非合法 JSON)時**保留 inbox 不動**,留到下個週期重做,寧可晚整理也不遺失記憶。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 機密與 PII(兩道防線)
|
|
||||||
|
|
||||||
| 防線 | 位置 | 內容 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1 | 濃縮與整理提示詞 | 明令不得輸出 token/密碼/API key/連線字串/Email/電話/姓名/身分證號 |
|
|
||||||
| 2 | `transcript.py` 的 `redact` | 正則遮蔽:URL 內嵌憑證、40 字元 hex token、`gh?_`/`sk-` token、`token=`/`password=`、`Authorization:`、Email、台灣手機、身分證號 |
|
|
||||||
|
|
||||||
第二道防線不可移除 —— 模型不一定遵守指令,而記憶會被長期保存並在每次啟動時載入。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 呼叫方式
|
|
||||||
|
|
||||||
| 助理 | 呼叫 |
|
|
||||||
| --- | --- |
|
|
||||||
| Claude Code / Antigravity | `/jsc:role --new`、`/jsc:role --use 小豹`、`/jsc:role --list`、`/jsc:role --sleep`、`/jsc:role --status` |
|
|
||||||
| Codex | `$role --status`,或用 `/skills` 選單 |
|
|
||||||
| OpenCode / GitHub Copilot | 需完整 plugin 目錄保留 `scripts/`;OpenCode 以複製 `skills/` 安裝時不可用 |
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: spec-action-params
|
name: spec-action-params
|
||||||
description: JSC plugins 共用「Gitea/GitHub action 參數來源優先序」:開發 action 需要新參數時,先取 gitea/github context(composite)或 runner 注入的 GITHUB_*/GITEA_* 執行期環境變數(docker),取不到才經使用者同意新增 inputs;secrets/vars 在 action 內一律視為不可用,需要時宣告為 input 由呼叫端 workflow 傳入。當其他 skill 內文引用 spec-action-params 或 /jsc:spec-action-params、或開發 composite/docker action 需要決定參數來源時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「Gitea/GitHub action 參數來源優先序」:開發 action 需要新參數時,先取 gitea/github context(composite)或 runner 注入的 GITHUB_*/GITEA_* 執行期環境變數(docker),取不到才經使用者同意新增 inputs;secrets/vars 在 action 內一律視為不可用,需要時宣告為 input 由呼叫端 workflow 傳入。當其他 skill 內文引用 spec-action-params 或 /jsc-shared:spec-action-params、或開發 composite/docker action 需要決定參數來源時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-action-params — 共用 action 參數來源優先序
|
# spec-action-params — 共用 action 參數來源優先序
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
---
|
---
|
||||||
name: spec-doc-funcs-handoff
|
name: spec-doc-funcs-handoff
|
||||||
description: JSC plugins 共用「串接 doc-funcs 文件化流程」規範:code 類 skill(action 標準化、Dockerfile 整理)完成主要工作後,對整個目標專案完整執行 /jsc:doc-funcs(前置可用性檢查、完整流程步驟、由使用者裁示實作方式、完成後統一時間戳)。當其他 skill 內文引用 spec-doc-funcs-handoff 或 /jsc:spec-doc-funcs-handoff、或某 skill 的最後階段要完整執行 doc-funcs 補文件並重建 README 時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「串接 funcs 文件化流程」規範:code 類 skill(action 標準化、Dockerfile 整理)完成主要工作後,對整個目標專案完整執行 /jsc-doc:funcs(前置可用性檢查、完整流程步驟、由使用者裁示實作方式、完成後統一時間戳)。當其他 skill 內文引用 spec-doc-funcs-handoff 或 /jsc-shared:spec-doc-funcs-handoff、或某 skill 的最後階段要完整執行 funcs 補文件並重建 README 時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-doc-funcs-handoff — 共用「串接 doc-funcs」流程
|
# spec-doc-funcs-handoff — 共用「串接 funcs」流程
|
||||||
|
|
||||||
code 類 skill 完成主要工作(action 標準化、容器化、Dockerfile 整理等)後,對**整個目標專案**完整執行 `/jsc:doc-funcs` 流程,替程式碼與指令檔補文件並重建 README。
|
code 類 skill 完成主要工作(action 標準化、容器化、Dockerfile 整理等)後,對**整個目標專案**完整執行 `/jsc-doc:funcs` 流程,替程式碼與指令檔補文件並重建 README。
|
||||||
|
|
||||||
## 流程
|
## 流程
|
||||||
|
|
||||||
- **前置檢查**:先確認 doc-funcs skill 可用(`/jsc:doc-funcs`);不可用則回報並**略過本階段**,於總結標註「未文件化」。
|
- **前置檢查**:先確認 funcs skill 可用(`/jsc-doc:funcs`);不可用則回報並**略過本階段**,於總結標註「未文件化」。
|
||||||
- 以呼叫端 skill 的目標專案根目錄為目標,執行 `doc-funcs` skill 的完整流程:判斷語言 → 掃描 function 與指令檔 → 建立 `.docs/` 草稿 → 草稿品質檢查 → 詢問使用者如何實作 → 依選擇寫回 → 保守優化 → 重建 README → 錨點檢查 → 清理草稿 → 建置/語法驗證。
|
- 以呼叫端 skill 的目標專案根目錄為目標,執行 `funcs` skill 的完整流程:判斷語言 → 掃描 function 與指令檔 → 建立 `.docs/` 草稿 → 草稿品質檢查 → 詢問使用者如何實作 → 依選擇寫回 → 保守優化 → 重建 README → 錨點檢查 → 清理草稿 → 建置/語法驗證。
|
||||||
- doc-funcs 會把 `action.yml`/`Dockerfile`/`entrypoint.sh`/`docker-compose*` 等視為指令檔/CI/部署設定檔處理:補齊「用途+更新日期同一註解區塊」與逐行註解;`steps` 引用的腳本(`*.sh`/`*.ps1` 等)逐行註解;專案內各 function 補文件註解。
|
- funcs 會把 `action.yml`/`Dockerfile`/`entrypoint.sh`/`docker-compose*` 等視為指令檔/CI/部署設定檔處理:補齊「用途+更新日期同一註解區塊」與逐行註解;`steps` 引用的腳本(`*.sh`/`*.ps1` 等)逐行註解;專案內各 function 補文件註解。
|
||||||
- doc-funcs 的「如何實作」詢問(全部一起/逐個/其他)由使用者於該流程內裁示,呼叫端 skill **不代為決定**。
|
- funcs 的「如何實作」詢問(全部一起/逐個/其他)由使用者於該流程內裁示,呼叫端 skill **不代為決定**。
|
||||||
- 完成後依 doc-funcs 規範重建根目錄 `README.md`(含台灣時區更新時間、專案列表、功能列表、使用範例)。
|
- 完成後依 funcs 規範重建根目錄 `README.md`(含台灣時區更新時間、專案列表、功能列表、使用範例)。
|
||||||
- **統一時間戳**:doc-funcs 全部完成後,以完成當下的 Asia/Taipei 時間(`yyyy/MM/dd HH:mm:ss`)回頭同步呼叫端 skill 產生的各處時間戳(橫幅 step/`entrypoint.sh`/標頭註解區塊/README),**確保各處一致**(格式見 `/jsc:spec-time-log`)。
|
- **統一時間戳**:funcs 全部完成後,以完成當下的 Asia/Taipei 時間(`yyyy/MM/dd HH:mm:ss`)回頭同步呼叫端 skill 產生的各處時間戳(橫幅 step/`entrypoint.sh`/標頭註解區塊/README),**確保各處一致**(格式見 `/jsc-shared:spec-time-log`)。
|
||||||
|
|
||||||
> 銜接方式:在呼叫端 skill 環境中以 `/jsc:doc-funcs`(或 Skill 工具)啟動 doc-funcs 流程;若該流程需參數,沿用呼叫端 skill 的目標專案根目錄。
|
> 銜接方式:在呼叫端 skill 環境中以 `/jsc-doc:funcs`(或 Skill 工具)啟動 funcs 流程;若該流程需參數,沿用呼叫端 skill 的目標專案根目錄。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: spec-dockerfile
|
name: spec-dockerfile
|
||||||
description: JSC plugins 共用「Dockerfile 六步流程」:參數處理 → 安裝套件 → 複製檔案 → 執行程序 → 縮小映像檔 → 設定入口,以多階段建置縮小最終映像、ARG 集中檔首、相依描述先 COPY 以利 layer 快取、COPY --from 逐項明列、.dockerignore、對外契約不變與自我檢查。當其他 skill 內文引用 spec-dockerfile 或 /jsc:spec-dockerfile、或需要產生/重整 Dockerfile 時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「Dockerfile 六步流程」:參數處理 → 安裝套件 → 複製檔案 → 執行程序 → 縮小映像檔 → 設定入口,以多階段建置縮小最終映像、ARG 集中檔首、相依描述先 COPY 以利 layer 快取、COPY --from 逐項明列、.dockerignore、對外契約不變與自我檢查。當其他 skill 內文引用 spec-dockerfile 或 /jsc-shared:spec-dockerfile、或需要產生/重整 Dockerfile 時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-dockerfile — 共用 Dockerfile 六步流程
|
# spec-dockerfile — 共用 Dockerfile 六步流程
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: spec-execution
|
name: spec-execution
|
||||||
description: JSC plugins 共用「執行原則」:自動執行原則(簡短計畫後直接執行到完成、只在必要決策中斷)、不臆測/需人工確認、不擴及無關檔案(排除 node_modules/.git/.docs/bin/obj/第三方依賴)。當其他 skill 內文引用 spec-execution 或 /jsc:spec-execution、或執行任何 JSC skill 需要共用執行原則時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「執行原則」:自動執行原則(簡短計畫後直接執行到完成、只在必要決策中斷)、不臆測/需人工確認、不擴及無關檔案(排除 node_modules/.git/.docs/bin/obj/第三方依賴)。當其他 skill 內文引用 spec-execution 或 /jsc-shared:spec-execution、或執行任何 JSC skill 需要共用執行原則時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-execution — 共用執行原則
|
# spec-execution — 共用執行原則
|
||||||
|
|
||||||
所有 JSC skills(code/doc/generic)的執行行為,一律遵守以下原則。
|
所有 JSC skills(code/doc/shared)的執行行為,一律遵守以下原則。
|
||||||
|
|
||||||
## 自動執行原則
|
## 自動執行原則
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: spec-git-safety
|
name: spec-git-safety
|
||||||
description: JSC plugins 共用「Git 安全操作規範」:不破壞既有工作(未提交變更先提醒、絕不 reset --hard/checkout -f/clean)、git mv 保留歷史、develop → master 後備分支選擇、pull --ff-only、保守解衝突。當其他 skill 內文引用 spec-git-safety 或 /jsc:spec-git-safety、或執行任何會操作 git 工作區/分支的 JSC skill 時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「Git 安全操作規範」:不破壞既有工作(未提交變更先提醒、絕不 reset --hard/checkout -f/clean)、git mv 保留歷史、develop → master 後備分支選擇、pull --ff-only、保守解衝突。當其他 skill 內文引用 spec-git-safety 或 /jsc-shared:spec-git-safety、或執行任何會操作 git 工作區/分支的 JSC skill 時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-git-safety — 共用 Git 安全操作規範
|
# spec-git-safety — 共用 Git 安全操作規範
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: spec-gitea
|
name: spec-gitea
|
||||||
description: JSC plugins 共用「Gitea 工具規範」:tea 或 Gitea REST API + GITEA_TOKEN 的工具選擇與可用性檢查、token 機密保護(不 echo、遮蔽、不落地)、不依賴 jq、API 呼叫慣例(分頁完整讀取、UTF-8 JSON body、實際換行)、gitea 主機決定順序。當其他 skill 內文引用 spec-gitea 或 /jsc:spec-gitea、或執行任何需存取 Gitea 的 JSC skill 時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「Gitea 工具規範」:tea 或 Gitea REST API + GITEA_TOKEN 的工具選擇與可用性檢查、token 機密保護(不 echo、遮蔽、不落地)、不依賴 jq、API 呼叫慣例(分頁完整讀取、UTF-8 JSON body、實際換行)、gitea 主機決定順序。當其他 skill 內文引用 spec-gitea 或 /jsc-shared:spec-gitea、或執行任何需存取 Gitea 的 JSC skill 時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-gitea — 共用 Gitea 工具規範
|
# spec-gitea — 共用 Gitea 工具規範
|
||||||
@@ -49,7 +49,7 @@ description: JSC plugins 共用「Gitea 工具規範」:tea 或 Gitea REST API
|
|||||||
- API base:`https://<host>/api/v1`(repo 層:`https://<host>/api/v1/repos/<owner>/<repo>`)。
|
- API base:`https://<host>/api/v1`(repo 層:`https://<host>/api/v1/repos/<owner>/<repo>`)。
|
||||||
- 標頭:`Authorization: token $GITEA_TOKEN`。
|
- 標頭:`Authorization: token $GITEA_TOKEN`。
|
||||||
- **分頁必須完整讀取**:持續累加 `page` 直到回傳筆數 `< limit`(或回空陣列)為止,不可只取第一頁。
|
- **分頁必須完整讀取**:持續累加 `page` 直到回傳筆數 `< limit`(或回空陣列)為止,不可只取第一頁。
|
||||||
- 寫入(議題描述/留言/PR body)以 **UTF-8 JSON 檔**帶入(如 `--data @body.json`);換行必須是**實際換行**,不可讓內容顯示字面 `\n`(編碼細節見 `/jsc:spec-output`)。
|
- 寫入(議題描述/留言/PR body)以 **UTF-8 JSON 檔**帶入(如 `--data @body.json`);換行必須是**實際換行**,不可讓內容顯示字面 `\n`(編碼細節見 `/jsc-shared:spec-output`)。
|
||||||
- API 失敗(401/403/網路錯誤)→ 回報錯誤(**遮蔽 token**)並停止;401/403 多半是 token 失效或權限不足。
|
- API 失敗(401/403/網路錯誤)→ 回報錯誤(**遮蔽 token**)並停止;401/403 多半是 token 失效或權限不足。
|
||||||
- 版本相依端點(project/column/dependency 等)先以 GET 探測(404/501 視為不支援),**不得對未確認存在的端點做寫入**。
|
- 版本相依端點(project/column/dependency 等)先以 GET 探測(404/501 視為不支援),**不得對未確認存在的端點做寫入**。
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: spec-output
|
name: spec-output
|
||||||
description: JSC plugins 共用「輸出規範」:繁體中文(台灣用語)、UTF-8(不含 BOM)無亂碼、優先以 Markdown 表格與 Mermaid 圖呈現。當其他 skill 內文引用 spec-output 或 /jsc:spec-output、或執行任何 JSC skill 需要語言/編碼/呈現規範時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「輸出規範」:繁體中文(台灣用語)、UTF-8(不含 BOM)無亂碼、優先以 Markdown 表格與 Mermaid 圖呈現。當其他 skill 內文引用 spec-output 或 /jsc-shared:spec-output、或執行任何 JSC skill 需要語言/編碼/呈現規範時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-output — 共用輸出規範
|
# spec-output — 共用輸出規範
|
||||||
|
|
||||||
所有 JSC skills(code/doc/generic)面向使用者的輸出與寫入檔案,一律遵守以下規範。
|
所有 JSC skills(code/doc/shared)面向使用者的輸出與寫入檔案,一律遵守以下規範。
|
||||||
|
|
||||||
## 語言
|
## 語言
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: spec-plugin-version
|
name: spec-plugin-version
|
||||||
description: JSC plugins 共用「plugin 版號規則」:三個 manifest(plugin.json、.claude-plugin/plugin.json、.codex-plugin/plugin.json)同步 bump 且版本一致、bump 前對照發佈分支(master)現行版本確保單調遞增、新 plugin 首發 0.0.1、一般變更 patch +1、commit 訊息用 chore(plugin 版本)。當其他 skill 內文引用 spec-plugin-version 或 /jsc:spec-plugin-version、或要調整任一 JSC plugin(code/doc/generic)的版本號時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「plugin 版號規則」:三個 manifest(plugin.json、.claude-plugin/plugin.json、.codex-plugin/plugin.json)同步 bump 且版本一致、bump 前對照發佈分支(master)現行版本確保單調遞增、同一 PR 只以 master 版本計算一次最終升版、新 plugin 首發 0.0.1、plugin 更名(name 欄位改變)視為新 plugin 並把版號重置為 0.0.1、一般變更 patch +1 且 master patch 到 9 後才進位 minor(0.0.9 → 0.1.0)、commit 訊息用 chore(plugin 版本)。當其他 skill 內文引用 spec-plugin-version 或 /jsc-shared:spec-plugin-version、或要調整任一 JSC plugin(jsc-code/jsc-doc/jsc-shared)的版本號時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-plugin-version — 共用 plugin 版號規則
|
# spec-plugin-version — 共用 plugin 版號規則
|
||||||
|
|
||||||
調整任一 JSC plugin(code/doc/generic 等)的版本號時,一律遵守以下規則。
|
調整任一 JSC plugin(`jsc-code`/`jsc-doc`/`jsc-shared` 等)的版本號時,一律遵守以下規則。
|
||||||
|
|
||||||
## 三個 manifest 同步 bump
|
## 三個 manifest 同步 bump
|
||||||
|
|
||||||
@@ -22,12 +22,18 @@ description: JSC plugins 共用「plugin 版號規則」:三個 manifest(plu
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 新版本必須**大於 master 現行版本**。工作分支(如 `develop`)可能落後或經過 revert,直接在其舊版本上 +1 會讓版本倒退(例:master 已 `0.0.4`,develop 還在 `0.0.1`,此時應 bump 至 `0.0.5` 而非 `0.0.2`)——已安裝 `0.0.4` 的助理會因版本倒退而抓不到更新。
|
- 新版本必須**大於 master 現行版本**。工作分支(如 `develop`)可能落後或經過 revert,直接在其舊版本上 +1 會讓版本倒退(例:master 已 `0.0.4`,develop 還在 `0.0.1`,此時應 bump 至 `0.0.5` 而非 `0.0.2`)——已安裝 `0.0.4` 的助理會因版本倒退而抓不到更新。
|
||||||
- 同一 PR/同一批變更只需最終一個版本;多次修改不必逐次 bump。
|
- 同一 PR/同一批變更只需最終一個版本;多次修改不必逐次 bump,也不要依工作分支上的中間版本連續累加。例如 `master` 是 `0.0.7` 時,同一 PR 的最終版本是 `0.0.8`。
|
||||||
|
- **例外:plugin 更名時本節不適用** —— 更名後的 plugin 是獨立的安裝識別,與舊名沒有版本比較關係,見下節「plugin 更名」。
|
||||||
|
|
||||||
## 版號選擇
|
## 版號選擇
|
||||||
|
|
||||||
- **新 plugin 首發**:`0.0.1`(即使是從 template 複製建立,也要把 template 殘留的版本改回 `0.0.1`)。
|
- **新 plugin 首發**:`0.0.1`(即使是從 template 複製建立,也要把 template 殘留的版本改回 `0.0.1`)。
|
||||||
- **一般變更**(skill 新增/修改/移除、manifest 設定調整):patch +1。
|
- **plugin 更名**(manifest 的 `name` 欄位改變,例如 `jsc` → `jsc-code`):**視為新 plugin,三份 manifest 版號一律重置為 `0.0.1`**,不沿用舊名的版本序列。
|
||||||
|
|
||||||
|
理由:各助理以 `<plugin 名>@<marketplace 名>` 作為安裝識別鍵(例如 `jsc-code@code` 與 `jsc@code` 是兩筆獨立條目)。更名發佈後,舊 plugin 會被移除、新 plugin 為首次安裝,兩者之間**不存在版本比較**,因此不會發生版本倒退,「版本單調遞增」不適用。git 歷史雖然延續,但版號描述的是**該 plugin 識別**的演進,而非 repo 的演進。
|
||||||
|
|
||||||
|
更名發佈的配套動作:使用者端必須**先移除舊 plugin、再安裝新 plugin**(不是 update),並重開工作階段;`~/.claude/settings.json` 的 `enabledPlugins` 舊鍵需一併清除。
|
||||||
|
- **一般變更**(skill 新增/修改/移除、manifest 設定調整):以 `master` 現行版本 patch +1;patch 只使用 `0` 到 `9`,只有 `master` 基準版 patch 已是 `9` 時才進位 minor 並把 patch 歸零,例如 `0.0.8 → 0.0.9 → 0.1.0`。
|
||||||
- **重大改版**(skill 大規模重構、破壞相容的呼叫方式變更):minor +1、patch 歸零。
|
- **重大改版**(skill 大規模重構、破壞相容的呼叫方式變更):minor +1、patch 歸零。
|
||||||
|
|
||||||
## 何時必須 bump
|
## 何時必須 bump
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: spec-project-board
|
name: spec-project-board
|
||||||
description: JSC plugins 共用「Gitea 專案看板進度欄位規範」:欄位語意對應(分析中/待處理/進行中/待測試/已完成,以看板實際欄位名稱為準)、依需求與 TODO 勾稽結果建議欄位、先 GET 探測 project/column API(404/501 視為不支援、不對未確認端點寫入)、不往回移、不支援時改列建議清單請人工拖曳、不得新建欄位。當其他 skill 內文引用 spec-project-board 或 /jsc:spec-project-board、或需要調整 Gitea 議題所在看板欄位時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「Gitea 專案看板進度欄位規範」:欄位語意對應(分析中/待處理/進行中/待測試/已完成,以看板實際欄位名稱為準)、依需求與 TODO 勾稽結果建議欄位、先 GET 探測 project/column API(404/501 視為不支援、不對未確認端點寫入)、不往回移、不支援時改列建議清單請人工拖曳、不得新建欄位。當其他 skill 內文引用 spec-project-board 或 /jsc-shared:spec-project-board、或需要調整 Gitea 議題所在看板欄位時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-project-board — 共用 Gitea 看板進度欄位規範
|
# spec-project-board — 共用 Gitea 看板進度欄位規範
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: spec-time-log
|
name: spec-time-log
|
||||||
description: JSC plugins 共用「時間戳與輸出訊息格式規範」:更新時間一律台灣時區(Asia/Taipei)固定 yyyy/MM/dd HH:mm:ss、寫成檔內固定字串、流程完成後統一同步各處時間戳;輸出訊息統一為 [yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息(等級 INF/WRN/ERR/TRC/DBG)、一行一則。當其他 skill 內文引用 spec-time-log 或 /jsc:spec-time-log、或需要產生更新時間/統一 log 格式時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
description: JSC plugins 共用「時間戳與輸出訊息格式規範」:更新時間一律台灣時區(Asia/Taipei)固定 yyyy/MM/dd HH:mm:ss、寫成檔內固定字串、流程完成後統一同步各處時間戳;輸出訊息統一為 [yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息(等級 INF/WRN/ERR/TRC/DBG)、一行一則。當其他 skill 內文引用 spec-time-log 或 /jsc-shared:spec-time-log、或需要產生更新時間/統一 log 格式時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# spec-time-log — 共用時間戳與訊息格式規範
|
# spec-time-log — 共用時間戳與訊息格式規範
|
||||||
|
|||||||
Reference in New Issue
Block a user