@vintmd/cos-vectors 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # vintmd-cos-vectors
2
+
3
+ [OpenClaw](https://github.com/openclaw/openclaw) 插件,集成 **腾讯云 COS 向量数据库**,实现**逐轮动态 Skill 路由**。
4
+
5
+ 每次对话时,用户的消息会被向量化,并自动将 **top-k 最相关的 skill** 注入到 system prompt 中 —— 无需重启会话,下一条消息即时生效。
6
+
7
+ ---
8
+
9
+ ## 工作原理
10
+
11
+ ```
12
+ 用户消息(每轮触发)
13
+
14
+
15
+ Embedding API(兼容 OpenAI 接口)
16
+ │ 对消息文本做向量化
17
+
18
+ COS Vectors(QueryVectors)
19
+ │ 返回 top-k 最相似的 skill(按 minScore 过滤)
20
+
21
+ appendSystemContext ──► LLM
22
+ ```
23
+
24
+ 1. Skill 以向量形式存储在 **腾讯云 COS 向量数据库**中,通过 `cos-vectors-proxy` HTTP API 操作。
25
+ 2. 每个 skill 使用任意 **兼容 OpenAI 接口的 Embedding API** 进行向量化(支持 OpenAI、混元、Azure、本地模型等)。
26
+ 3. `before_prompt_build` hook 在**每次 LLM 调用前**触发(而非仅在 session 初始化时),查询 top-k 相关 skill 并注入到可缓存的 system prompt 后缀中。
27
+
28
+ > **核心优势**:hook 每轮都会触发,因此通过 `skill_add` / `skill_remove` 添加或删除的 skill,在**下一条消息**即可生效,无需重开会话或重启 OpenClaw。
29
+
30
+ ---
31
+
32
+ ## 前置条件
33
+
34
+ | 依赖 | 说明 |
35
+ |---|---|
36
+ | OpenClaw ≥ 3.22 | 使用 `plugin-sdk` hook API |
37
+ | [cos-vectors-proxy](https://github.com/tencentyun/cos-vectors-proxy) | 本地或远程运行,提供 `PutVectors` / `QueryVectors` / `DeleteVectors` 接口 |
38
+ | 腾讯云账号 | 需要有 COS 向量数据库权限的 SecretId / SecretKey |
39
+ | 已存在的向量桶 + 索引 | **不要新建向量桶**,绑定已有的桶(如广州区 `test-12345678`) |
40
+ | 兼容 OpenAI 接口的 Embedding API | OpenAI / 混元 / Azure / 本地模型服务均可 |
41
+
42
+ ---
43
+
44
+ ## 安装
45
+
46
+ ### 方式一:通过 npm 安装(推荐)
47
+
48
+ ```bash
49
+ openclaw plugins install @vintmd/cos-vectors
50
+ ```
51
+
52
+ OpenClaw 会优先从 ClawHub 查找,找不到时自动回退到 npm。安装完成后重启 Gateway 即可。
53
+
54
+ ### 方式二:本地路径安装(开发 / 自定义)
55
+
56
+ 将本目录放置(或软链接)到 OpenClaw 的 extensions 目录下,然后在 OpenClaw 配置文件中启用:
57
+
58
+ ```jsonc
59
+ // openclaw.config.json
60
+ {
61
+ "plugins": [
62
+ {
63
+ "id": "vintmd-cos-vectors",
64
+ "path": "./extensions/vintmd-cos-vectors"
65
+ }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ 无需执行 `npm install` —— 插件没有外部依赖,仅使用 Node.js 内置的 `crypto` 和 `fetch`。
71
+
72
+ ---
73
+
74
+ ## 配置
75
+
76
+ 所有选项可在插件的 `config` 块中设置,**也可通过环境变量**配置。配置文件中的值优先级高于环境变量。
77
+
78
+ ### 配置项说明
79
+
80
+ | 字段 | 类型 | 默认值 | 环境变量 | 说明 |
81
+ |---|---|---|---|---|
82
+ | `proxyEndpoint` | string | `http://127.0.0.1:8080` | `COSVECTORS_ENDPOINT` | cos-vectors-proxy 的访问地址 |
83
+ | `vectorBucketName` | string | `openclaw-skills` | `COSVECTORS_BUCKET` | COS 向量桶名称(必须已存在) |
84
+ | `indexName` | string | `skills` | `COSVECTORS_INDEX` | 向量桶内的索引名称(必须已存在) |
85
+ | `secretId` | string | — | `COSVECTORS_SECRET_ID` | 腾讯云 SecretId |
86
+ | `secretKey` | string | — | `COSVECTORS_SECRET_KEY` | 腾讯云 SecretKey |
87
+ | `topK` | number | `5` | — | 每轮最多注入的 skill 数量 |
88
+ | `minScore` | number | `0.5` | — | 最低相似度阈值(0~1),越低召回越多 |
89
+ | `embeddingModel` | string | `text-embedding-3-small` | `SKILL_ROUTER_EMBEDDING_MODEL` | Embedding 模型名称 |
90
+ | `embeddingBaseUrl` | string | `https://api.openai.com/v1` | `SKILL_ROUTER_EMBEDDING_BASE_URL` | Embedding API 的 base URL |
91
+ | `embeddingApiKey` | string | — | `SKILL_ROUTER_EMBEDDING_API_KEY` / `OPENAI_API_KEY` | Embedding API 密钥 |
92
+
93
+ ### 配置示例 — 使用混元 Embedding
94
+
95
+ ```jsonc
96
+ {
97
+ "plugins": [
98
+ {
99
+ "id": "vintmd-cos-vectors",
100
+ "path": "./extensions/vintmd-cos-vectors",
101
+ "config": {
102
+ "proxyEndpoint": "http://<cos-vectors-proxy地址>:<端口>",
103
+ "vectorBucketName": "your-bucket-name",
104
+ "indexName": "skills",
105
+ "secretId": "<your-secret-id>",
106
+ "secretKey": "<your-secret-key>",
107
+ "topK": 5,
108
+ "minScore": 0.3,
109
+ "embeddingModel": "hunyuan-embedding",
110
+ "embeddingBaseUrl": "https://api.hunyuan.cloud.tencent.com/v1",
111
+ "embeddingApiKey": "<your-api-key>"
112
+ }
113
+ }
114
+ ]
115
+ }
116
+ ```
117
+
118
+ ### 配置示例 — 环境变量方式
119
+
120
+ ```bash
121
+ export COSVECTORS_ENDPOINT=http://<cos-vectors-proxy地址>:<端口>
122
+ export COSVECTORS_BUCKET=your-bucket-name
123
+ export COSVECTORS_INDEX=skills
124
+ export COSVECTORS_SECRET_ID=<your-secret-id>
125
+ export COSVECTORS_SECRET_KEY=<your-secret-key>
126
+ export SKILL_ROUTER_EMBEDDING_BASE_URL=https://api.hunyuan.cloud.tencent.com/v1
127
+ export SKILL_ROUTER_EMBEDDING_MODEL=hunyuan-embedding
128
+ export OPENAI_API_KEY=<your-api-key> # 作为 embeddingApiKey 的兜底
129
+ ```
130
+
131
+ ---
132
+
133
+ ## 工具(Tools)
134
+
135
+ 插件注册了四个工具,可由 LLM 调用,也可通过 OpenClaw tool API 直接调用:
136
+
137
+ ### `skill_add`
138
+
139
+ 将 skill 向量化后写入 COS Vectors(upsert)。**下一条消息**即可生效。
140
+
141
+ ```json
142
+ {
143
+ "name": "python-debugging",
144
+ "content": "调试 Python 时,优先查看 traceback 最后一行。交互式调试使用 pdb.set_trace()..."
145
+ }
146
+ ```
147
+
148
+ ### `skill_remove`
149
+
150
+ 按名称删除 skill。**下一条消息**即可生效。
151
+
152
+ ```json
153
+ { "name": "python-debugging" }
154
+ ```
155
+
156
+ ### `skill_list`
157
+
158
+ 列出**本地内存缓存**中的 skill(仅包含当前进程启动后通过 `skill_add` 添加的条目)。
159
+
160
+ > ⚠️ 此工具**不会**查询 COS Vectors 服务端,只反映当前进程生命周期内的操作记录。如需发现已有 skill,请使用 `skill_search`。
161
+
162
+ ```json
163
+ {}
164
+ ```
165
+
166
+ ### `skill_search`
167
+
168
+ 按查询文本搜索相关 skill,适合调试路由逻辑。
169
+
170
+ ```json
171
+ {
172
+ "query": "如何修复 C++ 中的段错误",
173
+ "topK": 3
174
+ }
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Skill ID 命名规则
180
+
181
+ Skill 名称会被规范化为稳定的向量 key,作为 COS Vectors 的文档键:
182
+
183
+ ```
184
+ "Python Debugging" → "skill-python-debugging"
185
+ "SQL Optimization" → "skill-sql-optimization"
186
+ "deploy-k8s" → "skill-deploy-k8s"
187
+ ```
188
+
189
+ 注意:`skill_add("Python Debugging", ...)` 和 `skill_add("python-debugging", ...)` 会映射到**同一个** key,后者会覆盖前者。
190
+
191
+ ---
192
+
193
+ ## 相似度分数与调优
194
+
195
+ COS Vectors 对归一化向量使用**内积(IP)距离**计算余弦相似度。插件将其转换为 `[0, 1]` 分数:
196
+
197
+ ```
198
+ score = 1 - distance (截断到 [0, 1])
199
+ ```
200
+
201
+ 只有 `score >= minScore` 的 skill 才会被注入。调优参考:
202
+
203
+ | `minScore` | 行为 |
204
+ |---|---|
205
+ | `0.7+` | 高精度,只注入非常相关的 skill |
206
+ | `0.5` | 均衡(默认值) |
207
+ | `0.3` | 高召回,注入更多 skill,可能包含弱相关内容 |
208
+ | `0.0` | 始终注入 top-k,不过滤 |
209
+
210
+ Embedding 模型的选择同样重要:
211
+ - **混元 Embedding**(`hunyuan-embedding`):**1024 维**,中英文混合内容效果好
212
+ - **OpenAI `text-embedding-3-small`**:**1536 维**,英文内容效果更佳
213
+
214
+ ---
215
+
216
+ ## 动态 Skill 更新(核心特性)
217
+
218
+ 与静态 system prompt 不同,本插件**逐轮**更新注入的 skill:
219
+
220
+ ```
221
+ 第 1 轮:用户问 git 相关 → 注入 git-commit skill
222
+ 第 2 轮:用户问 SQL 相关 → 注入 sql-optimize skill(git-commit 不注入)
223
+ 第 3 轮:调用 skill_add("new-skill", ...) → 从第 4 轮起可用
224
+ 第 4 轮:用户问 new-skill 相关话题 → new-skill 立即被注入
225
+ ```
226
+
227
+ 这解决了"skill 只在新开会话时才更新"的经典问题。
228
+
229
+ ---
230
+
231
+ ## Token 节省效果
232
+
233
+ 与将所有 skill 全量注入 system prompt 相比:
234
+
235
+ | 方案 | 注入 skill 数 | 估算 token(10 个 skill) |
236
+ |---|---|---|
237
+ | 静态全量注入 | 10 / 10 | ~450 |
238
+ | 动态 top-k=3 | 3 / 10 | ~165 |
239
+ | **节省** | — | **~63%** |
240
+
241
+ skill 数量越多,节省效果越显著。
242
+
243
+ ---
244
+
245
+ ## 快速开始
246
+
247
+ 1. 确保 `cos-vectors-proxy` 已启动并可访问。
248
+ 2. 确认 COS 向量桶和索引已存在(可用 `list-indexes.mjs` 验证)。
249
+ 3. 填写配置文件或设置环境变量。
250
+ 4. 在 `openclaw.config.json` 中启用插件,启动 OpenClaw。
251
+ 5. 让 LLM 添加一个 skill:
252
+ > "添加一个名为 'git-rebase' 的 skill,内容:合并前始终使用 `git rebase -i` 整理提交记录。"
253
+ 6. 在**同一个会话**中,问任何与 git rebase 相关的问题 —— skill 会自动注入。
254
+
255
+ ---
256
+
257
+ ## 验证连通性
258
+
259
+ 在接入 OpenClaw 前,可用内置脚本验证环境:
260
+
261
+ ```bash
262
+ # 列出向量桶中的所有索引(验证桶和索引是否存在)
263
+ node list-indexes.mjs
264
+
265
+ # 运行完整 E2E 测试:添加 skill、查询、删除、token 对比
266
+ node test-real.mjs
267
+ ```
268
+
269
+ `test-real.mjs` 预期输出:
270
+ ```
271
+ ✅ PASS: git-commit skill found in results
272
+ ✅ PASS: git-commit skill successfully removed
273
+ ✅ PASS: deploy-k8s skill found
274
+ 📊 Token reduction: ~63% (saved ~285 tokens)
275
+ ```
276
+
277
+ ---
278
+
279
+ ## 常见问题
280
+
281
+ ### Skill 没有出现在回复中
282
+
283
+ - 检查 `minScore` 是否过高 —— 调低到 `0.3` 看是否有 skill 被过滤掉。
284
+ - 用 `skill_search` 加上你的查询文本,查看实际返回的分数。
285
+ - 确认添加 skill 时使用的 Embedding 模型与当前查询时一致,且向量桶/索引名称匹配。
286
+
287
+ ### `skill_list` 返回空列表
288
+
289
+ - `skill_list` 只显示**当前进程**通过 `skill_add` 添加的 skill,不查询 COS Vectors 服务端。如需发现已有 skill,请用 `skill_search` 进行宽泛查询。
290
+
291
+ ### COS Vectors 鉴权失败
292
+
293
+ - 检查 `secretId` / `secretKey` 是否正确,且具有 COS 向量数据库操作权限。
294
+ - 确认 `proxyEndpoint` 从当前机器可以访问。
295
+ - 签名使用 HMAC-SHA1,请确保系统时钟准确(误差在 ±5 分钟以内)。
296
+
297
+ ### Embedding API 报错
298
+
299
+ - 使用混元时:base URL 必须为 `https://api.hunyuan.cloud.tencent.com/v1`,模型名为 `hunyuan-embedding`。
300
+ - API key 读取优先级:`config.embeddingApiKey` → `SKILL_ROUTER_EMBEDDING_API_KEY` 环境变量 → `OPENAI_API_KEY` 环境变量。
301
+
302
+ ### 索引维度不匹配
303
+
304
+ - 同一个索引内所有向量的维度必须一致。切换 Embedding 模型后,需要重建索引或使用新索引。
305
+ - 混元 Embedding:**1024 维**;OpenAI `text-embedding-3-small`:**1536 维**。
package/index.ts ADDED
@@ -0,0 +1,203 @@
1
+ import type { OpenClawPluginApi, OpenClawPluginToolFactory } from "openclaw/plugin-sdk/core";
2
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
3
+ import { SkillRouter } from "./src/skill-router.js";
4
+
5
+ export default definePluginEntry({
6
+ id: "vintmd-cos-vectors",
7
+ name: "Vintmd COS Vectors",
8
+ description: "Vintmd COS Vectors-backed dynamic skill routing with embedding similarity search",
9
+ register(api: OpenClawPluginApi) {
10
+ const cfg = (api.pluginConfig ?? {}) as {
11
+ proxyEndpoint?: string;
12
+ vectorBucketName?: string;
13
+ indexName?: string;
14
+ secretId?: string;
15
+ secretKey?: string;
16
+ topK?: number;
17
+ minScore?: number;
18
+ embeddingModel?: string;
19
+ embeddingBaseUrl?: string;
20
+ embeddingApiKey?: string;
21
+ };
22
+
23
+ const router = new SkillRouter({
24
+ proxyEndpoint:
25
+ cfg.proxyEndpoint ?? process.env.COSVECTORS_ENDPOINT ?? "http://127.0.0.1:8080",
26
+ vectorBucketName: cfg.vectorBucketName ?? process.env.COSVECTORS_BUCKET ?? "openclaw-skills",
27
+ indexName: cfg.indexName ?? process.env.COSVECTORS_INDEX ?? "skills",
28
+ secretId: cfg.secretId ?? process.env.COSVECTORS_SECRET_ID ?? "",
29
+ secretKey: cfg.secretKey ?? process.env.COSVECTORS_SECRET_KEY ?? "",
30
+ topK: cfg.topK ?? 5,
31
+ minScore: cfg.minScore ?? 0.5,
32
+ embeddingModel:
33
+ cfg.embeddingModel ?? process.env.SKILL_ROUTER_EMBEDDING_MODEL ?? "text-embedding-3-small",
34
+ embeddingBaseUrl:
35
+ cfg.embeddingBaseUrl ??
36
+ process.env.SKILL_ROUTER_EMBEDDING_BASE_URL ??
37
+ "https://api.openai.com/v1",
38
+ embeddingApiKey:
39
+ cfg.embeddingApiKey ??
40
+ process.env.SKILL_ROUTER_EMBEDDING_API_KEY ??
41
+ process.env.OPENAI_API_KEY ??
42
+ "",
43
+ logger: api.logger,
44
+ });
45
+
46
+ // ── Hook: inject relevant skills before each prompt build ──────────────
47
+ api.on(
48
+ "before_prompt_build",
49
+ async (event) => {
50
+ try {
51
+ const skills = await router.queryTopK(event.prompt);
52
+ if (skills.length === 0) return;
53
+
54
+ const skillContext = skills
55
+ .map((s) => `## Skill: ${s.name}\n${s.content}`)
56
+ .join("\n\n---\n\n");
57
+
58
+ return {
59
+ // appendSystemContext goes into the cacheable system prompt suffix,
60
+ // so providers like Anthropic can cache it across turns.
61
+ appendSystemContext: `\n\n# Relevant Skills (auto-selected by SkillRouter)\n\n${skillContext}`,
62
+ };
63
+ } catch (err) {
64
+ api.logger.warn(`[vintmd-cos-vectors] before_prompt_build error: ${err}`);
65
+ return;
66
+ }
67
+ },
68
+ { priority: 5 },
69
+ );
70
+
71
+ // ── Tool: add a skill ──────────────────────────────────────────────────
72
+ api.registerTool(((_ctx) => ({
73
+ name: "skill_add",
74
+ label: "Add Skill",
75
+ description:
76
+ "Add or update a skill in the SkillRouter. The skill will be embedded and stored in COS Vectors, and will be automatically injected into future conversations when relevant.",
77
+ parameters: {
78
+ type: "object",
79
+ properties: {
80
+ name: {
81
+ type: "string",
82
+ description: "Unique skill name (e.g. 'python-debugging', 'sql-optimization')",
83
+ },
84
+ content: {
85
+ type: "string",
86
+ description: "Full skill content / instructions in Markdown",
87
+ },
88
+ },
89
+ required: ["name", "content"],
90
+ },
91
+ async execute(_id: string, params: Record<string, unknown>) {
92
+ const name = typeof params.name === "string" ? params.name.trim() : "";
93
+ const content = typeof params.content === "string" ? params.content.trim() : "";
94
+ if (!name) throw new Error("name is required");
95
+ if (!content) throw new Error("content is required");
96
+ await router.addSkill(name, content);
97
+ return {
98
+ content: [{ type: "text", text: `Skill "${name}" added/updated successfully.` }],
99
+ details: { ok: true, name },
100
+ };
101
+ },
102
+ })) as unknown as OpenClawPluginToolFactory);
103
+
104
+ // ── Tool: remove a skill ───────────────────────────────────────────────
105
+ api.registerTool(((_ctx) => ({
106
+ name: "skill_remove",
107
+ label: "Remove Skill",
108
+ description: "Remove a skill from the SkillRouter by name.",
109
+ parameters: {
110
+ type: "object",
111
+ properties: {
112
+ name: {
113
+ type: "string",
114
+ description: "Skill name to remove",
115
+ },
116
+ },
117
+ required: ["name"],
118
+ },
119
+ async execute(_id: string, params: Record<string, unknown>) {
120
+ const name = typeof params.name === "string" ? params.name.trim() : "";
121
+ if (!name) throw new Error("name is required");
122
+ await router.removeSkill(name);
123
+ return {
124
+ content: [{ type: "text", text: `Skill "${name}" removed successfully.` }],
125
+ details: { ok: true, name },
126
+ };
127
+ },
128
+ })) as unknown as OpenClawPluginToolFactory);
129
+
130
+ // ── Tool: list skills ──────────────────────────────────────────────────
131
+ api.registerTool(((_ctx) => ({
132
+ name: "skill_list",
133
+ label: "List Skills",
134
+ description: "List all skills currently stored in the SkillRouter.",
135
+ parameters: {
136
+ type: "object",
137
+ properties: {},
138
+ required: [],
139
+ },
140
+ async execute(_id: string, _params: Record<string, unknown>) {
141
+ const skills = await router.listSkills();
142
+ const text =
143
+ skills.length === 0
144
+ ? "No skills registered yet."
145
+ : skills
146
+ .map(
147
+ (s) =>
148
+ `- **${s.name}**: ${s.content.slice(0, 80)}${s.content.length > 80 ? "..." : ""}`,
149
+ )
150
+ .join("\n");
151
+ return {
152
+ content: [{ type: "text", text }],
153
+ details: { skills: skills.map((s) => s.name) },
154
+ };
155
+ },
156
+ })) as unknown as OpenClawPluginToolFactory);
157
+
158
+ // ── Tool: search skills (debug/test) ───────────────────────────────────
159
+ api.registerTool(((_ctx) => ({
160
+ name: "skill_search",
161
+ label: "Search Skills",
162
+ description:
163
+ "Search for relevant skills by query text (for debugging/testing the SkillRouter).",
164
+ parameters: {
165
+ type: "object",
166
+ properties: {
167
+ query: {
168
+ type: "string",
169
+ description: "Query text to search for relevant skills",
170
+ },
171
+ topK: {
172
+ type: "number",
173
+ description: "Number of results to return (default: 5)",
174
+ },
175
+ },
176
+ required: ["query"],
177
+ },
178
+ async execute(_id: string, params: Record<string, unknown>) {
179
+ const query = typeof params.query === "string" ? params.query.trim() : "";
180
+ if (!query) throw new Error("query is required");
181
+ const k = typeof params.topK === "number" ? Math.floor(params.topK) : undefined;
182
+ const skills = await router.queryTopK(query, k);
183
+ const text =
184
+ skills.length === 0
185
+ ? "No relevant skills found."
186
+ : skills
187
+ .map(
188
+ (s, i) =>
189
+ `${i + 1}. **${s.name}** (score: ${s.score?.toFixed(3) ?? "n/a"})\n${s.content.slice(0, 120)}${s.content.length > 120 ? "..." : ""}`,
190
+ )
191
+ .join("\n\n");
192
+ return {
193
+ content: [{ type: "text", text }],
194
+ details: { skills },
195
+ };
196
+ },
197
+ })) as unknown as OpenClawPluginToolFactory);
198
+
199
+ api.logger.info(
200
+ "[vintmd-cos-vectors] registered: before_prompt_build hook + skill_add/remove/list/search tools",
201
+ );
202
+ },
203
+ });
@@ -0,0 +1,69 @@
1
+ {
2
+ "id": "vintmd-cos-vectors",
3
+ "name": "Vintmd COS Vectors",
4
+ "description": "Dynamic skill routing via COS Vectors embedding similarity search. Automatically selects top-k relevant skills per conversation turn to reduce token usage.",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "proxyEndpoint": {
10
+ "type": "string",
11
+ "description": "COS Vectors proxy endpoint, e.g. http://127.0.0.1:8080"
12
+ },
13
+ "vectorBucketName": {
14
+ "type": "string",
15
+ "description": "COS Vectors bucket name"
16
+ },
17
+ "indexName": {
18
+ "type": "string",
19
+ "description": "COS Vectors index name for skills"
20
+ },
21
+ "secretId": {
22
+ "type": "string",
23
+ "description": "Tencent Cloud SecretId for COS Vectors auth"
24
+ },
25
+ "secretKey": {
26
+ "type": "string",
27
+ "description": "Tencent Cloud SecretKey for COS Vectors auth"
28
+ },
29
+ "topK": {
30
+ "type": "number",
31
+ "description": "Number of top similar skills to inject per turn (default: 5)"
32
+ },
33
+ "minScore": {
34
+ "type": "number",
35
+ "description": "Minimum similarity score threshold 0~1 (default: 0.5)"
36
+ },
37
+ "embeddingModel": {
38
+ "type": "string",
39
+ "description": "OpenAI-compatible embedding model to use (default: text-embedding-3-small)"
40
+ },
41
+ "embeddingBaseUrl": {
42
+ "type": "string",
43
+ "description": "Base URL for embedding API (default: https://api.openai.com/v1)"
44
+ },
45
+ "embeddingApiKey": {
46
+ "type": "string",
47
+ "description": "API key for embedding service"
48
+ }
49
+ }
50
+ },
51
+ "uiHints": {
52
+ "proxyEndpoint": {
53
+ "label": "COS Vectors Proxy Endpoint",
54
+ "placeholder": "http://127.0.0.1:8080"
55
+ },
56
+ "vectorBucketName": { "label": "Vector Bucket Name" },
57
+ "indexName": { "label": "Index Name", "placeholder": "skills" },
58
+ "secretId": { "label": "SecretId", "sensitive": true },
59
+ "secretKey": { "label": "SecretKey", "sensitive": true },
60
+ "topK": { "label": "Top-K Skills", "placeholder": "5" },
61
+ "minScore": { "label": "Min Similarity Score", "placeholder": "0.5" },
62
+ "embeddingModel": { "label": "Embedding Model", "placeholder": "text-embedding-3-small" },
63
+ "embeddingBaseUrl": {
64
+ "label": "Embedding API Base URL",
65
+ "placeholder": "https://api.openai.com/v1"
66
+ },
67
+ "embeddingApiKey": { "label": "Embedding API Key", "sensitive": true }
68
+ }
69
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@vintmd/cos-vectors",
3
+ "version": "1.0.0",
4
+ "description": "OpenClaw plugin: dynamic per-turn skill routing via COS Vectors embedding similarity search",
5
+ "keywords": [
6
+ "cos-vectors",
7
+ "embedding",
8
+ "openclaw",
9
+ "openclaw-plugin",
10
+ "rag",
11
+ "skill-router",
12
+ "tencent"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "index.ts",
17
+ "src/",
18
+ "openclaw.plugin.json",
19
+ "README.md"
20
+ ],
21
+ "type": "module",
22
+ "openclaw": {
23
+ "extensions": [
24
+ "./index.ts"
25
+ ]
26
+ }
27
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * SkillRouter — COS Vectors-backed dynamic skill store.
3
+ *
4
+ * Architecture:
5
+ * 1. Skills are stored as vectors in COS Vectors (cos-vectors-proxy HTTP API).
6
+ * 2. Each skill is embedded via an OpenAI-compatible embedding API.
7
+ * 3. On every before_prompt_build hook, the current user prompt is embedded
8
+ * and the top-k most similar skills are retrieved and injected into the
9
+ * system prompt suffix (appendSystemContext) for prompt caching.
10
+ *
11
+ * COS Vectors HTTP API (cos-vectors-proxy):
12
+ * POST /:methodName — method names: PutVectors, QueryVectors, DeleteVectors
13
+ * Auth: HMAC-SHA256 signature (simplified: SecretId/SecretKey in header)
14
+ *
15
+ * Embedding API: OpenAI-compatible POST /embeddings
16
+ */
17
+
18
+ import crypto from "node:crypto";
19
+
20
+ /** Minimal logger interface (mirrors openclaw/plugin-sdk/core PluginLogger) */
21
+ export type PluginLogger = {
22
+ debug?: (message: string) => void;
23
+ info: (message: string) => void;
24
+ warn: (message: string) => void;
25
+ error: (message: string) => void;
26
+ };
27
+
28
+ // ── Types ──────────────────────────────────────────────────────────────────
29
+
30
+ export type Skill = {
31
+ id: string;
32
+ name: string;
33
+ content: string;
34
+ score?: number;
35
+ };
36
+
37
+ export type SkillRouterConfig = {
38
+ /** COS Vectors proxy endpoint, e.g. http://127.0.0.1:8080 */
39
+ proxyEndpoint: string;
40
+ /** COS Vectors bucket name */
41
+ vectorBucketName: string;
42
+ /** COS Vectors index name */
43
+ indexName: string;
44
+ /** Tencent Cloud SecretId */
45
+ secretId: string;
46
+ /** Tencent Cloud SecretKey */
47
+ secretKey: string;
48
+ /** Number of top-k skills to return per query */
49
+ topK: number;
50
+ /** Minimum similarity score threshold (0~1, cosine similarity) */
51
+ minScore: number;
52
+ /** OpenAI-compatible embedding model */
53
+ embeddingModel: string;
54
+ /** Base URL for embedding API */
55
+ embeddingBaseUrl: string;
56
+ /** API key for embedding service */
57
+ embeddingApiKey: string;
58
+ logger?: PluginLogger;
59
+ };
60
+
61
+ // ── In-memory skill cache (name → Skill) ──────────────────────────────────
62
+
63
+ type SkillCacheEntry = {
64
+ id: string;
65
+ name: string;
66
+ content: string;
67
+ };
68
+
69
+ // ── SkillRouter ────────────────────────────────────────────────────────────
70
+
71
+ export class SkillRouter {
72
+ private cfg: SkillRouterConfig;
73
+ private log: PluginLogger;
74
+ // Local cache: skill name → entry (for list/delete by name)
75
+ private skillCache = new Map<string, SkillCacheEntry>();
76
+
77
+ constructor(cfg: SkillRouterConfig) {
78
+ this.cfg = cfg;
79
+ this.log = cfg.logger ?? {
80
+ info: (m) => console.info(m),
81
+ warn: (m) => console.warn(m),
82
+ error: (m) => console.error(m),
83
+ };
84
+ }
85
+
86
+ // ── Public API ────────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Add or update a skill. Embeds the skill text and upserts into COS Vectors.
90
+ */
91
+ async addSkill(name: string, content: string): Promise<void> {
92
+ const id = this.nameToId(name);
93
+ const text = `${name}: ${content}`;
94
+ const embedding = await this.embed(text);
95
+
96
+ await this.putVectors([
97
+ {
98
+ key: id,
99
+ data: { float32: embedding },
100
+ metadata: { name, content },
101
+ },
102
+ ]);
103
+
104
+ this.skillCache.set(name, { id, name, content });
105
+ this.log.info(`[skill-router] addSkill: "${name}" (id=${id})`);
106
+ }
107
+
108
+ /**
109
+ * Remove a skill by name.
110
+ */
111
+ async removeSkill(name: string): Promise<void> {
112
+ const id = this.nameToId(name);
113
+ await this.deleteVectors([id]);
114
+ this.skillCache.delete(name);
115
+ this.log.info(`[skill-router] removeSkill: "${name}" (id=${id})`);
116
+ }
117
+
118
+ /**
119
+ * Query top-k skills most similar to the given query text.
120
+ */
121
+ async queryTopK(query: string, topK?: number): Promise<Skill[]> {
122
+ const k = topK ?? this.cfg.topK;
123
+ const embedding = await this.embed(query);
124
+ const results = await this.queryVectors(embedding, k);
125
+ return results.filter((s) => (s.score ?? 1) >= this.cfg.minScore);
126
+ }
127
+
128
+ /**
129
+ * List all skills from local cache (populated on add/remove).
130
+ * For a full server-side list, use listVectors API.
131
+ */
132
+ async listSkills(): Promise<Skill[]> {
133
+ return Array.from(this.skillCache.values()).map((e) => ({
134
+ id: e.id,
135
+ name: e.name,
136
+ content: e.content,
137
+ }));
138
+ }
139
+
140
+ // ── Embedding ─────────────────────────────────────────────────────────────
141
+
142
+ private async embed(text: string): Promise<number[]> {
143
+ const url = `${this.cfg.embeddingBaseUrl.replace(/\/$/, "")}/embeddings`;
144
+ const body = JSON.stringify({
145
+ model: this.cfg.embeddingModel,
146
+ input: text,
147
+ });
148
+
149
+ const resp = await fetch(url, {
150
+ method: "POST",
151
+ headers: {
152
+ "Content-Type": "application/json",
153
+ Authorization: `Bearer ${this.cfg.embeddingApiKey}`,
154
+ },
155
+ body,
156
+ });
157
+
158
+ if (!resp.ok) {
159
+ const errText = await resp.text().catch(() => "");
160
+ throw new Error(`[skill-router] embed failed: ${resp.status} ${errText}`);
161
+ }
162
+
163
+ const json = (await resp.json()) as {
164
+ data?: Array<{ embedding?: number[] }>;
165
+ };
166
+ const embedding = json.data?.[0]?.embedding;
167
+ if (!Array.isArray(embedding) || embedding.length === 0) {
168
+ throw new Error("[skill-router] embed: empty embedding returned");
169
+ }
170
+ return embedding;
171
+ }
172
+
173
+ // ── COS Vectors HTTP API ──────────────────────────────────────────────────
174
+
175
+ /**
176
+ * PUT (upsert) vectors into COS Vectors.
177
+ * POST /<endpoint>/PutVectors
178
+ */
179
+ private async putVectors(
180
+ vectors: Array<{
181
+ key: string;
182
+ data: { float32: number[] };
183
+ metadata?: Record<string, unknown>;
184
+ }>,
185
+ ): Promise<void> {
186
+ const body = JSON.stringify({
187
+ vectorBucketName: this.cfg.vectorBucketName,
188
+ indexName: this.cfg.indexName,
189
+ vectors: vectors.map((v) => ({
190
+ key: v.key,
191
+ data: { float32: v.data.float32.map((f) => f) },
192
+ metadata: v.metadata,
193
+ })),
194
+ });
195
+
196
+ await this.cosRequest("PutVectors", body);
197
+ }
198
+
199
+ /**
200
+ * Query top-k similar vectors from COS Vectors.
201
+ * POST /<endpoint>/QueryVectors
202
+ */
203
+ private async queryVectors(queryEmbedding: number[], topK: number): Promise<Skill[]> {
204
+ const body = JSON.stringify({
205
+ vectorBucketName: this.cfg.vectorBucketName,
206
+ indexName: this.cfg.indexName,
207
+ queryVector: { float32: queryEmbedding },
208
+ topK,
209
+ returnMetadata: true,
210
+ returnDistance: true,
211
+ });
212
+
213
+ const resp = await this.cosRequest("QueryVectors", body);
214
+ const json = resp as {
215
+ vectors?: Array<{
216
+ key: string;
217
+ distance?: number;
218
+ metadata?: { name?: string; content?: string };
219
+ }>;
220
+ };
221
+
222
+ if (!json.vectors) return [];
223
+
224
+ return json.vectors.map((v) => ({
225
+ id: v.key,
226
+ name: v.metadata?.name ?? v.key,
227
+ content: v.metadata?.content ?? "",
228
+ // COS Vectors returns L2/IP distance; convert to similarity score (0~1)
229
+ // For IP (inner product / cosine on normalized vectors): score = distance
230
+ // For L2: score = 1 / (1 + distance)
231
+ score: v.distance !== undefined ? this.distanceToScore(v.distance) : undefined,
232
+ }));
233
+ }
234
+
235
+ /**
236
+ * Delete vectors by key from COS Vectors.
237
+ * POST /<endpoint>/DeleteVectors
238
+ */
239
+ private async deleteVectors(keys: string[]): Promise<void> {
240
+ const body = JSON.stringify({
241
+ vectorBucketName: this.cfg.vectorBucketName,
242
+ indexName: this.cfg.indexName,
243
+ keys,
244
+ });
245
+
246
+ await this.cosRequest("DeleteVectors", body);
247
+ }
248
+
249
+ // ── COS Vectors request helper ────────────────────────────────────────────
250
+
251
+ private async cosRequest(method: string, body: string): Promise<unknown> {
252
+ const url = `${this.cfg.proxyEndpoint.replace(/\/$/, "")}/${method}`;
253
+ const date = new Date().toUTCString();
254
+
255
+ // Simple HMAC-SHA1 signature for COS-style auth
256
+ // Format: q-sign-algorithm=sha1&q-ak=<SecretId>&q-sign-time=<epoch>;<epoch+3600>
257
+ // For internal/dev use, we support both signed and unsigned modes.
258
+ const headers: Record<string, string> = {
259
+ "Content-Type": "application/json",
260
+ Date: date,
261
+ };
262
+
263
+ if (this.cfg.secretId && this.cfg.secretKey) {
264
+ const now = Math.floor(Date.now() / 1000);
265
+ const expires = now + 3600;
266
+ const signTime = `${now};${expires}`;
267
+ const signature = this.buildCosSignature(method, signTime, body);
268
+ headers["Authorization"] = [
269
+ "q-sign-algorithm=sha1",
270
+ `q-ak=${this.cfg.secretId}`,
271
+ `q-sign-time=${signTime}`,
272
+ `q-key-time=${signTime}`,
273
+ `q-header-list=content-type;date`,
274
+ `q-url-param-list=`,
275
+ `q-signature=${signature}`,
276
+ ].join("&");
277
+ }
278
+
279
+ const resp = await fetch(url, {
280
+ method: "POST",
281
+ headers,
282
+ body,
283
+ });
284
+
285
+ if (!resp.ok) {
286
+ const errText = await resp.text().catch(() => "");
287
+ throw new Error(`[skill-router] COS Vectors ${method} failed: ${resp.status} ${errText}`);
288
+ }
289
+
290
+ // PutVectors / DeleteVectors return empty body on success
291
+ const text = await resp.text();
292
+ if (!text.trim()) return {};
293
+ return JSON.parse(text);
294
+ }
295
+
296
+ /**
297
+ * Build COS HMAC-SHA1 signature.
298
+ * Simplified version for internal proxy usage.
299
+ */
300
+ private buildCosSignature(method: string, signTime: string, body: string): string {
301
+ // Step 1: SignKey = HMAC-SHA1(SecretKey, signTime)
302
+ const signKey = crypto.createHmac("sha1", this.cfg.secretKey).update(signTime).digest("hex");
303
+
304
+ // Step 2: HttpString = method\npath\nparams\nheaders\n
305
+ const path = `/${method}`;
306
+ const httpString = `post\n${path}\n\ncontent-type=application%2Fjson&date=${encodeURIComponent(new Date().toUTCString())}\n`;
307
+
308
+ // Step 3: StringToSign = sha1\nsignTime\nSHA1(HttpString)\n
309
+ const httpStrHash = crypto.createHash("sha1").update(httpString).digest("hex");
310
+ const stringToSign = `sha1\n${signTime}\n${httpStrHash}\n`;
311
+
312
+ // Step 4: Signature = HMAC-SHA1(SignKey, StringToSign)
313
+ return crypto.createHmac("sha1", signKey).update(stringToSign).digest("hex");
314
+ }
315
+
316
+ // ── Helpers ───────────────────────────────────────────────────────────────
317
+
318
+ /** Convert skill name to a stable vector key */
319
+ private nameToId(name: string): string {
320
+ return `skill-${name
321
+ .toLowerCase()
322
+ .replace(/[^a-z0-9]+/g, "-")
323
+ .replace(/^-|-$/g, "")}`;
324
+ }
325
+
326
+ /**
327
+ * Convert COS Vectors distance to a 0~1 similarity score.
328
+ * COS Vectors uses IP (inner product) distance for cosine similarity on normalized vectors.
329
+ * IP distance = 1 - cosine_similarity, so score = 1 - distance.
330
+ * For L2 distance: score = 1 / (1 + distance).
331
+ */
332
+ private distanceToScore(distance: number): number {
333
+ // Assume IP distance (cosine on normalized vectors): score = 1 - distance
334
+ // Clamp to [0, 1]
335
+ return Math.max(0, Math.min(1, 1 - distance));
336
+ }
337
+ }