@lmzhen/dsh-evolution-skill-history 0.0.0 → 0.11.1
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 +51 -0
- package/lib/client.js +340 -0
- package/lib/index.js +269 -0
- package/lib/types/client/Panel.d.ts +25 -0
- package/lib/types/client/api.d.ts +51 -0
- package/lib/types/client/index.d.ts +11 -0
- package/lib/types/client/messages.d.ts +21 -0
- package/lib/types/client/seam.d.ts +46 -0
- package/lib/types/index.d.ts +21 -0
- package/lib/types/routes.d.ts +66 -0
- package/package.json +56 -3
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @lmzhen/dsh-evolution-skill-history
|
|
2
|
+
|
|
3
|
+
The web surface for skill content history: a left-sidebar panel row (**Skill history**) that lists the versions every skill write records, and the four loopback host routes it reads.
|
|
4
|
+
|
|
5
|
+
The **facts** belong to `@lmzhen/dsh-evolution-core` (which artifact a version holds, how the body chain and the support-file chain split) and the **only write** is `curator.undo`. This package maps a route to that seam and draws it; it owns no state of its own.
|
|
6
|
+
|
|
7
|
+
## Where it lives
|
|
8
|
+
|
|
9
|
+
Two registrations, one id (`skill-history`), because that is the sidebar's contract: `sidebar.panellist` owns the button and the layout's keyed `main` slot owns the body, dispatched by the same id.
|
|
10
|
+
|
|
11
|
+
ctx.slots.inject('sidebar.panellist', function* () {
|
|
12
|
+
yield ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 35, label: () => t('entry.label') }, PanelIcon)
|
|
13
|
+
yield ctx.slots.register({ name: 'main', key: PANEL_ID, inject: () => ({ t, loadSkills, loadVersions, undo }) }, SkillHistoryPanel)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
The label is a **thunk**: the sidebar re-reads it on every projection, so a language switch follows without re-registering the row. The body receives plain callbacks through its inject face — no service handle, no subscription, and the component never sees the context.
|
|
17
|
+
|
|
18
|
+
## The host routes
|
|
19
|
+
|
|
20
|
+
| Method | Path | Use case |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| GET | `/api/dsh-evolution/skill-history/skills` | the skills that recorded at least one version, with their counts |
|
|
23
|
+
| GET | `/api/dsh-evolution/skill-history/versions?name=` | the body chain and the support-file chain, split, each body row carrying its own `undoable` verdict |
|
|
24
|
+
| POST | `/api/dsh-evolution/skill-history/undo` | `curator.undo(name, v?)` — the only write |
|
|
25
|
+
| GET | `/api/dsh-evolution/skill-history/health` | liveness probe for the client |
|
|
26
|
+
|
|
27
|
+
Every route sits behind the platform's own /api fence, kept local because the canonical implementation is not part of that package's published surface: the **socket** must be loopback (authoritative; `X-Forwarded-For` is never trusted), the **Host** header must name a loopback authority, an explicit `sec-fetch-site: cross-site` marker is refused, and an attached **Origin** must be exactly this authority.
|
|
28
|
+
|
|
29
|
+
Transport refusals stay transport refusals (`403` fence, `405` method, `400` missing name / malformed or oversized body). A **business** refusal is a `200` with `{ ok: false, code, message }`, and that message is the curator's own sentence — the same one `/evolution skill undo` prints, so the two faces cannot drift.
|
|
30
|
+
|
|
31
|
+
The row is **inert without a web server** (`ctx.get('webServer')`, not a declared inject): a headless profile keeps the slash commands as its entry point, and no fiber waits forever on a service that will never arrive. The curator is resolved **per request**, so a profile that mounts the routes before it answers the family's `E-302` sentence instead of throwing.
|
|
32
|
+
|
|
33
|
+
## What the panel shows
|
|
34
|
+
|
|
35
|
+
The selected skill's versions as two groups: **Body versions** (restorable) and **File versions** (history only, because a restore rewrites `SKILL.md`). A body row offers **Restore this version** unless it already IS the live content (the host marks those), and the button turns into an inline confirmation first. After a restore the list is read again, because the restore itself is a new version.
|
|
36
|
+
|
|
37
|
+
## Build
|
|
38
|
+
|
|
39
|
+
The host half builds with the family's normal `packages/evolution/scripts/build-lib.mjs`. The browser half builds with `build-client.mjs`: discovery is automatic for a package that declares `dsh.client` and ships `src/client/index.ts`, and the artifact is the module loader's lazy CJS factory (`lib/client.js`).
|
|
40
|
+
|
|
41
|
+
## Model Experience
|
|
42
|
+
|
|
43
|
+
This package adds **no model-visible content**: no prompt section, no tool schema, no tool result text. It therefore has no token cost and no KV-cache effect. The panel is a human surface and its routes are loopback-only.
|
|
44
|
+
|
|
45
|
+
## Known limitations and deferred work
|
|
46
|
+
|
|
47
|
+
- **No rendered spec.** The family's specs are Node-level and this package's client half cannot be rendered there, so the panel is covered by `tsc`, the client bundle build, the pure `tests/client-api.spec.ts` and a live pass on the installed artifact. A rendered spec (jsdom plus a driven fixture runtime) is the follow-up that would catch wiring in CI.
|
|
48
|
+
- **The list shows skills with recorded versions only.** A skill that never wrote through the library has no history to browse, so it does not appear; the tree-wide listing stays with the skill catalog.
|
|
49
|
+
- **No diff view.** The routes return entries, not bodies; a two-version diff would need a second read route and a client-side differ.
|
|
50
|
+
- **Support-file bytes are history only.** They share the index with the body, so they are listed and labelled, but `undo` refuses them by name — the bytes stay in the blob store for a hand copy.
|
|
51
|
+
- **No occupancy row.** `.history` growth is documented in the evolution-core README; showing the size in the panel would promise a cleanup path the family has not designed (a whole-library reference count belongs to the curator).
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@lmzhen/dsh-evolution-skill-history",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
//#region src/client/api.ts
|
|
9
|
+
/**
|
|
10
|
+
* The host routes this bundle calls, and the ONE place a request is made.
|
|
11
|
+
*
|
|
12
|
+
* The paths mirror the host half's `SKILL_HISTORY_ROUTES` (a spec asserts the two sides agree). Every
|
|
13
|
+
* refusal arrives as `ok:false` plus the curator's own sentence, so the panel shows the same words the
|
|
14
|
+
* slash command prints instead of inventing a second vocabulary.
|
|
15
|
+
* @module @lmzhen/dsh-evolution-skill-history/client
|
|
16
|
+
*/
|
|
17
|
+
/** Route paths, mirrored from the host half. */
|
|
18
|
+
const HOST_ROUTES = {
|
|
19
|
+
skills: "/api/dsh-evolution/skill-history/skills",
|
|
20
|
+
versions: "/api/dsh-evolution/skill-history/versions",
|
|
21
|
+
undo: "/api/dsh-evolution/skill-history/undo"
|
|
22
|
+
};
|
|
23
|
+
/** A refusal the host reported, carrying its own sentence (the curator's, not ours). */
|
|
24
|
+
var SkillHistoryRefusal = class extends Error {};
|
|
25
|
+
/**
|
|
26
|
+
* Build the panel's face over one fetch implementation.
|
|
27
|
+
* @param fetchImpl - the fetch to use; a spec passes a stub.
|
|
28
|
+
* @returns the three callbacks the component receives through its inject face.
|
|
29
|
+
*/
|
|
30
|
+
function createSkillHistoryApi(fetchImpl = fetch) {
|
|
31
|
+
const request = async (path, init) => {
|
|
32
|
+
const response = await fetchImpl(path, init);
|
|
33
|
+
if (!response.ok) throw new SkillHistoryRefusal("HTTP " + String(response.status));
|
|
34
|
+
const body = await response.json();
|
|
35
|
+
if (body.ok !== true) throw new SkillHistoryRefusal(body.message ?? "request refused");
|
|
36
|
+
return body.data;
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
skills: async () => await request(HOST_ROUTES.skills),
|
|
40
|
+
versions: async (name) => await request(HOST_ROUTES.versions + "?name=" + encodeURIComponent(name)),
|
|
41
|
+
undo: async (name, v) => {
|
|
42
|
+
return (await request(HOST_ROUTES.undo, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "content-type": "application/json" },
|
|
45
|
+
body: JSON.stringify(v === void 0 ? { name } : {
|
|
46
|
+
name,
|
|
47
|
+
v
|
|
48
|
+
})
|
|
49
|
+
})).message;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/client/messages.ts
|
|
55
|
+
/**
|
|
56
|
+
* Panel copy, in a zh/en dictionary registered through the platform's locale seat.
|
|
57
|
+
*
|
|
58
|
+
* Client copy is locale-owned: the entry label, the group headings, the actions and the status lines
|
|
59
|
+
* all resolve here, and the panel carries no literal string of its own.
|
|
60
|
+
* @module @lmzhen/dsh-evolution-skill-history/client
|
|
61
|
+
*/
|
|
62
|
+
/** The locale namespace this bundle registers. */
|
|
63
|
+
const NS = "evolution-skill-history";
|
|
64
|
+
/** Chinese dictionary. */
|
|
65
|
+
const zh = {
|
|
66
|
+
"entry.label": "技能历史",
|
|
67
|
+
title: "技能历史",
|
|
68
|
+
hint: "每次技能写入留下的内容版本。回退只改 SKILL.md 正文,标记、使用计数与策展状态不变。",
|
|
69
|
+
"group.content": "正文版本",
|
|
70
|
+
"group.support": "文件版本(不可回退)",
|
|
71
|
+
"group.support.note": "附带文件的字节与正文共用一份索引;回退不恢复它们。",
|
|
72
|
+
"empty.skills": "还没有技能留下过内容版本。",
|
|
73
|
+
"empty.versions": "这个技能还没有记录版本。",
|
|
74
|
+
loading: "读取中…",
|
|
75
|
+
refresh: "刷新",
|
|
76
|
+
undo: "回退到此版",
|
|
77
|
+
"undo.confirm": "确认回退到此版?",
|
|
78
|
+
"undo.yes": "回退",
|
|
79
|
+
"undo.no": "取消",
|
|
80
|
+
current: "当前",
|
|
81
|
+
error: "读取失败",
|
|
82
|
+
versions: "个版本"
|
|
83
|
+
};
|
|
84
|
+
/** English dictionary: the same keys, or the panel falls back to the key itself. */
|
|
85
|
+
const en = {
|
|
86
|
+
"entry.label": "Skill history",
|
|
87
|
+
title: "Skill history",
|
|
88
|
+
hint: "The content versions every skill write leaves behind. Restoring changes the SKILL.md body only: markers, usage counts and curation state stay as they are.",
|
|
89
|
+
"group.content": "Body versions",
|
|
90
|
+
"group.support": "File versions (not restorable)",
|
|
91
|
+
"group.support.note": "Support-file bytes share this index with the body; restoring the body does not bring them back.",
|
|
92
|
+
"empty.skills": "No skill has recorded a content version yet.",
|
|
93
|
+
"empty.versions": "This skill has no recorded versions.",
|
|
94
|
+
loading: "Loading…",
|
|
95
|
+
refresh: "Refresh",
|
|
96
|
+
undo: "Restore this version",
|
|
97
|
+
"undo.confirm": "Restore this version?",
|
|
98
|
+
"undo.yes": "Restore",
|
|
99
|
+
"undo.no": "Cancel",
|
|
100
|
+
current: "Current",
|
|
101
|
+
error: "Could not load",
|
|
102
|
+
versions: "versions"
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/client/Panel.ts
|
|
106
|
+
/**
|
|
107
|
+
* The skill-history panel: the skills that recorded versions, the two chains of the selected skill,
|
|
108
|
+
* and one action per body version (restore, behind an inline confirmation).
|
|
109
|
+
*
|
|
110
|
+
* The panel owns no rule: the host reports each body row with a verdict (`undoable`) computed from
|
|
111
|
+
* the version facts in evolution-core, and the curator owns the words a refusal uses. This file is
|
|
112
|
+
* presentation plus local state — it never subscribes to anything and it never sees the context.
|
|
113
|
+
* @module @lmzhen/dsh-evolution-skill-history/client
|
|
114
|
+
*/
|
|
115
|
+
const root = {
|
|
116
|
+
display: "flex",
|
|
117
|
+
height: "100%",
|
|
118
|
+
minHeight: "0",
|
|
119
|
+
fontSize: "13px",
|
|
120
|
+
color: "var(--dsw-alias-label-primary)"
|
|
121
|
+
};
|
|
122
|
+
const aside = {
|
|
123
|
+
width: "200px",
|
|
124
|
+
flex: "0 0 200px",
|
|
125
|
+
borderRight: "1px solid var(--dsw-alias-border-l2)",
|
|
126
|
+
overflowY: "auto",
|
|
127
|
+
padding: "8px 0"
|
|
128
|
+
};
|
|
129
|
+
const main = {
|
|
130
|
+
flex: "1 1 auto",
|
|
131
|
+
minWidth: "0",
|
|
132
|
+
overflowY: "auto",
|
|
133
|
+
padding: "12px 14px"
|
|
134
|
+
};
|
|
135
|
+
const skillButton = {
|
|
136
|
+
display: "block",
|
|
137
|
+
width: "100%",
|
|
138
|
+
textAlign: "left",
|
|
139
|
+
padding: "6px 12px",
|
|
140
|
+
border: "none",
|
|
141
|
+
background: "transparent",
|
|
142
|
+
color: "inherit",
|
|
143
|
+
cursor: "pointer",
|
|
144
|
+
fontSize: "13px"
|
|
145
|
+
};
|
|
146
|
+
const skillButtonActive = {
|
|
147
|
+
...skillButton,
|
|
148
|
+
background: "var(--dsw-alias-bg-l2)",
|
|
149
|
+
fontWeight: 600
|
|
150
|
+
};
|
|
151
|
+
const heading = {
|
|
152
|
+
margin: "0 0 4px",
|
|
153
|
+
fontSize: "13px",
|
|
154
|
+
fontWeight: 600
|
|
155
|
+
};
|
|
156
|
+
const hint = {
|
|
157
|
+
margin: "0 0 10px",
|
|
158
|
+
fontSize: "12px",
|
|
159
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
160
|
+
lineHeight: "1.5"
|
|
161
|
+
};
|
|
162
|
+
const row = {
|
|
163
|
+
display: "flex",
|
|
164
|
+
alignItems: "center",
|
|
165
|
+
gap: "8px",
|
|
166
|
+
padding: "4px 0",
|
|
167
|
+
borderTop: "1px solid var(--dsw-alias-border-l3)"
|
|
168
|
+
};
|
|
169
|
+
const rowMeta = {
|
|
170
|
+
flex: "1 1 auto",
|
|
171
|
+
minWidth: "0",
|
|
172
|
+
fontFamily: "var(--dsw-font-mono, monospace)",
|
|
173
|
+
fontSize: "12px",
|
|
174
|
+
whiteSpace: "nowrap",
|
|
175
|
+
overflow: "hidden",
|
|
176
|
+
textOverflow: "ellipsis"
|
|
177
|
+
};
|
|
178
|
+
const chip = {
|
|
179
|
+
flex: "0 0 auto",
|
|
180
|
+
fontSize: "11px",
|
|
181
|
+
padding: "1px 6px",
|
|
182
|
+
borderRadius: "999px",
|
|
183
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
184
|
+
color: "var(--dsw-alias-label-secondary)"
|
|
185
|
+
};
|
|
186
|
+
const action = {
|
|
187
|
+
flex: "0 0 auto",
|
|
188
|
+
fontSize: "12px",
|
|
189
|
+
padding: "2px 8px",
|
|
190
|
+
borderRadius: "6px",
|
|
191
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
192
|
+
background: "transparent",
|
|
193
|
+
color: "inherit",
|
|
194
|
+
cursor: "pointer"
|
|
195
|
+
};
|
|
196
|
+
const note = {
|
|
197
|
+
margin: "0 0 10px",
|
|
198
|
+
fontSize: "12px",
|
|
199
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
200
|
+
whiteSpace: "pre-wrap"
|
|
201
|
+
};
|
|
202
|
+
/** One version row: the facts, the current/plain chip, and the restore action when the row allows it. */
|
|
203
|
+
function versionEntry(face, entry) {
|
|
204
|
+
const confirming = face.confirming === entry.v;
|
|
205
|
+
return (0, react.createElement)("div", {
|
|
206
|
+
key: String(entry.v),
|
|
207
|
+
style: row
|
|
208
|
+
}, (0, react.createElement)("span", { style: rowMeta }, "v" + String(entry.v) + " " + entry.at + " " + entry.action + " " + String(entry.chars) + " chars " + entry.hash.slice(0, 12)), entry.undoable === false ? (0, react.createElement)("span", { style: chip }, face.t("current")) : null, entry.undoable === true ? (0, react.createElement)("button", {
|
|
209
|
+
type: "button",
|
|
210
|
+
style: action,
|
|
211
|
+
onClick: () => {
|
|
212
|
+
face.restore(entry.v);
|
|
213
|
+
}
|
|
214
|
+
}, confirming ? face.t("undo.confirm") : face.t("undo")) : null);
|
|
215
|
+
}
|
|
216
|
+
/** One group: a heading, an optional note, and its rows. */
|
|
217
|
+
function group(face, title, entries, noteText) {
|
|
218
|
+
if (entries.length === 0) return null;
|
|
219
|
+
return (0, react.createElement)("section", { key: title }, (0, react.createElement)("h3", { style: heading }, title), noteText === void 0 ? null : (0, react.createElement)("p", { style: hint }, noteText), ...entries.map((entry) => versionEntry(face, entry)));
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The panel body.
|
|
223
|
+
* @param face - copy, the data callbacks and the in-flight confirmation state.
|
|
224
|
+
* @returns the panel element.
|
|
225
|
+
*/
|
|
226
|
+
function SkillHistoryPanel(face) {
|
|
227
|
+
const [skills, setSkills] = (0, react.useState)([]);
|
|
228
|
+
const [selected, setSelected] = (0, react.useState)(void 0);
|
|
229
|
+
const [payload, setPayload] = (0, react.useState)(void 0);
|
|
230
|
+
const [noteText, setNoteText] = (0, react.useState)(void 0);
|
|
231
|
+
const [pending, setPending] = (0, react.useState)(void 0);
|
|
232
|
+
const failed = (error) => {
|
|
233
|
+
setNoteText(face.t("error") + ": " + (error instanceof Error ? error.message : String(error)));
|
|
234
|
+
};
|
|
235
|
+
const refresh = () => {
|
|
236
|
+
setSkills([]);
|
|
237
|
+
face.loadSkills().then(setSkills).catch(failed);
|
|
238
|
+
};
|
|
239
|
+
const open = (name) => {
|
|
240
|
+
setSelected(name);
|
|
241
|
+
setPayload(void 0);
|
|
242
|
+
setPending(void 0);
|
|
243
|
+
setNoteText(void 0);
|
|
244
|
+
face.loadVersions(name).then(setPayload).catch(failed);
|
|
245
|
+
};
|
|
246
|
+
const restore = (v) => {
|
|
247
|
+
if (selected === void 0) return;
|
|
248
|
+
if (pending !== v) {
|
|
249
|
+
setPending(v);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
setPending(void 0);
|
|
253
|
+
face.undo(selected, v).then((message) => {
|
|
254
|
+
setNoteText(message);
|
|
255
|
+
open(selected);
|
|
256
|
+
}).catch(failed);
|
|
257
|
+
};
|
|
258
|
+
(0, react.useEffect)(refresh, []);
|
|
259
|
+
const loaded = payload !== void 0;
|
|
260
|
+
return (0, react.createElement)("div", { style: root }, (0, react.createElement)("aside", { style: aside }, skills.length === 0 ? (0, react.createElement)("p", { style: hint }, face.t("empty.skills")) : skills.map((skill) => (0, react.createElement)("button", {
|
|
261
|
+
key: skill.name,
|
|
262
|
+
type: "button",
|
|
263
|
+
style: skill.name === selected ? skillButtonActive : skillButton,
|
|
264
|
+
onClick: () => {
|
|
265
|
+
open(skill.name);
|
|
266
|
+
}
|
|
267
|
+
}, skill.name + " (" + String(skill.versions) + ")"))), (0, react.createElement)("div", { style: main }, (0, react.createElement)("h2", { style: heading }, face.t("title")), (0, react.createElement)("p", { style: hint }, face.t("hint")), noteText === void 0 ? null : (0, react.createElement)("p", { style: note }, noteText), selected === void 0 ? null : loaded ? null : (0, react.createElement)("p", { style: hint }, face.t("loading")), loaded ? (0, react.createElement)("div", null, group({
|
|
268
|
+
...face,
|
|
269
|
+
restore,
|
|
270
|
+
confirming: pending
|
|
271
|
+
}, face.t("group.content"), payload.content), group({
|
|
272
|
+
...face,
|
|
273
|
+
restore,
|
|
274
|
+
confirming: pending
|
|
275
|
+
}, face.t("group.support"), payload.support, face.t("group.support.note")), payload.content.length === 0 && payload.support.length === 0 ? (0, react.createElement)("p", { style: hint }, face.t("empty.versions")) : null) : null));
|
|
276
|
+
}
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/client/seam.ts
|
|
279
|
+
/** The panel id. The icon row and the `main` body MUST share it: the sidebar resolves the body by it. */
|
|
280
|
+
const PANEL_ID = "skill-history";
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/client/index.ts
|
|
283
|
+
/**
|
|
284
|
+
* Browser half: one left-sidebar panel row (`技能历史`) and the main-column body it opens.
|
|
285
|
+
*
|
|
286
|
+
* Two places, one id — the sidebar contract is that `sidebar.panellist` owns the button and the layout's
|
|
287
|
+
* keyed `main` slot owns the body, dispatched by that same id. The row renders only while this
|
|
288
|
+
* bundle is loaded: a deployment that drops the row loses the panel and nothing else, because every
|
|
289
|
+
* fact it shows comes from the host routes on demand.
|
|
290
|
+
* @module @lmzhen/dsh-evolution-skill-history/client
|
|
291
|
+
*/
|
|
292
|
+
/** Required client services: the slot registry and the locale seat. */
|
|
293
|
+
const inject = ["slots", "locale"];
|
|
294
|
+
/** Row order inside the global panel list (beside the platform's own rows). */
|
|
295
|
+
const PANEL_ORDER = 35;
|
|
296
|
+
/** The row's icon: the label carries the accessible name, so the glyph is decoration only. */
|
|
297
|
+
function PanelIcon() {
|
|
298
|
+
return (0, react.createElement)("span", { "aria-hidden": "true" }, "🕘");
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Register the locale namespace, the panel row and the panel body.
|
|
302
|
+
* @param ctx - the client cordis context.
|
|
303
|
+
*/
|
|
304
|
+
function apply(ctx) {
|
|
305
|
+
const seam = ctx;
|
|
306
|
+
const api = createSkillHistoryApi();
|
|
307
|
+
ctx.effect(() => {
|
|
308
|
+
return seam.locale.register(NS, {
|
|
309
|
+
zh,
|
|
310
|
+
en
|
|
311
|
+
});
|
|
312
|
+
}, "evolution-skill-history: locale");
|
|
313
|
+
const t = seam.locale.bind(NS);
|
|
314
|
+
seam.slots.inject("sidebar.panellist", function* () {
|
|
315
|
+
yield seam.slots.register({
|
|
316
|
+
name: "sidebar.panellist",
|
|
317
|
+
id: PANEL_ID,
|
|
318
|
+
order: 35,
|
|
319
|
+
label: () => t("entry.label")
|
|
320
|
+
}, PanelIcon);
|
|
321
|
+
yield seam.slots.register({
|
|
322
|
+
name: "main",
|
|
323
|
+
key: PANEL_ID,
|
|
324
|
+
inject: () => ({
|
|
325
|
+
t,
|
|
326
|
+
loadSkills: api.skills,
|
|
327
|
+
loadVersions: api.versions,
|
|
328
|
+
undo: api.undo
|
|
329
|
+
})
|
|
330
|
+
}, SkillHistoryPanel);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
//#endregion
|
|
334
|
+
exports.PANEL_ORDER = PANEL_ORDER;
|
|
335
|
+
exports.apply = apply;
|
|
336
|
+
exports.inject = inject;
|
|
337
|
+
|
|
338
|
+
return module.exports;
|
|
339
|
+
}
|
|
340
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { contentHash, errorText, partitionVersions } from "@lmzhen/dsh-evolution-core";
|
|
2
|
+
//#region lib/types/routes.js
|
|
3
|
+
/**
|
|
4
|
+
* The skill-history HTTP surface: four loopback routes over the curator's read/write seam.
|
|
5
|
+
*
|
|
6
|
+
* Layering: this module maps a route to a USE CASE and does nothing else. "Which artifact does this
|
|
7
|
+
* version hold" and "how do the two chains split" live in evolution-core; the only write is
|
|
8
|
+
* curator.undo. The panel never writes the skill tree itself.
|
|
9
|
+
* @module @lmzhen/dsh-evolution-skill-history/routes
|
|
10
|
+
*/
|
|
11
|
+
/** Route paths. The client bundle mirrors these literals; a spec asserts the two sides agree. */
|
|
12
|
+
const SKILL_HISTORY_ROUTES = {
|
|
13
|
+
skills: "/api/dsh-evolution/skill-history/skills",
|
|
14
|
+
versions: "/api/dsh-evolution/skill-history/versions",
|
|
15
|
+
undo: "/api/dsh-evolution/skill-history/undo",
|
|
16
|
+
health: "/api/dsh-evolution/skill-history/health"
|
|
17
|
+
};
|
|
18
|
+
function header(request, name) {
|
|
19
|
+
const value = request.headers[name];
|
|
20
|
+
return typeof value === "string" ? value : void 0;
|
|
21
|
+
}
|
|
22
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
23
|
+
function isIPv4Loopback(value) {
|
|
24
|
+
const parts = value.split(".");
|
|
25
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
26
|
+
}
|
|
27
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
28
|
+
function isLoopbackAddress(address) {
|
|
29
|
+
if (address === void 0) return false;
|
|
30
|
+
const normalized = address.toLowerCase();
|
|
31
|
+
if (normalized === "::1") return true;
|
|
32
|
+
if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
|
|
33
|
+
return isIPv4Loopback(normalized);
|
|
34
|
+
}
|
|
35
|
+
/** Whether a hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
36
|
+
function isLoopbackHostname(hostname) {
|
|
37
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
38
|
+
return isIPv4Loopback(hostname);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether one request may enter these routes.
|
|
42
|
+
*
|
|
43
|
+
* The same fence the platform puts on its own /api bridge, kept local because the canonical
|
|
44
|
+
* implementation (packages/client/connection/src/api-request-trust.ts) is not part of that package's
|
|
45
|
+
* published surface. Rules, in order: the SOCKET must be loopback (authoritative — X-Forwarded-For is
|
|
46
|
+
* never trusted), the Host header must name a loopback authority (DNS-rebinding defense), an explicit
|
|
47
|
+
* cross-site marker is refused, and an attached Origin must be exactly this authority.
|
|
48
|
+
* @param request - the request to judge.
|
|
49
|
+
* @returns true when the request may proceed.
|
|
50
|
+
*/
|
|
51
|
+
function isLoopbackRequest(request) {
|
|
52
|
+
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
|
|
53
|
+
const host = header(request, "host");
|
|
54
|
+
if (host === void 0) return false;
|
|
55
|
+
let hostUrl;
|
|
56
|
+
try {
|
|
57
|
+
hostUrl = new URL("http://" + host);
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
62
|
+
if (header(request, "sec-fetch-site") === "cross-site") return false;
|
|
63
|
+
const origin = header(request, "origin");
|
|
64
|
+
if (origin === void 0) return true;
|
|
65
|
+
try {
|
|
66
|
+
return new URL(origin).host === hostUrl.host;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Write one JSON response. The routes own their response lifecycle, so every path ends here. */
|
|
72
|
+
function writeJson(res, status, value) {
|
|
73
|
+
const body = JSON.stringify(value);
|
|
74
|
+
res.writeHead(status, {
|
|
75
|
+
"content-type": "application/json; charset=utf-8",
|
|
76
|
+
"content-length": Buffer.byteLength(body)
|
|
77
|
+
});
|
|
78
|
+
res.end(body);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read one JSON request body, bounded.
|
|
82
|
+
* @param req - the request.
|
|
83
|
+
* @returns the parsed value, or null when the body is oversized, empty, or not JSON.
|
|
84
|
+
*/
|
|
85
|
+
async function readJsonBody(req) {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let size = 0;
|
|
88
|
+
for await (const chunk of req) {
|
|
89
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
90
|
+
size += buffer.byteLength;
|
|
91
|
+
if (size > 65536) return null;
|
|
92
|
+
chunks.push(buffer);
|
|
93
|
+
}
|
|
94
|
+
if (size === 0) return null;
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Build the four routes over the curator seam.
|
|
103
|
+
* @param services - the host services the handlers read.
|
|
104
|
+
* @returns the route list for ctx.webServer.register.
|
|
105
|
+
*/
|
|
106
|
+
function makeSkillHistoryRoutes(services) {
|
|
107
|
+
/** Run one handler body with the curator, or answer the family's E-302 sentence when it is absent. */
|
|
108
|
+
const withCurator = async (res, run) => {
|
|
109
|
+
const curator = services.getCurator();
|
|
110
|
+
if (curator === void 0) {
|
|
111
|
+
writeJson(res, 200, {
|
|
112
|
+
ok: false,
|
|
113
|
+
code: "curator-not-mounted",
|
|
114
|
+
message: errorText("e-302-curator-service-not-mounted")
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await run(curator);
|
|
119
|
+
};
|
|
120
|
+
/** Guard: trust fence, then method. Every refusal is a JSON body, never an empty response. */
|
|
121
|
+
const guard = (req, res, method) => {
|
|
122
|
+
if (!isLoopbackRequest(req)) {
|
|
123
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (req.method !== method) {
|
|
127
|
+
writeJson(res, 405, { error: "method not allowed: " + (req.method ?? "") });
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
};
|
|
132
|
+
return [
|
|
133
|
+
{
|
|
134
|
+
kind: "exact",
|
|
135
|
+
path: SKILL_HISTORY_ROUTES.skills,
|
|
136
|
+
handler: async (req, res) => {
|
|
137
|
+
if (!guard(req, res, "GET")) return;
|
|
138
|
+
await withCurator(res, async (curator) => {
|
|
139
|
+
const listed = await curator.skills.list();
|
|
140
|
+
const withHistory = [];
|
|
141
|
+
for (const skill of listed) {
|
|
142
|
+
const versions = await curator.skills.listVersions(skill.name);
|
|
143
|
+
if (versions.length > 0) withHistory.push({
|
|
144
|
+
name: skill.name,
|
|
145
|
+
versions: versions.length
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
writeJson(res, 200, {
|
|
149
|
+
ok: true,
|
|
150
|
+
data: withHistory
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
kind: "exact",
|
|
157
|
+
path: SKILL_HISTORY_ROUTES.versions,
|
|
158
|
+
handler: async (req, res) => {
|
|
159
|
+
if (!guard(req, res, "GET")) return;
|
|
160
|
+
const name = new URL(req.url ?? "/", "http://loopback").searchParams.get("name")?.trim() ?? "";
|
|
161
|
+
if (name === "") {
|
|
162
|
+
writeJson(res, 400, {
|
|
163
|
+
ok: false,
|
|
164
|
+
code: "bad-request",
|
|
165
|
+
message: "name is required"
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
await withCurator(res, async (curator) => {
|
|
170
|
+
const groups = partitionVersions(await curator.history(name));
|
|
171
|
+
const live = await curator.skills.read(name);
|
|
172
|
+
const liveHash = live === null ? null : contentHash(live);
|
|
173
|
+
writeJson(res, 200, {
|
|
174
|
+
ok: true,
|
|
175
|
+
data: {
|
|
176
|
+
content: groups.content.map((entry) => ({
|
|
177
|
+
...entry,
|
|
178
|
+
undoable: entry.hash !== liveHash
|
|
179
|
+
})),
|
|
180
|
+
support: groups.support,
|
|
181
|
+
liveHash
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
kind: "exact",
|
|
189
|
+
path: SKILL_HISTORY_ROUTES.undo,
|
|
190
|
+
handler: async (req, res) => {
|
|
191
|
+
if (!guard(req, res, "POST")) return;
|
|
192
|
+
const body = await readJsonBody(req);
|
|
193
|
+
const payload = body !== null && typeof body === "object" && !Array.isArray(body) ? body : null;
|
|
194
|
+
const name = typeof payload?.name === "string" ? payload.name.trim() : "";
|
|
195
|
+
if (name === "") {
|
|
196
|
+
writeJson(res, 400, {
|
|
197
|
+
ok: false,
|
|
198
|
+
code: "bad-request",
|
|
199
|
+
message: "name is required"
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const raw = payload?.v;
|
|
204
|
+
if (raw !== void 0 && (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1)) {
|
|
205
|
+
writeJson(res, 400, {
|
|
206
|
+
ok: false,
|
|
207
|
+
code: "bad-request",
|
|
208
|
+
message: "v must be a positive integer"
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
await withCurator(res, async (curator) => {
|
|
213
|
+
const result = raw === void 0 ? await curator.undo(name) : await curator.undo(name, raw);
|
|
214
|
+
writeJson(res, 200, result.ok ? {
|
|
215
|
+
ok: true,
|
|
216
|
+
data: result
|
|
217
|
+
} : {
|
|
218
|
+
ok: false,
|
|
219
|
+
code: "undo-refused",
|
|
220
|
+
message: result.message
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
kind: "exact",
|
|
227
|
+
path: SKILL_HISTORY_ROUTES.health,
|
|
228
|
+
handler: (req, res) => {
|
|
229
|
+
if (!guard(req, res, "GET")) return;
|
|
230
|
+
writeJson(res, 200, {
|
|
231
|
+
ok: true,
|
|
232
|
+
data: { surface: "skill-history" }
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region lib/types/index.js
|
|
240
|
+
/**
|
|
241
|
+
* Host half of the skill-history surface: it mounts the four loopback routes the sidebar panel reads
|
|
242
|
+
* and writes, and it owns nothing else.
|
|
243
|
+
*
|
|
244
|
+
* The version FACTS come from evolution-core (which artifact a version holds, how the body chain and
|
|
245
|
+
* the support-file chain split) and the only write is the curator's undo, so this row carries no state
|
|
246
|
+
* of its own: unloading it removes the routes and leaves the feature's data untouched.
|
|
247
|
+
* @module @lmzhen/dsh-evolution-skill-history
|
|
248
|
+
*/
|
|
249
|
+
const name = "evolution-skill-history";
|
|
250
|
+
/** No declared service: the web server is OPTIONAL (a headless profile has none) and the curator is
|
|
251
|
+
* resolved per request, so the row activates wherever it is installed and answers honestly where a
|
|
252
|
+
* service is missing — the platform rule for optional services is ctx.get, not a waited-on inject. */
|
|
253
|
+
const inject = [];
|
|
254
|
+
/**
|
|
255
|
+
* Mount the routes as ONE effect: the disposers run on unload and on HMR.
|
|
256
|
+
* @param ctx - the host context carrying webServer and the curator.
|
|
257
|
+
*/
|
|
258
|
+
function apply(ctx) {
|
|
259
|
+
const webServer = ctx.get("webServer");
|
|
260
|
+
if (webServer === void 0) return;
|
|
261
|
+
ctx.effect(() => {
|
|
262
|
+
const disposers = makeSkillHistoryRoutes({ getCurator: () => ctx.get("evolutionCurator") }).map((route) => webServer.register(route));
|
|
263
|
+
return () => {
|
|
264
|
+
for (const dispose of disposers) dispose();
|
|
265
|
+
};
|
|
266
|
+
}, "evolution-skill-history: routes");
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skill-history panel: the skills that recorded versions, the two chains of the selected skill,
|
|
3
|
+
* and one action per body version (restore, behind an inline confirmation).
|
|
4
|
+
*
|
|
5
|
+
* The panel owns no rule: the host reports each body row with a verdict (`undoable`) computed from
|
|
6
|
+
* the version facts in evolution-core, and the curator owns the words a refusal uses. This file is
|
|
7
|
+
* presentation plus local state — it never subscribes to anything and it never sees the context.
|
|
8
|
+
* @module @lmzhen/dsh-evolution-skill-history/client
|
|
9
|
+
*/
|
|
10
|
+
import { type ReactNode } from 'react';
|
|
11
|
+
import type { SkillRow, VersionsPayload } from './api.ts';
|
|
12
|
+
/** The face the plugin hands the component: copy plus three callbacks, never a service handle. */
|
|
13
|
+
export interface PanelFace {
|
|
14
|
+
readonly t: (key: string) => string;
|
|
15
|
+
readonly loadSkills: () => Promise<readonly SkillRow[]>;
|
|
16
|
+
readonly loadVersions: (name: string) => Promise<VersionsPayload>;
|
|
17
|
+
readonly undo: (name: string, v?: number) => Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The panel body.
|
|
21
|
+
* @param face - copy, the data callbacks and the in-flight confirmation state.
|
|
22
|
+
* @returns the panel element.
|
|
23
|
+
*/
|
|
24
|
+
export declare function SkillHistoryPanel(face: PanelFace): ReactNode;
|
|
25
|
+
//# sourceMappingURL=Panel.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host routes this bundle calls, and the ONE place a request is made.
|
|
3
|
+
*
|
|
4
|
+
* The paths mirror the host half's `SKILL_HISTORY_ROUTES` (a spec asserts the two sides agree). Every
|
|
5
|
+
* refusal arrives as `ok:false` plus the curator's own sentence, so the panel shows the same words the
|
|
6
|
+
* slash command prints instead of inventing a second vocabulary.
|
|
7
|
+
* @module @lmzhen/dsh-evolution-skill-history/client
|
|
8
|
+
*/
|
|
9
|
+
/** Route paths, mirrored from the host half. */
|
|
10
|
+
export declare const HOST_ROUTES: {
|
|
11
|
+
readonly skills: "/api/dsh-evolution/skill-history/skills";
|
|
12
|
+
readonly versions: "/api/dsh-evolution/skill-history/versions";
|
|
13
|
+
readonly undo: "/api/dsh-evolution/skill-history/undo";
|
|
14
|
+
};
|
|
15
|
+
/** One recorded version, as the host reports it. */
|
|
16
|
+
export interface VersionRow {
|
|
17
|
+
readonly v: number;
|
|
18
|
+
readonly at: string;
|
|
19
|
+
readonly action: string;
|
|
20
|
+
readonly hash: string;
|
|
21
|
+
readonly chars: number;
|
|
22
|
+
/** Present on body rows: false when this version already IS the live content. */
|
|
23
|
+
readonly undoable?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** One skill that has recorded versions. */
|
|
26
|
+
export interface SkillRow {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly versions: number;
|
|
29
|
+
}
|
|
30
|
+
/** The two chains, plus the live body hash the panel marks as "current". */
|
|
31
|
+
export interface VersionsPayload {
|
|
32
|
+
readonly content: readonly VersionRow[];
|
|
33
|
+
readonly support: readonly VersionRow[];
|
|
34
|
+
readonly liveHash: string | null;
|
|
35
|
+
}
|
|
36
|
+
/** The panel's data face: plain callbacks, no service handle. */
|
|
37
|
+
export interface SkillHistoryApi {
|
|
38
|
+
readonly skills: () => Promise<readonly SkillRow[]>;
|
|
39
|
+
readonly versions: (name: string) => Promise<VersionsPayload>;
|
|
40
|
+
readonly undo: (name: string, v?: number) => Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
/** A refusal the host reported, carrying its own sentence (the curator's, not ours). */
|
|
43
|
+
export declare class SkillHistoryRefusal extends Error {
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Build the panel's face over one fetch implementation.
|
|
47
|
+
* @param fetchImpl - the fetch to use; a spec passes a stub.
|
|
48
|
+
* @returns the three callbacks the component receives through its inject face.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createSkillHistoryApi(fetchImpl?: typeof fetch): SkillHistoryApi;
|
|
51
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis';
|
|
2
|
+
/** Required client services: the slot registry and the locale seat. */
|
|
3
|
+
export declare const inject: string[];
|
|
4
|
+
/** Row order inside the global panel list (beside the platform's own rows). */
|
|
5
|
+
export declare const PANEL_ORDER = 35;
|
|
6
|
+
/**
|
|
7
|
+
* Register the locale namespace, the panel row and the panel body.
|
|
8
|
+
* @param ctx - the client cordis context.
|
|
9
|
+
*/
|
|
10
|
+
export declare function apply(ctx: ClientContext): void;
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel copy, in a zh/en dictionary registered through the platform's locale seat.
|
|
3
|
+
*
|
|
4
|
+
* Client copy is locale-owned: the entry label, the group headings, the actions and the status lines
|
|
5
|
+
* all resolve here, and the panel carries no literal string of its own.
|
|
6
|
+
* @module @lmzhen/dsh-evolution-skill-history/client
|
|
7
|
+
*/
|
|
8
|
+
/** The locale namespace this bundle registers. */
|
|
9
|
+
export declare const NS = "evolution-skill-history";
|
|
10
|
+
/** Chinese dictionary. */
|
|
11
|
+
export declare const zh: Record<string, string>;
|
|
12
|
+
/** English dictionary: the same keys, or the panel falls back to the key itself. */
|
|
13
|
+
export declare const en: Record<string, string>;
|
|
14
|
+
/**
|
|
15
|
+
* One message without the locale seat (the fallback path, and what a spec reads).
|
|
16
|
+
* @param language - which dictionary to read.
|
|
17
|
+
* @param key - the message key.
|
|
18
|
+
* @returns the copy, or the key itself when neither dictionary carries it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function message(language: 'zh' | 'en', key: string): string;
|
|
21
|
+
//# sourceMappingURL=messages.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural view of the two client seams this bundle uses: the sidebar
|
|
3
|
+
* global panel row plus the main-column body it addresses, and the locale seat.
|
|
4
|
+
*
|
|
5
|
+
* The package is distributed outside the platform repository, so it declares the seams it consumes
|
|
6
|
+
* instead of importing another plugin's values (forbidden by the client bundle purity rule) or its
|
|
7
|
+
* types (which would drag platform client sources into this project). The shapes are the shell's
|
|
8
|
+
* contract: `sidebar.panellist` takes `id`/`order`/`label` and the sidebar owns the button, while the body is the
|
|
9
|
+
* root layout's keyed `main` slot dispatched by that same id.
|
|
10
|
+
* @module @lmzhen/dsh-evolution-skill-history/client
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
/** The panel id. The icon row and the `main` body MUST share it: the sidebar resolves the body by it. */
|
|
14
|
+
export declare const PANEL_ID = "skill-history";
|
|
15
|
+
/** Registration options for the global panel row (the sidebar's own shape). */
|
|
16
|
+
export interface PanelRowOptions {
|
|
17
|
+
name: 'sidebar.panellist';
|
|
18
|
+
id: string;
|
|
19
|
+
order: number;
|
|
20
|
+
/** Lazy label: the sidebar re-reads it on every projection, so the locale can change underneath. */
|
|
21
|
+
label: () => string;
|
|
22
|
+
}
|
|
23
|
+
/** Registration options for the main-column body: a keyed slot dispatched by sidebar entry id. */
|
|
24
|
+
export interface PanelBodyOptions {
|
|
25
|
+
name: 'main';
|
|
26
|
+
key: string;
|
|
27
|
+
/** The face handed to the component: plain data and callbacks, never a service. */
|
|
28
|
+
inject: () => unknown;
|
|
29
|
+
}
|
|
30
|
+
/** The locale seat: register this bundle's namespace, bind a translator. */
|
|
31
|
+
export interface LocaleSeat {
|
|
32
|
+
register(namespace: string, dictionaries: {
|
|
33
|
+
zh: Record<string, string>;
|
|
34
|
+
en: Record<string, string>;
|
|
35
|
+
}): () => void;
|
|
36
|
+
bind(namespace: string): (key: string) => string;
|
|
37
|
+
}
|
|
38
|
+
/** The slice of the client context this plugin consumes. */
|
|
39
|
+
export interface ClientSeam {
|
|
40
|
+
slots: {
|
|
41
|
+
inject(name: string, callback: () => Generator<unknown, void, unknown>): unknown;
|
|
42
|
+
register(options: PanelRowOptions | PanelBodyOptions, component: (props: never) => ReactNode): () => void;
|
|
43
|
+
};
|
|
44
|
+
locale: LocaleSeat;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=seam.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the skill-history surface: it mounts the four loopback routes the sidebar panel reads
|
|
3
|
+
* and writes, and it owns nothing else.
|
|
4
|
+
*
|
|
5
|
+
* The version FACTS come from evolution-core (which artifact a version holds, how the body chain and
|
|
6
|
+
* the support-file chain split) and the only write is the curator's undo, so this row carries no state
|
|
7
|
+
* of its own: unloading it removes the routes and leaves the feature's data untouched.
|
|
8
|
+
* @module @lmzhen/dsh-evolution-skill-history
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
export declare const name = "evolution-skill-history";
|
|
12
|
+
/** No declared service: the web server is OPTIONAL (a headless profile has none) and the curator is
|
|
13
|
+
* resolved per request, so the row activates wherever it is installed and answers honestly where a
|
|
14
|
+
* service is missing — the platform rule for optional services is ctx.get, not a waited-on inject. */
|
|
15
|
+
export declare const inject: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Mount the routes as ONE effect: the disposers run on unload and on HMR.
|
|
18
|
+
* @param ctx - the host context carrying webServer and the curator.
|
|
19
|
+
*/
|
|
20
|
+
export declare function apply(ctx: Context): void;
|
|
21
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skill-history HTTP surface: four loopback routes over the curator's read/write seam.
|
|
3
|
+
*
|
|
4
|
+
* Layering: this module maps a route to a USE CASE and does nothing else. "Which artifact does this
|
|
5
|
+
* version hold" and "how do the two chains split" live in evolution-core; the only write is
|
|
6
|
+
* curator.undo. The panel never writes the skill tree itself.
|
|
7
|
+
* @module @lmzhen/dsh-evolution-skill-history/routes
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
12
|
+
/** Route paths. The client bundle mirrors these literals; a spec asserts the two sides agree. */
|
|
13
|
+
export declare const SKILL_HISTORY_ROUTES: {
|
|
14
|
+
readonly skills: "/api/dsh-evolution/skill-history/skills";
|
|
15
|
+
readonly versions: "/api/dsh-evolution/skill-history/versions";
|
|
16
|
+
readonly undo: "/api/dsh-evolution/skill-history/undo";
|
|
17
|
+
readonly health: "/api/dsh-evolution/skill-history/health";
|
|
18
|
+
};
|
|
19
|
+
/** Largest request body these routes accept (the undo payload is a name and a number). */
|
|
20
|
+
export declare const MAX_REQUEST_BODY_BYTES: number;
|
|
21
|
+
/** The request facts the trust fence reads: a Node request, or the same fields in a spec fixture. */
|
|
22
|
+
export interface FenceRequest {
|
|
23
|
+
readonly headers: Record<string, string | string[] | undefined>;
|
|
24
|
+
readonly socket?: {
|
|
25
|
+
readonly remoteAddress?: string | undefined;
|
|
26
|
+
} | undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether one request may enter these routes.
|
|
30
|
+
*
|
|
31
|
+
* The same fence the platform puts on its own /api bridge, kept local because the canonical
|
|
32
|
+
* implementation (packages/client/connection/src/api-request-trust.ts) is not part of that package's
|
|
33
|
+
* published surface. Rules, in order: the SOCKET must be loopback (authoritative — X-Forwarded-For is
|
|
34
|
+
* never trusted), the Host header must name a loopback authority (DNS-rebinding defense), an explicit
|
|
35
|
+
* cross-site marker is refused, and an attached Origin must be exactly this authority.
|
|
36
|
+
* @param request - the request to judge.
|
|
37
|
+
* @returns true when the request may proceed.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isLoopbackRequest(request: FenceRequest): boolean;
|
|
40
|
+
/** Write one JSON response. The routes own their response lifecycle, so every path ends here. */
|
|
41
|
+
export declare function writeJson(res: ServerResponse, status: number, value: unknown): void;
|
|
42
|
+
/**
|
|
43
|
+
* Read one JSON request body, bounded.
|
|
44
|
+
* @param req - the request.
|
|
45
|
+
* @returns the parsed value, or null when the body is oversized, empty, or not JSON.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readJsonBody(req: IncomingMessage): Promise<unknown>;
|
|
48
|
+
/** The curator surface the routes read: history() and the library listing, undo() as the only write. */
|
|
49
|
+
export type CuratorFace = Pick<Context['evolutionCurator'], 'history' | 'undo' | 'skills'>;
|
|
50
|
+
/**
|
|
51
|
+
* What the handlers resolve PER REQUEST.
|
|
52
|
+
*
|
|
53
|
+
* The curator is looked up late, not captured: a profile that mounts these routes before the curator
|
|
54
|
+
* (or without it) answers the family's own E-302 sentence instead of throwing, which is how every
|
|
55
|
+
* other optional-service consumer in this family behaves.
|
|
56
|
+
*/
|
|
57
|
+
export interface RouteServices {
|
|
58
|
+
readonly getCurator: () => CuratorFace | undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the four routes over the curator seam.
|
|
62
|
+
* @param services - the host services the handlers read.
|
|
63
|
+
* @returns the route list for ctx.webServer.register.
|
|
64
|
+
*/
|
|
65
|
+
export declare function makeSkillHistoryRoutes(services: RouteServices): WebRoute[];
|
|
66
|
+
//# sourceMappingURL=routes.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lmzhen/dsh-evolution-skill-history",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"description": "Web surface for skill content history: loopback host routes plus the sidebar panel (community build)",
|
|
4
|
+
"version": "0.11.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/lmzhen/dsh-evolution.git",
|
|
11
|
+
"directory": "packages/evolution-skill-history"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./client": {
|
|
22
|
+
"types": "./lib/types/client/index.d.ts",
|
|
23
|
+
"default": "./lib/client.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/*.js",
|
|
29
|
+
"lib/types/**/*.d.ts"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@lmzhen/dsh-evolution-core": "^0.11.1"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
37
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
|
|
38
|
+
"react": "^18.2.0",
|
|
39
|
+
"@lmzhen/dsh-evolution-curator": "^0.11.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
43
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
|
|
44
|
+
"@lmzhen/dsh-evolution-core": "^0.11.1",
|
|
45
|
+
"@lmzhen/dsh-evolution-curator": "^0.11.1",
|
|
46
|
+
"@lmzhen/dsh-evolution-io": "^0.11.1",
|
|
47
|
+
"@lmzhen/dsh-evolution-io-node": "^0.11.1"
|
|
48
|
+
},
|
|
49
|
+
"dsh": {
|
|
50
|
+
"client": {
|
|
51
|
+
"platform": "web",
|
|
52
|
+
"inject": [
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
55
|
+
"@deepseek-ai/dsh-client-locale"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
6
59
|
}
|