@max-null/dsh-achievements 0.1.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,28 @@
1
+ # dsh-achievements 路 SSiD 全家桶成就插件
2
+
3
+ [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSS)全局进度成就系统:监听 session / tool 事件,把工具调用、记忆/模板库工具、上下文审计、token 用量、GenUI 渲染等行为折成成就进度,解锁时 toast + 设置页「成就」面板(全景:含 GenUI 类别的融合视图)。
4
+
5
+ ## 特性
6
+
7
+ - **18 个成就**、7 类别(启程 / 记忆 / 审计 / GenUI / 工具 / 行为 / 隐藏),稀有度覆盖 普通 → 传说;隐藏成就(如 夜猫子、自我指涉、亿万富翁)解锁前显示「??」
8
+ - **分层面板**:工具名、事件类型、token 计数、当天时刻等叶级标量——**绝不读取消息正文、文件内容或错误详情**
9
+ - **GenUI 融合**:client 读取 genui 插件自身的计数存储(`dsh.genui.achievements`),上报合并——全景页与 genui 成就页数据同源、互不重复计数
10
+ - **本地持久化**:`$DSH_HOME/achievements/state.json`,重启保留
11
+ - **HTTP 通道**:`/achievements/api`(list / recent / clear / genui-merge),信任围栏同 dsh-memory 的 `/memory/api`
12
+ - **模型工具**:`list_achievements`(只读进度查询)
13
+
14
+ ## 接入
15
+
16
+ ```bash
17
+ dsh plugin add @max-null/dsh-achievements
18
+ ```
19
+
20
+ 设置页 → 「成就」;首次解锁 toast 在页面右下角弹出。
21
+
22
+ ## 配置
23
+
24
+ 无(零配置;`$DSH_HOME` 缺省 `~/.dsh`)。
25
+
26
+ ## 设计纪律
27
+
28
+ 成就的意义在「激励探索」而非「统计监控」:所有计数只保留推理所需的叶级标量;卸载/清空(`clear`)随时可重置;深度内容(正文/文件)不在任何成就的判定路径上。
package/client.js ADDED
@@ -0,0 +1,252 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@max-null/dsh-achievements",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+
7
+ "use strict";
8
+ var __defProp = Object.defineProperty;
9
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
10
+ var __getOwnPropNames = Object.getOwnPropertyNames;
11
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
16
+ var __copyProps = (to, from, except, desc) => {
17
+ if (from && typeof from === "object" || typeof from === "function") {
18
+ for (let key of __getOwnPropNames(from))
19
+ if (!__hasOwnProp.call(to, key) && key !== except)
20
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
21
+ }
22
+ return to;
23
+ };
24
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
25
+
26
+ // src/client/index.tsx
27
+ var index_exports = {};
28
+ __export(index_exports, {
29
+ AchievementsView: () => AchievementsView,
30
+ apply: () => apply,
31
+ inject: () => inject,
32
+ mountToastLayer: () => mountToastLayer
33
+ });
34
+ module.exports = __toCommonJS(index_exports);
35
+ var import_react = require("react");
36
+ async function api(method, body) {
37
+ try {
38
+ const response = await fetch(`/achievements/api/${method}`, {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ body: JSON.stringify(body ?? {})
42
+ });
43
+ const payload = await response.json();
44
+ return payload.value ?? null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+ var STRINGS = {
50
+ zh: {
51
+ title: "\u6210\u5C31",
52
+ desc: "SSiD \u5168\u5BB6\u6876\u8FDB\u5EA6\uFF1A\u5DE5\u5177/\u8BB0\u5FC6/\u5BA1\u8BA1/GenUI \u4F7F\u7528\u6210\u5C31\uFF0C\u89E3\u9501\u53EF\u9886 toast\u3002",
53
+ refresh: "\u5237\u65B0",
54
+ locked: "\u672A\u89E3\u9501",
55
+ unlocked: "\u5DF2\u89E3\u9501"
56
+ },
57
+ en: {
58
+ title: "Achievements",
59
+ desc: "SSiD family progress: tool/memory/audit/GenUI usage trophies with unlock toasts.",
60
+ refresh: "Refresh",
61
+ locked: "Locked",
62
+ unlocked: "Unlocked"
63
+ }
64
+ };
65
+ var GENUI_LS_KEY = "dsh.genui.achievements";
66
+ function readGenUI() {
67
+ try {
68
+ const raw = localStorage.getItem(GENUI_LS_KEY);
69
+ if (raw === null) return { unlockedCount: 0, fences: 0 };
70
+ const parsed = JSON.parse(raw);
71
+ return {
72
+ unlockedCount: Object.keys(parsed.unlocked ?? {}).length,
73
+ fences: parsed.state?.fences ?? 0
74
+ };
75
+ } catch {
76
+ return { unlockedCount: 0, fences: 0 };
77
+ }
78
+ }
79
+ function lang() {
80
+ return typeof document !== "undefined" && (document.documentElement.lang || "zh").toLowerCase().startsWith("zh") ? "zh" : "en";
81
+ }
82
+ function AchievementsView(_props) {
83
+ const [snapshot, setSnapshot] = (0, import_react.useState)(null);
84
+ const [category, setCategory] = (0, import_react.useState)("\u5168\u90E8");
85
+ const [note, setNote] = (0, import_react.useState)("");
86
+ const t = STRINGS[lang()];
87
+ const reload = (0, import_react.useCallback)(async () => {
88
+ const genui = readGenUI();
89
+ await api("genui-merge", genui);
90
+ const data = await api("list");
91
+ if (data !== null) setSnapshot(data);
92
+ }, []);
93
+ (0, import_react.useEffect)(() => {
94
+ void reload();
95
+ const timer = window.setInterval(() => void reload(), 15e3);
96
+ return () => window.clearInterval(timer);
97
+ }, []);
98
+ const categories = snapshot === null ? ["\u5168\u90E8"] : ["\u5168\u90E8", ...new Set(snapshot.achievements.map((a) => a.category))];
99
+ const items = snapshot?.achievements.filter((a) => category === "\u5168\u90E8" || a.category === category) ?? [];
100
+ return (0, import_react.createElement)(
101
+ "div",
102
+ { className: "ach", "data-dsh-achievements-view": "" },
103
+ (0, import_react.createElement)(
104
+ "div",
105
+ { className: "achHead" },
106
+ (0, import_react.createElement)("span", { className: "achTitle" }, t.title),
107
+ (0, import_react.createElement)("span", { className: "achMeta" }, snapshot !== null ? `${snapshot.unlocked} / ${snapshot.total}` : "\u2026"),
108
+ (0, import_react.createElement)("button", {
109
+ type: "button",
110
+ className: "achBtn",
111
+ onClick: () => {
112
+ void reload();
113
+ setNote("\u5DF2\u5237\u65B0");
114
+ window.setTimeout(() => setNote(""), 1500);
115
+ }
116
+ }, t.refresh)
117
+ ),
118
+ (0, import_react.createElement)("div", { className: "achDesc" }, t.desc),
119
+ (0, import_react.createElement)(
120
+ "div",
121
+ { className: "achCats" },
122
+ categories.map((c) => (0, import_react.createElement)("button", {
123
+ key: c,
124
+ type: "button",
125
+ className: `achCat${category === c ? " achCatOn" : ""}`,
126
+ onClick: () => setCategory(c)
127
+ }, c))
128
+ ),
129
+ (0, import_react.createElement)(
130
+ "div",
131
+ { className: "achList" },
132
+ items.map((a) => (0, import_react.createElement)(
133
+ "div",
134
+ { key: a.id, className: `achRow${a.locked ? "" : " achRowOn"}` },
135
+ (0, import_react.createElement)("span", { className: "achIcon", "aria-hidden": "" }, a.icon),
136
+ (0, import_react.createElement)(
137
+ "div",
138
+ { className: "achBody" },
139
+ (0, import_react.createElement)(
140
+ "div",
141
+ { className: "achName" },
142
+ a.name,
143
+ (0, import_react.createElement)(
144
+ "span",
145
+ { className: `achRarity achRarity-${a.rarity}` },
146
+ a.rarity === "legendary" ? "\u4F20\u8BF4" : a.rarity === "epic" ? "\u53F2\u8BD7" : a.rarity === "rare" ? "\u7A00\u6709" : "\u666E\u901A"
147
+ )
148
+ ),
149
+ (0, import_react.createElement)("div", { className: "achDesc2" }, a.desc),
150
+ (0, import_react.createElement)(
151
+ "div",
152
+ { className: "achProgRow" },
153
+ (0, import_react.createElement)(
154
+ "div",
155
+ { className: "achProg" },
156
+ (0, import_react.createElement)("div", {
157
+ className: "achProgFill",
158
+ style: { width: `${Math.min(100, Math.round(a.progress.current / a.progress.target * 100))}%` }
159
+ })
160
+ ),
161
+ (0, import_react.createElement)("span", { className: "achProgNum" }, `${a.progress.current} / ${a.progress.target}`)
162
+ )
163
+ ),
164
+ (0, import_react.createElement)("span", { className: `achState${a.locked ? "" : " achStateOn"}` }, a.locked ? t.locked : t.unlocked)
165
+ ))
166
+ ),
167
+ note !== "" ? (0, import_react.createElement)("div", { className: "achNote" }, note) : null
168
+ );
169
+ }
170
+ function mountToastLayer() {
171
+ if (typeof document === "undefined") return () => {
172
+ };
173
+ const host = document.createElement("div");
174
+ host.dataset.dshAchievementsToast = "1";
175
+ document.body.appendChild(host);
176
+ const render = () => {
177
+ void api("recent").then((data) => {
178
+ if (data === null || data.length === 0) return;
179
+ host.textContent = "";
180
+ for (const u of data) {
181
+ const row = document.createElement("div");
182
+ row.className = "at-row";
183
+ row.textContent = `\u{1F3C6} \u6210\u5C31\u89E3\u9501\uFF1A${u.name}`;
184
+ host.appendChild(row);
185
+ }
186
+ window.setTimeout(() => {
187
+ host.textContent = "";
188
+ }, 4200);
189
+ });
190
+ };
191
+ const first = window.setTimeout(render, 2500);
192
+ const interval = window.setInterval(render, 8e3);
193
+ return () => {
194
+ window.clearTimeout(first);
195
+ window.clearInterval(interval);
196
+ host.remove();
197
+ };
198
+ }
199
+ var inject = ["slots"];
200
+ function apply(ctx) {
201
+ const disposers = [];
202
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
203
+ name: "settings.section",
204
+ id: "ssid-achievements",
205
+ order: 90,
206
+ label: () => STRINGS[lang()].title
207
+ }, () => (0, import_react.createElement)(AchievementsView, { visible: true })));
208
+ disposers.push(mountToastLayer());
209
+ return () => {
210
+ for (const dispose of disposers) dispose();
211
+ };
212
+ }
213
+ if (typeof document !== "undefined" && document.querySelector("style[data-dsh-achievements-css]") === null) {
214
+ const tag = document.createElement("style");
215
+ tag.dataset.dshAchievementsCss = "1";
216
+ tag.textContent = CSS;
217
+ document.head.appendChild(tag);
218
+ }
219
+ var CSS = [
220
+ ".ach{display:flex;flex-direction:column;gap:10px;padding:4px 0}",
221
+ ".achHead{display:flex;align-items:center;gap:10px}",
222
+ ".achTitle{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)}",
223
+ ".achMeta{font-size:13px;color:var(--dsw-alias-label-secondary,inherit)}",
224
+ ".achBtn{margin-left:auto;padding:4px 12px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.4));border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font-size:12px;cursor:pointer}",
225
+ ".achDesc{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,inherit)}",
226
+ ".achCats{display:flex;flex-wrap:wrap;gap:6px}",
227
+ ".achCat{padding:3px 10px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.4));border-radius:999px;background:transparent;color:var(--dsw-alias-label-secondary,inherit);font-size:12px;cursor:pointer}",
228
+ ".achCatOn{border-color:var(--dsw-alias-state-business-primary,#4f8ef7);color:var(--dsw-alias-state-business-primary,#4f8ef7)}",
229
+ ".achList{display:flex;flex-direction:column;gap:8px;max-height:420px;overflow:auto}",
230
+ ".achRow{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:12px}",
231
+ ".achRowOn{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary,#4f8ef7) 35%,transparent)}",
232
+ ".achIcon{font-size:20px;line-height:1}",
233
+ ".achBody{flex:1;min-width:0}",
234
+ ".achName{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)}",
235
+ ".achRarity{font-size:11px;padding:0 6px;border-radius:6px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#4f8ef7) 14%,transparent);color:var(--dsw-alias-state-business-primary,#4f8ef7)}",
236
+ ".achRarity-rare{background:color-mix(in srgb,#f59e0b 14%,transparent);color:#f59e0b}",
237
+ ".achRarity-epic{background:color-mix(in srgb,#a78bfa 14%,transparent);color:#a78bfa}",
238
+ ".achRarity-legendary{background:color-mix(in srgb,#f43f5e 14%,transparent);color:#f43f5e}",
239
+ ".achDesc2{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary,inherit)}",
240
+ ".achProgRow{display:flex;align-items:center;gap:8px;margin-top:4px}",
241
+ ".achProg{flex:1;height:4px;border-radius:999px;background:var(--dsw-alias-border-l1,rgba(127,127,127,.3));overflow:hidden}",
242
+ ".achProgFill{height:100%;background:var(--dsw-alias-state-business-primary,#4f8ef7)}",
243
+ ".achProgNum{font-size:11px;color:var(--dsw-alias-label-tertiary,inherit)}",
244
+ ".achState{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary,inherit)}",
245
+ ".achStateOn{color:var(--dsw-alias-state-success-primary,#6ccb5f)}",
246
+ ".achNote{font-size:12px;color:var(--dsw-alias-state-success-primary,#6ccb5f)}",
247
+ ".at-row{position:fixed;right:16px;bottom:16px;z-index:9999;width:300px;padding:10px 12px;border:1px solid var(--dsw-alias-state-business-primary,#4f8ef7);border-radius:12px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.85));color:var(--dsw-alias-state-business-primary,#4f8ef7);font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.25)}"
248
+ ].join("");
249
+ return module.exports;
250
+ },
251
+ });
252
+
@@ -0,0 +1,6 @@
1
+ # dsh-achievements bundle patch: one insert over the profile root.
2
+ # The engine observes session/tool events already provided by the host
3
+ # (dsh-base); this adds only the achievements plugin.
4
+ - insert:
5
+ - id: achievements
6
+ name: '@max-null/dsh-achievements'
@@ -0,0 +1,48 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type AchieveState } from './rules.ts';
3
+ export interface EngineSnapshot {
4
+ state: AchieveState;
5
+ distinct: Record<string, number>;
6
+ unlocked: Record<string, number>;
7
+ recent: Array<{
8
+ id: string;
9
+ name: string;
10
+ rarity: string;
11
+ icon: string;
12
+ }>;
13
+ }
14
+ /** 引擎(带事件源的宿主注入)。 */
15
+ export declare class AchievementsEngine {
16
+ private store;
17
+ private queue;
18
+ /** 回合中间状态(agent key → 工具计数)。 */
19
+ private turnTools;
20
+ get count(): AchieveState;
21
+ recordTool(name: string): void;
22
+ recordTurnStart(agentKey: string): void;
23
+ recordTurnTool(agentKey: string): void;
24
+ recordTurnEnd(agentKey: string): void;
25
+ recordSession(): void;
26
+ recordTurn(): void;
27
+ recordMidnight(): void;
28
+ recordTokens(amount: number): void;
29
+ /** GenUI 计数合并(client 写入;host 侧直接写计数键)。 */
30
+ mergeGenUI(unlockedCount: number, fences: number): void;
31
+ /** 检查解锁;返回新解锁(进队列 + 持久化;计数每次落盘)。 */
32
+ flush(): void;
33
+ snapshot(): EngineSnapshot;
34
+ drainRecent(): Array<{
35
+ id: string;
36
+ name: string;
37
+ rarity: string;
38
+ icon: string;
39
+ }>;
40
+ fullSnapshot(): Record<string, unknown>;
41
+ clear(): void;
42
+ }
43
+ /** 事件监听接线(0.1.1-rc.2 宿主事件面)。 */
44
+ export declare function attachListeners(ctx: Context, engine: AchievementsEngine): void;
45
+ /** HTTP 路由(/achievements/api,POST)——仿 dsh-memory 的 /memory/api 信任围栏。 */
46
+ export declare function registerRoutes(ctx: Context, engine: AchievementsEngine): void;
47
+ /** 工具:list_achievements(模型侧查询进度;只读)。 */
48
+ export declare function registerTool(ctx: Context, engine: AchievementsEngine): void;
package/dist/engine.js ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * engine.ts — 成就引擎:session/tool 事件监听 → 计数 → 解锁队列 →
3
+ * 持久化(`$DSH_HOME/achievements/state.json`)+ HTTP 路由 + 工具。
4
+ *
5
+ * 隐私纪律:只读叶级标量(工具名/成功标志/session 事件类型/token 数/
6
+ * 当天时刻),绝不读消息正文/文件内容/错误详情。
7
+ */
8
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { defineTool } from '@deepseek-ai/dsh-tools';
12
+ import { checkUnlocks, emptyState, snapshot } from "./rules.js";
13
+ const STATE_SCHEMA_VERSION = 1;
14
+ function stateDir() {
15
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'achievements');
16
+ }
17
+ class StateStore {
18
+ counters = emptyState();
19
+ distinct = {};
20
+ unlocked = {};
21
+ file;
22
+ constructor() {
23
+ this.file = join(stateDir(), 'state.json');
24
+ this.load();
25
+ }
26
+ load() {
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(this.file, 'utf8'));
29
+ const base = emptyState();
30
+ for (const [key, value] of Object.entries(parsed.counters ?? {})) {
31
+ if (typeof value === 'number')
32
+ this.counters[key] = (this.counters[key] ?? 0) + value;
33
+ else
34
+ this.counters[key] = base[key] ?? 0;
35
+ }
36
+ for (const [key, values] of Object.entries(parsed.distinct ?? {})) {
37
+ if (Array.isArray(values))
38
+ this.distinct[key] = new Set(values.filter(v => typeof v === 'string'));
39
+ }
40
+ for (const [id, at] of Object.entries(parsed.unlocked ?? {})) {
41
+ if (typeof at === 'number')
42
+ this.unlocked[id] = at;
43
+ }
44
+ }
45
+ catch {
46
+ this.counters = emptyState();
47
+ }
48
+ }
49
+ save() {
50
+ try {
51
+ mkdirSync(stateDir(), { recursive: true });
52
+ const data = {
53
+ schemaVersion: STATE_SCHEMA_VERSION,
54
+ counters: this.counters,
55
+ distinct: Object.fromEntries(Object.entries(this.distinct).map(([k, v]) => [k, [...v]])),
56
+ unlocked: this.unlocked,
57
+ };
58
+ writeFileSync(this.file, JSON.stringify(data, null, 2));
59
+ }
60
+ catch {
61
+ // 持久化失败不致命:本页会话内存状态继续工作。
62
+ }
63
+ }
64
+ /** 清零并立即落盘(clear 语义:不重读旧档)。 */
65
+ reset() {
66
+ this.counters = emptyState();
67
+ this.distinct = {};
68
+ this.unlocked = {};
69
+ this.save();
70
+ }
71
+ bump(key, by = 1) {
72
+ this.counters[key] = (this.counters[key] ?? 0) + by;
73
+ }
74
+ addDistinct(key, value) {
75
+ let set = this.distinct[key];
76
+ if (set === undefined) {
77
+ set = new Set();
78
+ this.distinct[key] = set;
79
+ }
80
+ if (!set.has(value)) {
81
+ set.add(value);
82
+ this.save();
83
+ }
84
+ }
85
+ get count() { return { ...this.counters }; }
86
+ get distinctCounts() {
87
+ const out = {};
88
+ for (const [key, set] of Object.entries(this.distinct))
89
+ out[key] = set.size;
90
+ return out;
91
+ }
92
+ get unlockMap() { return { ...this.unlocked }; }
93
+ setUnlocked(id, at) { this.unlocked[id] = at; }
94
+ totalDistinct(key) { return this.distinct[key]?.size ?? 0; }
95
+ }
96
+ /** family 指纹:记忆类工具(dsh-memory / prompt 模板库)。 */
97
+ const MEMORY_TOOLS = new Set([
98
+ 'memory_save', 'memory_list', 'memory_search', 'memory_confirm', 'memory_forget', 'memory_update',
99
+ 'prompt_search', 'prompt_get', 'prompt_list', 'prompt_add',
100
+ ]);
101
+ const CONFIRM_TOOLS = new Set(['memory_confirm']);
102
+ const TEMPLATE_TOOLS = new Set(['prompt_search', 'prompt_get', 'prompt_list', 'prompt_add']);
103
+ const AUDIT_TOOLS = new Set(['context_audit']);
104
+ /** 引擎(带事件源的宿主注入)。 */
105
+ export class AchievementsEngine {
106
+ store = new StateStore();
107
+ queue = [];
108
+ /** 回合中间状态(agent key → 工具计数)。 */
109
+ turnTools = new Map();
110
+ get count() { return this.store.count; }
111
+ recordTool(name) {
112
+ this.store.bump('tools');
113
+ this.store.addDistinct('toolsUsed', name);
114
+ if (MEMORY_TOOLS.has(name))
115
+ this.store.bump('memoryTools');
116
+ if (CONFIRM_TOOLS.has(name))
117
+ this.store.bump('memoryConfirmed');
118
+ if (TEMPLATE_TOOLS.has(name))
119
+ this.store.bump('templateTools');
120
+ if (AUDIT_TOOLS.has(name))
121
+ this.store.bump('audits');
122
+ if (name === 'list_achievements')
123
+ this.store.bump('selfQueries');
124
+ }
125
+ recordTurnStart(agentKey) {
126
+ this.turnTools.set(agentKey, 0);
127
+ }
128
+ recordTurnTool(agentKey) {
129
+ const current = (this.turnTools.get(agentKey) ?? 0) + 1;
130
+ this.turnTools.set(agentKey, current);
131
+ if (current >= 10)
132
+ this.store.bump('marathonTurns'); // 单回合 10 次工具(一次性语义靠解锁幂等)
133
+ }
134
+ recordTurnEnd(agentKey) {
135
+ this.turnTools.delete(agentKey);
136
+ }
137
+ recordSession() { this.store.bump('sessions'); }
138
+ recordTurn() { this.store.bump('turns'); }
139
+ recordMidnight() { this.store.bump('midnightTurns'); }
140
+ recordTokens(amount) { if (amount > 0)
141
+ this.store.bump('tokens', amount); }
142
+ /** GenUI 计数合并(client 写入;host 侧直接写计数键)。 */
143
+ mergeGenUI(unlockedCount, fences) {
144
+ this.store.bump('genuiUnlocked', Math.max(0, unlockedCount));
145
+ this.store.bump('genuiFences', Math.max(0, fences));
146
+ }
147
+ /** 检查解锁;返回新解锁(进队列 + 持久化;计数每次落盘)。 */
148
+ flush() {
149
+ const state = this.count;
150
+ const fresh = checkUnlocks(state, this.store.unlockMap);
151
+ for (const achievement of fresh) {
152
+ const at = Date.now();
153
+ this.store.setUnlocked(achievement.id, at);
154
+ this.queue.push({ id: achievement.id, name: achievement.name, rarity: achievement.rarity, icon: achievement.icon });
155
+ }
156
+ this.store.save();
157
+ }
158
+ snapshot() {
159
+ return {
160
+ state: this.count,
161
+ distinct: this.store.distinctCounts,
162
+ unlocked: this.store.unlockMap,
163
+ recent: [...this.queue].slice(-5),
164
+ };
165
+ }
166
+ drainRecent() {
167
+ const queue = this.queue;
168
+ this.queue = [];
169
+ return queue;
170
+ }
171
+ fullSnapshot() {
172
+ return {
173
+ ...snapshot(this.count, this.store.unlockMap),
174
+ recent: this.snapshot().recent,
175
+ distinct: this.store.distinctCounts,
176
+ };
177
+ }
178
+ clear() {
179
+ this.store.reset();
180
+ this.queue = [];
181
+ }
182
+ }
183
+ /** 事件监听接线(0.1.1-rc.2 宿主事件面)。 */
184
+ export function attachListeners(ctx, engine) {
185
+ // tools/result:工具执行完成(冻结的最终结果)。
186
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
187
+ ctx.on('tools/result', ((exec, result) => {
188
+ const name = typeof exec?.name === 'string' ? exec.name : '';
189
+ if (name === '')
190
+ return;
191
+ engine.recordTool(name);
192
+ engine.recordTurnTool(agentKeyOf(exec));
193
+ if (result?.isError !== true) {
194
+ // 成功即可;失败也计入 tools(recordTool 已计)
195
+ }
196
+ engine.flush();
197
+ }));
198
+ // session/event:usage / header / user 消息 / 会话首事件(叶级标量)。
199
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
200
+ ctx.on('session/event', ((session, event) => {
201
+ if (event?.type === 'assistant/message') {
202
+ const usage = event.data?.usage;
203
+ if (usage !== undefined && typeof usage === 'object') {
204
+ let total = 0;
205
+ for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
206
+ const value = usage[key];
207
+ if (typeof value === 'number')
208
+ total += value;
209
+ }
210
+ engine.recordTokens(total);
211
+ engine.flush();
212
+ }
213
+ return;
214
+ }
215
+ if (event?.type === 'user/message') {
216
+ const hour = new Date().getHours();
217
+ if (hour >= 0 && hour < 5)
218
+ engine.recordMidnight();
219
+ engine.flush();
220
+ return;
221
+ }
222
+ if (event?.type === 'session/start') {
223
+ engine.recordSession();
224
+ engine.recordTurn();
225
+ engine.flush();
226
+ }
227
+ }));
228
+ }
229
+ function agentKeyOf(exec) {
230
+ const id = exec?.agent?.id;
231
+ return typeof id === 'string' ? id : 'root';
232
+ }
233
+ /** HTTP 路由(/achievements/api,POST)——仿 dsh-memory 的 /memory/api 信任围栏。 */
234
+ export function registerRoutes(ctx, engine) {
235
+ const webServer = ctx.webServer;
236
+ const webRuntime = ctx.webRuntime;
237
+ ctx.effect(() => webServer.register({
238
+ kind: 'prefix',
239
+ path: '/achievements/api',
240
+ handler: async (req, res) => {
241
+ const request = req;
242
+ if (!isTrusted(request, webRuntime.trustedHosts)) {
243
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
244
+ return;
245
+ }
246
+ if (request.method !== 'POST') {
247
+ writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } });
248
+ return;
249
+ }
250
+ const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname;
251
+ const method = pathname.startsWith('/achievements/api/') ? pathname.slice('/achievements/api/'.length) : undefined;
252
+ if (method === undefined || method.includes('/')) {
253
+ writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown achievement API method' } });
254
+ return;
255
+ }
256
+ try {
257
+ switch (method) {
258
+ case 'list':
259
+ writeJson(res, 200, { ok: true, value: engine.fullSnapshot() });
260
+ return;
261
+ case 'recent':
262
+ writeJson(res, 200, { ok: true, value: engine.drainRecent() });
263
+ return;
264
+ case 'clear':
265
+ engine.clear();
266
+ writeJson(res, 200, { ok: true, value: engine.fullSnapshot() });
267
+ return;
268
+ case 'genui-merge': {
269
+ const body = (await readJsonBody(req));
270
+ engine.mergeGenUI(typeof body.unlockedCount === 'number' ? body.unlockedCount : 0, typeof body.fences === 'number' ? body.fences : 0);
271
+ engine.flush();
272
+ writeJson(res, 200, { ok: true, value: engine.fullSnapshot() });
273
+ return;
274
+ }
275
+ default:
276
+ writeJson(res, 404, { ok: false, error: { code: 'not-found', message: `unknown method "${method}"` } });
277
+ }
278
+ }
279
+ catch (error) {
280
+ writeJson(res, 500, { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error) } });
281
+ }
282
+ },
283
+ }), '@max-null/dsh-achievements: /achievements/api routes');
284
+ }
285
+ const MAX_BODY_BYTES = 1 << 20;
286
+ async function readJsonBody(req) {
287
+ const chunks = [];
288
+ let total = 0;
289
+ for await (const chunk of req) {
290
+ const buffer = Buffer.from(chunk);
291
+ total += buffer.length;
292
+ if (total > MAX_BODY_BYTES)
293
+ throw new Error('request body too large');
294
+ chunks.push(buffer);
295
+ }
296
+ try {
297
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
298
+ }
299
+ catch {
300
+ return {};
301
+ }
302
+ }
303
+ /** 工具:list_achievements(模型侧查询进度;只读)。 */
304
+ export function registerTool(ctx, engine) {
305
+ ctx.tools.register(defineTool({
306
+ name: 'list_achievements',
307
+ description: '查询 SSiD 成就系统状态:已解锁成就、总成就数、最近解锁与各项进度。只读,不影响会话。',
308
+ parameters: {},
309
+ output: {
310
+ schema: { type: 'json' },
311
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
312
+ },
313
+ execute: (_args) => Promise.resolve(engine.fullSnapshot()),
314
+ }));
315
+ }
316
+ /* ---- 路由辅助(与 dsh-memory routes.ts 同款) ---- */
317
+ function writeJson(res, status, body) {
318
+ const r = res;
319
+ try {
320
+ r.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
321
+ r.end(JSON.stringify(body));
322
+ }
323
+ catch {
324
+ // 流已关闭/非法响应:忽略
325
+ }
326
+ }
327
+ /** loopback / trustedHosts 围栏(与 /memory/api 一致)。 */
328
+ function isTrusted(request, trustedHosts) {
329
+ const host = request.headers['host'];
330
+ const hostValue = typeof host === 'string' ? host : '';
331
+ const authority = typeof request.headers[':authority'] === 'string' ? request.headers[':authority'] : hostValue;
332
+ if (authority !== '') {
333
+ try {
334
+ const u = new URL(`http://${authority}`);
335
+ if (u.hostname === 'localhost' || u.hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(u.hostname))
336
+ return true;
337
+ if (trustedHosts.includes(u.hostname))
338
+ return true;
339
+ }
340
+ catch { /* fall through */ }
341
+ return false;
342
+ }
343
+ return trustedHosts.length > 0 ? trustedHosts.includes('*') : true;
344
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @max-null/dsh-achievements host half: the achievements engine for the SSiD
3
+ * family. Observes the session/tool event plane, folds leaf scalars into
4
+ * durable counters, unlocks trophies (toast queue + HTTP + tool view), and
5
+ * serves /achievements/api to the browser half. Privacy: counters only —
6
+ * tool names, event kinds, token counts; message/file/error content is never
7
+ * read. The GenUI category is merged by the client reading the genui
8
+ * plugin's own localStorage counters.
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis';
11
+ export declare const name = "@max-null/dsh-achievements";
12
+ export declare const inject: string[];
13
+ export declare function apply(ctx: Context): void;
14
+ export { AchievementsEngine } from './engine.ts';
15
+ export { ACHIEVEMENTS, checkUnlocks, emptyState, snapshot, viewOf } from './rules.ts';
16
+ export type { AchieveState, AchievementDef, Category, Rarity } from './rules.ts';
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ import { AchievementsEngine, attachListeners, registerRoutes, registerTool } from "./engine.js";
2
+ export const name = '@max-null/dsh-achievements';
3
+ export const inject = ['tools', 'webServer', 'webRuntime'];
4
+ export function apply(ctx) {
5
+ const engine = new AchievementsEngine();
6
+ attachListeners(ctx, engine);
7
+ registerRoutes(ctx, engine);
8
+ registerTool(ctx, engine);
9
+ ctx.on('agent/session-start', (() => {
10
+ engine.recordSession();
11
+ engine.flush();
12
+ }));
13
+ ctx.on('agent/turn-stopping', (() => {
14
+ engine.recordTurn();
15
+ engine.flush();
16
+ }));
17
+ }
18
+ export { AchievementsEngine } from "./engine.js";
19
+ export { ACHIEVEMENTS, checkUnlocks, emptyState, snapshot, viewOf } from "./rules.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * rules.ts — SSiD 全家桶成就定义(纯数据/纯函数,零依赖可测)。
3
+ *
4
+ * 计数语义只含「工具名/事件类型/时间」级别的标量:工具调用名与成功与否、
5
+ * 会话/回合计数、usage token 数(叶级标量)、当天时刻。绝不读取消息正文、
6
+ * 文件内容或错误详情。GenUI 类别由 client 侧读取 genui 插件的计数
7
+ * localStorage 合并(见 client/index.tsx)。
8
+ */
9
+ export type Category = '启程' | '记忆' | '审计' | 'GenUI' | '工具' | '行为' | '隐藏';
10
+ export type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
11
+ export interface AchievementDef {
12
+ id: string;
13
+ name: string;
14
+ desc: string;
15
+ icon: string;
16
+ category: Category;
17
+ rarity: Rarity;
18
+ hidden?: boolean;
19
+ /** 阈值规则:计数器键 → 目标值。 */
20
+ threshold: {
21
+ counter: string;
22
+ target: number;
23
+ };
24
+ }
25
+ /** 计数键(leaf scalars only)。 */
26
+ export declare const KEYS: {
27
+ readonly sessions: "sessions";
28
+ readonly turns: "turns";
29
+ readonly tools: "tools";
30
+ readonly toolsUsed: "toolsUsed";
31
+ readonly memoryTools: "memoryTools";
32
+ readonly memorySaved: "memorySaved";
33
+ readonly memoryConfirmed: "memoryConfirmed";
34
+ readonly templateTools: "templateTools";
35
+ readonly audits: "audits";
36
+ readonly genuiUnlocked: "genuiUnlocked";
37
+ readonly genuiFences: "genuiFences";
38
+ readonly marathonTurns: "marathonTurns";
39
+ readonly midnightTurns: "midnightTurns";
40
+ readonly selfQueries: "selfQueries";
41
+ readonly tokens: "tokens";
42
+ };
43
+ /** SSiD 全家桶成就表。 */
44
+ export declare const ACHIEVEMENTS: readonly AchievementDef[];
45
+ /** 成就状态数据(纯标量)。 */
46
+ export interface AchieveState {
47
+ [index: string]: number;
48
+ }
49
+ export declare function emptyState(): AchieveState;
50
+ /** 检查新解锁(不看 hidden——hidden 仅影响展示)。 */
51
+ export declare function checkUnlocks(state: AchieveState, unlocked: Record<string, number>): AchievementDef[];
52
+ /** 视图行(client 渲染用)。 */
53
+ export declare function viewOf(achievement: AchievementDef, state: AchieveState, unlocked: Record<string, number>): Record<string, unknown>;
54
+ /** 快照(HTTP / 工具共用)。 */
55
+ export declare function snapshot(state: AchieveState, unlocked: Record<string, number>): Record<string, unknown>;
package/dist/rules.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * rules.ts — SSiD 全家桶成就定义(纯数据/纯函数,零依赖可测)。
3
+ *
4
+ * 计数语义只含「工具名/事件类型/时间」级别的标量:工具调用名与成功与否、
5
+ * 会话/回合计数、usage token 数(叶级标量)、当天时刻。绝不读取消息正文、
6
+ * 文件内容或错误详情。GenUI 类别由 client 侧读取 genui 插件的计数
7
+ * localStorage 合并(见 client/index.tsx)。
8
+ */
9
+ /** 计数键(leaf scalars only)。 */
10
+ export const KEYS = {
11
+ sessions: 'sessions',
12
+ turns: 'turns',
13
+ tools: 'tools',
14
+ toolsUsed: 'toolsUsed',
15
+ memoryTools: 'memoryTools',
16
+ memorySaved: 'memorySaved',
17
+ memoryConfirmed: 'memoryConfirmed',
18
+ templateTools: 'templateTools',
19
+ audits: 'audits',
20
+ genuiUnlocked: 'genuiUnlocked',
21
+ genuiFences: 'genuiFences',
22
+ marathonTurns: 'marathonTurns',
23
+ midnightTurns: 'midnightTurns',
24
+ selfQueries: 'selfQueries',
25
+ tokens: 'tokens',
26
+ };
27
+ /** SSiD 全家桶成就表。 */
28
+ export const ACHIEVEMENTS = [
29
+ // ── 启程 ──
30
+ { id: 'first-session', name: '启程', desc: '第一个会话开跑。', icon: '🚀', category: '启程', rarity: 'common', threshold: { counter: KEYS.sessions, target: 1 } },
31
+ { id: 'first-turn', name: '初试身手', desc: '完成第一轮对话。', icon: '👋', category: '启程', rarity: 'common', threshold: { counter: KEYS.turns, target: 1 } },
32
+ { id: 'first-tool', name: '工具初体验', desc: '第一次调用工具。', icon: '🛠️', category: '启程', rarity: 'common', threshold: { counter: KEYS.tools, target: 1 } },
33
+ // ── 记忆 ──
34
+ { id: 'memory-first-save', name: '落笔成忆', desc: '第一次使用记忆工具(memory_*)。', icon: '📝', category: '记忆', rarity: 'common', threshold: { counter: KEYS.memoryTools, target: 1 } },
35
+ { id: 'memory-10', name: '记忆织工', desc: '累计 10 次记忆工具调用。', icon: '🧵', category: '记忆', rarity: 'rare', threshold: { counter: KEYS.memoryTools, target: 10 } },
36
+ { id: 'memory-first-confirm', name: '审核人', desc: '第一次人工确认(memory_confirm)。', icon: '✅', category: '记忆', rarity: 'rare', threshold: { counter: KEYS.memoryConfirmed, target: 1 } },
37
+ { id: 'template-user', name: '模板常客', desc: '第一次使用模板库工具(prompt_*)。', icon: '🗂️', category: '记忆', rarity: 'rare', threshold: { counter: KEYS.templateTools, target: 1 } },
38
+ // ── 审计 ──
39
+ { id: 'audit-first', name: '上下文审计官', desc: '第一次跑 context_audit。', icon: '🔍', category: '审计', rarity: 'rare', threshold: { counter: KEYS.audits, target: 1 } },
40
+ { id: 'audit-10', name: '注审老手', desc: '累计 10 次上下文审计。', icon: '🕵️', category: '审计', rarity: 'epic', threshold: { counter: KEYS.audits, target: 10 } },
41
+ // ── GenUI(client 侧合并 genui 计数)──
42
+ { id: 'genui-first', name: '第一块面板', desc: 'GenUI 渲染了第一块界面。', icon: '🎨', category: 'GenUI', rarity: 'common', threshold: { counter: KEYS.genuiUnlocked, target: 1 } },
43
+ { id: 'genui-5', name: '界面收藏家', desc: 'GenUI 解锁 5 个成就。', icon: '🖼️', category: 'GenUI', rarity: 'rare', threshold: { counter: KEYS.genuiUnlocked, target: 5 } },
44
+ { id: 'genui-25', name: '界面编织者', desc: 'GenUI 渲染过 25 个界面。', icon: '🧶', category: 'GenUI', rarity: 'epic', threshold: { counter: KEYS.genuiFences, target: 25 } },
45
+ // ── 工具 ──
46
+ { id: 'tool-10', name: '工具新手', desc: '累计调用 10 次工具。', icon: '🔧', category: '工具', rarity: 'common', threshold: { counter: KEYS.tools, target: 10 } },
47
+ { id: 'tool-50', name: '工具达人', desc: '累计调用 50 次工具。', icon: '⚙️', category: '工具', rarity: 'rare', threshold: { counter: KEYS.tools, target: 50 } },
48
+ { id: 'five-tools', name: '多面手', desc: '使用过 5 种不同工具。', icon: '🧰', category: '工具', rarity: 'rare', threshold: { counter: KEYS.toolsUsed, target: 5 } },
49
+ // ── 行为 ──
50
+ { id: 'marathon', name: '马拉松', desc: '单回合内调用 10 次工具。', icon: '🏃', category: '行为', rarity: 'rare', hidden: true, threshold: { counter: KEYS.marathonTurns, target: 1 } },
51
+ { id: 'night-owl', name: '夜猫子', desc: '在凌晨 0-5 点发过消息。', icon: '🦉', category: '行为', rarity: 'rare', hidden: true, threshold: { counter: KEYS.midnightTurns, target: 1 } },
52
+ // ── 隐藏 ──
53
+ { id: 'self-ref', name: '自我指涉', desc: '用成就工具查询自己。', icon: '🪞', category: '隐藏', rarity: 'rare', hidden: true, threshold: { counter: KEYS.selfQueries, target: 1 } },
54
+ { id: 'billionaire', name: '亿万富翁', desc: '累计消耗一亿 token。', icon: '💰', category: '隐藏', rarity: 'legendary', hidden: true, threshold: { counter: KEYS.tokens, target: 100000000 } },
55
+ ];
56
+ export function emptyState() {
57
+ const state = {};
58
+ for (const achievement of ACHIEVEMENTS)
59
+ state[achievement.threshold.counter] = 0;
60
+ return state;
61
+ }
62
+ /** 检查新解锁(不看 hidden——hidden 仅影响展示)。 */
63
+ export function checkUnlocks(state, unlocked) {
64
+ return ACHIEVEMENTS.filter(achievement => unlocked[achievement.id] === undefined
65
+ && (state[achievement.threshold.counter] ?? 0) >= achievement.threshold.target);
66
+ }
67
+ /** 视图行(client 渲染用)。 */
68
+ export function viewOf(achievement, state, unlocked) {
69
+ const current = Math.min(state[achievement.threshold.counter] ?? 0, achievement.threshold.target);
70
+ return {
71
+ id: achievement.id,
72
+ name: achievement.hidden && unlocked[achievement.id] === undefined ? '??' : achievement.name,
73
+ desc: achievement.hidden && unlocked[achievement.id] === undefined ? '继续探索以揭示' : achievement.desc,
74
+ icon: achievement.icon,
75
+ category: achievement.category,
76
+ rarity: achievement.rarity,
77
+ hidden: achievement.hidden === true,
78
+ unlocked: unlocked[achievement.id] !== undefined,
79
+ progress: { current, target: achievement.threshold.target },
80
+ };
81
+ }
82
+ /** 快照(HTTP / 工具共用)。 */
83
+ export function snapshot(state, unlocked) {
84
+ return {
85
+ total: ACHIEVEMENTS.length,
86
+ unlocked: Object.keys(unlocked).length,
87
+ achievements: ACHIEVEMENTS.map(achievement => viewOf(achievement, state, unlocked)),
88
+ };
89
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@max-null/dsh-achievements",
3
+ "version": "0.1.0",
4
+ "description": "SSiD family progress achievements — listens to session/tool events, folds activity into durable counters, unlocks trophies with toasts and an achievements page; the GenUI category merges the genui plugin's own counter data.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "main": "dist/index.js",
11
+ "types": "dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./client": "./client.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "client.js",
23
+ "cordis.patch.yml",
24
+ "README.md"
25
+ ],
26
+ "dsh": {
27
+ "bundle": {
28
+ "patch": "./cordis.patch.yml"
29
+ },
30
+ "client": {
31
+ "inject": [
32
+ "@deepseek-ai/dsh-client-runtime",
33
+ "@deepseek-ai/dsh-client-locale"
34
+ ],
35
+ "platform": "web"
36
+ }
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.json && node build-client.mjs",
40
+ "typecheck": "tsc --noEmit -p tsconfig.json",
41
+ "test": "vitest run",
42
+ "prepublishOnly": "npm run build"
43
+ },
44
+ "peerDependencies": {
45
+ "@deepseek-ai/cordis": "^4.0.1",
46
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.1",
47
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.1"
48
+ },
49
+ "devDependencies": {
50
+ "@deepseek-ai/cordis": "^4.0.1",
51
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
52
+ "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.1",
53
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.1",
54
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.1",
55
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.1",
56
+ "@types/node": "^26.2.0",
57
+ "esbuild": "^0.25.0",
58
+ "react": "^18.3.1",
59
+ "tsx": "^4.0.0",
60
+ "typescript": "^5.5.0",
61
+ "vitest": "^2.1.0"
62
+ }
63
+ }