@lmzhen/dsh-evolution-skill-history 0.17.0 → 0.18.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 CHANGED
@@ -15,6 +15,8 @@ Two registrations, one id (`skill-history`), because that is the sidebar's contr
15
15
 
16
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
17
 
18
+ The icon draws at the edge the sidebar asks for. The seat hands every occupant `{ size, active }` and the panel row owns the glyph slot, its inset and the gap to the label — the platform's own occupants hand that same `size` straight to their primitive — so `PanelIcon` renders its 16-unit glyph at exactly that `size`. A glyph box of our own moves this label out of the column the other panel rows share; ours did, by 10px, until 0.17.1.
19
+
18
20
  ## The host routes
19
21
 
20
22
  | Method | Path | Use case |
@@ -24,13 +26,13 @@ The label is a **thunk**: the sidebar re-reads it on every projection, so a lang
24
26
  | POST | `/api/dsh-evolution/skill-history/undo` | `curator.undo(name, v?)` — the only write |
25
27
  | GET | `/api/dsh-evolution/skill-history/versions/diff?name=&v=` | one body version against its recorded predecessor, as line counts plus the windowed changed region |
26
28
  | GET | `/api/dsh-evolution/skill-history/versions/body?name=&v=` | one version's whole text, for the panel's preview; bounded by `MAX_BODY_CHARS` and flagged `truncated` |
27
- | GET | `/api/dsh-evolution/skill-history/health` | liveness probe for the client |
29
+ | GET | `/api/dsh-evolution/skill-history/health` | answers `{ surface: 'skill-history' }` — a host-side liveness/identity probe. The browser half declares only the five routes it uses and never calls this one (it is what the client's route-parity spec means by "fewer routes than the host publishes") |
28
30
 
29
31
  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.
30
32
 
31
33
  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.
32
34
 
33
- 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.
35
+ The row declares **no service** and **waits** for the web server: `inject` is empty and `apply` mounts the routes inside `ctx.inject(['webServer'], scope => scope.effect(…))`, so a headless profile keeps the slash commands as its entry point and never runs the callback at all — inert, and no row left pending on a service that will never arrive. Reading the service at apply time instead (`ctx.get('webServer')`) is a TIMING fact, not a verdict: 0.11.1 shipped that way and left the routes permanently unregistered while the panel rendered and every call answered HTTP 404. 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.
34
36
 
35
37
  ## What the panel shows
36
38
 
@@ -71,5 +73,6 @@ This package adds **no model-visible content**: no prompt section, no tool schem
71
73
  - **No quality and no last-use in the list.** Both are real and both are wanted, but they live in the curator's health view and the usage store; joining them into this listing would make the route a second home for facts it does not own. The row shows what the listing already holds (description, managed/protected markers, version count).
72
74
  - **A support version can be read but not restored.** Its bytes and (from 0.14.0) its file name are recorded, and the preview shows them, but `undo` still refuses a support version by name: restoring one means deciding deletion semantics, path validation and what happens to a `scripts/` file, which is a design of its own.
73
75
 
76
+ - **The six routes are loopback-only by design.** The fence is the family's shared loopback check, authoritative on the socket (`X-Forwarded-For` is never trusted), so a LAN-facing host that answers the platform's `/api` normally returns **403** here for any non-loopback client; reach the host over loopback (a local browser, or an SSH tunnel). Following the platform's `trustedHosts` instead is an open decision (v46 `决策 3`; the mechanism lives in `evolution-core`'s `http-routes.ts`).
74
77
  - **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.
75
78
  - **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 CHANGED
@@ -78,6 +78,7 @@ window.__ModuleLoader__.load({
78
78
  "group.support.note": "附带文件的字节与正文共用一份索引;回退不恢复它们。",
79
79
  "empty.skills": "还没有技能留下过内容版本。",
80
80
  "empty.versions": "这个技能还没有记录版本。",
81
+ "empty.versions.failed": "版本读取失败——这个技能有没有版本,现在判断不了;点「刷新」重试。",
81
82
  "empty.pick": "从左栏选一个技能,看它记录过的版本。",
82
83
  loading: "读取中…",
83
84
  refresh: "刷新",
@@ -152,6 +153,7 @@ window.__ModuleLoader__.load({
152
153
  "group.support.note": "Support-file bytes share this index with the body; restoring the body does not bring them back.",
153
154
  "empty.skills": "No skill has recorded a content version yet.",
154
155
  "empty.versions": "This skill has no recorded versions.",
156
+ "empty.versions.failed": "The version read FAILED - whether this skill has versions is unknown right now; use Refresh to retry.",
155
157
  "empty.pick": "Pick a skill on the left to see the versions it recorded.",
156
158
  loading: "Loading…",
157
159
  refresh: "Refresh",
@@ -215,6 +217,15 @@ window.__ModuleLoader__.load({
215
217
  "error.hint": "The panel could not reach the host routes — usually a plugin/host version mismatch; restart dsh web and retry."
216
218
  };
217
219
  /**
220
+ * One message without the locale seat (the fallback path, and what a spec reads).
221
+ * @param language - which dictionary to read.
222
+ * @param key - the message key.
223
+ * @returns the copy, or the key itself when neither dictionary carries it.
224
+ */
225
+ function message(language, key) {
226
+ return (language === "en" ? en : zh)[key] ?? key;
227
+ }
228
+ /**
218
229
  * Fill one TEMPLATE’s `{n}` slots. The seat resolves the key, this fills it, so the panel needs
219
230
  * only the seat plus this rule and never learns which language it is rendering.
220
231
  * @param template - the copy, with `{slot}` placeholders.
@@ -853,6 +864,7 @@ window.__ModuleLoader__.load({
853
864
  const [skillsLoaded, setSkillsLoaded] = (0, react.useState)(false);
854
865
  const [selected, setSelected] = (0, react.useState)(void 0);
855
866
  const [payload, setPayload] = (0, react.useState)(void 0);
867
+ const [versionsFailed, setVersionsFailed] = (0, react.useState)(void 0);
856
868
  const [noteText, setNoteText] = (0, react.useState)(void 0);
857
869
  const [noteError, setNoteError] = (0, react.useState)(false);
858
870
  const [pending, setPending] = (0, react.useState)(void 0);
@@ -869,6 +881,8 @@ window.__ModuleLoader__.load({
869
881
  setNoteError(false);
870
882
  setNoteText(message);
871
883
  };
884
+ /** The sentence one failed read carries, in ONE place: every reader below reports the same way. */
885
+ const failedMessage = (error) => face.t("error") + ": " + (error instanceof Error ? error.message : String(error));
872
886
  /** Read the skills again, keeping the sentence: a re-read is not an answer to anything. */
873
887
  const reloadSkills = () => {
874
888
  face.loadSkills().then(setSkills).catch(failed).finally(() => {
@@ -886,6 +900,7 @@ window.__ModuleLoader__.load({
886
900
  readTicket.current = ticket;
887
901
  setSelected(name);
888
902
  setPayload(void 0);
903
+ setVersionsFailed(void 0);
889
904
  setPending(void 0);
890
905
  setExpanded(void 0);
891
906
  setDiffs(/* @__PURE__ */ new Map());
@@ -893,7 +908,9 @@ window.__ModuleLoader__.load({
893
908
  face.loadVersions(name).then((next) => {
894
909
  if (readTicket.current === ticket) setPayload(next);
895
910
  }).catch((error) => {
896
- if (readTicket.current === ticket) onFailure(error);
911
+ if (readTicket.current !== ticket) return;
912
+ setVersionsFailed(failedMessage(error));
913
+ onFailure(error);
897
914
  });
898
915
  };
899
916
  /** Open a skill from the list: another skill's sentence described another skill, so it goes away. */
@@ -944,7 +961,6 @@ window.__ModuleLoader__.load({
944
961
  const name = selected;
945
962
  const ticket = readTicket.current + 1;
946
963
  readTicket.current = ticket;
947
- const failedMessage = (error) => face.t("error") + ": " + (error instanceof Error ? error.message : String(error));
948
964
  if (kind === "diff") {
949
965
  setDiffs((current) => new Map(current).set(v, { kind: "loading" }));
950
966
  face.loadDiff(name, v).then((diff) => {
@@ -1036,7 +1052,7 @@ window.__ModuleLoader__.load({
1036
1052
  })))), (0, react.createElement)("div", { className: "evo-hist-main" }, panelHead(face, refresh), notice(noteText, noteError), selected === void 0 ? [emptyState(face.t(skills.length === 0 ? skillsLoaded ? "empty.skills" : "loading" : "empty.pick")), (0, react.createElement)("p", {
1037
1053
  key: "hint",
1038
1054
  className: "evo-hist-hint"
1039
- }, face.t("hint"))] : loaded ? null : emptyState(face.t("loading")), loaded ? (0, react.createElement)("div", null, group(face.t("group.content"), payload.content, void 0), group(face.t("group.support"), payload.support, face.t("group.support.note")), payload.content.length === 0 && payload.support.length === 0 ? (0, react.createElement)("p", { className: "evo-hist-hint" }, face.t("empty.versions")) : null) : null));
1055
+ }, face.t("hint"))] : loaded ? null : emptyState(versionsFailed === void 0 ? face.t("loading") : `${face.t("empty.versions.failed")} — ${versionsFailed}`), loaded ? (0, react.createElement)("div", null, group(face.t("group.content"), payload.content, void 0), group(face.t("group.support"), payload.support, face.t("group.support.note")), payload.content.length === 0 && payload.support.length === 0 ? (0, react.createElement)("p", { className: "evo-hist-hint" }, face.t("empty.versions")) : null) : null));
1040
1056
  }
1041
1057
  /**
1042
1058
  * The lazy-read cache without one key.
@@ -1073,44 +1089,30 @@ window.__ModuleLoader__.load({
1073
1089
  /** Row order inside the global panel list (beside the platform's own rows). */
1074
1090
  const PANEL_ORDER = 35;
1075
1091
  /**
1076
- * The row's icon: a platform-style line glyph, whose SIZE comes from the sidebar's contract and whose
1077
- * glyph BOX is the one the neighbouring rows draw at.
1092
+ * The row's icon: a platform-style line glyph, drawn at the edge the sidebar asks for.
1078
1093
  *
1079
- * Size: the platform hands every `sidebar.panellist` occupant `{ size, active }` (`SidebarRoot.tsx`
1080
- * `renderSlot('sidebar.panellist', { size: wide ? 16 : 18, active }, …)`), so the glyph takes its size
1081
- * from that prop; a glyph that sizes itself from CSS (`width: 100%`) resolves against an unsized flex
1082
- * box and fills the whole column.
1094
+ * The sidebar hands every `sidebar.panellist` occupant `{ size, active }` — its panel row renders
1095
+ * `renderSlot('sidebar.panellist', { size: wide ? 16 : 18, active }, { only: id })` and owns the glyph
1096
+ * slot, its inset and the gap to the label (`ui-sidebar/src/client/SidebarRoot.tsx`) — so the glyph
1097
+ * takes BOTH its edge and its box from that prop, exactly as the platform's own occupants do: their
1098
+ * `PluginsPanelIcon` and `TaskManagerIcon` hand `size` straight to their primitive.
1083
1099
  *
1084
- * Box: the three rows above (task board / SSH / skill centre) are NOT slot occupants — they inject
1085
- * plain DOM into the sidebar with their own CSS (`.entry{padding:0 10px;gap:8px}` plus a 24px icon box
1086
- * holding an 18px glyph, `shared/client/sidebar-entry-core.ts`), so their glyph ink starts 13px into
1087
- * the column and their label 42px. Inside the platform panel row (8px padding, 8px gap) those two
1088
- * lines are reached by a 26px box: `8 + (26 - 16) / 2 = 13` and `8 + 26 + 8 = 42`.
1089
- * Matching the neighbours is a CHOICE (the platform's own rows below — the workspace folders — sit at
1090
- * 8px and 32px); it keeps the four plugin rows reading as one column. The label carries the accessible
1091
- * name, so the glyph stays decoration.
1100
+ * A box of our own, sized to some other row's pixels, moves this label out of the column: measured in
1101
+ * the desktop's global panel list (2026-10-09, 0.2.0) a 26px box put the label at 56px while the
1102
+ * platform's own rows in that list sat at 46px. The label carries the accessible name, so the glyph
1103
+ * stays decoration.
1092
1104
  */
1093
- /** The glyph box in px: the neighbours' 24px icon box, placed to land on their two content lines. */
1094
- const GLYPH_BOX = 26;
1095
1105
  /** The glyph's own coordinate system: every path below is drawn inside 16 units. */
1096
1106
  const GLYPH_UNITS = 16;
1097
- /**
1098
- * Trim a computed viewBox number: three decimals stay readable and are far below one pixel.
1099
- * @param value - the raw number.
1100
- * @returns the value rounded to three decimals.
1101
- */
1102
- function round3(value) {
1103
- return Math.round(value * 1e3) / 1e3;
1104
- }
1107
+ /** The edge to draw at when the sidebar hands no size: its collapsed column asks for 18. */
1108
+ const DEFAULT_GLYPH_SIZE = 18;
1105
1109
  function PanelIcon(props = {}) {
1106
- const size = typeof props.size === "number" && props.size > 0 ? props.size : 18;
1107
- const units = round3(GLYPH_UNITS * GLYPH_BOX / size);
1108
- const inset = round3((units - GLYPH_UNITS) / 2);
1110
+ const size = typeof props.size === "number" && props.size > 0 ? props.size : DEFAULT_GLYPH_SIZE;
1109
1111
  return (0, react.createElement)("svg", {
1110
1112
  "aria-hidden": "true",
1111
- width: String(GLYPH_BOX),
1112
- height: String(GLYPH_BOX),
1113
- viewBox: String(-inset) + " " + String(-inset) + " " + String(units) + " " + String(units),
1113
+ width: String(size),
1114
+ height: String(size),
1115
+ viewBox: "0 0 " + String(GLYPH_UNITS) + " " + String(GLYPH_UNITS),
1114
1116
  fill: "none",
1115
1117
  stroke: "currentColor",
1116
1118
  strokeWidth: "1.5",
@@ -1129,13 +1131,22 @@ window.__ModuleLoader__.load({
1129
1131
  function apply(ctx) {
1130
1132
  const seam = ctx;
1131
1133
  const api = createSkillHistoryApi();
1134
+ let registered = false;
1132
1135
  ctx.effect(() => {
1133
- return seam.locale.register(NS, {
1134
- zh,
1135
- en
1136
- });
1136
+ try {
1137
+ const dispose = seam.locale.register(NS, {
1138
+ zh,
1139
+ en
1140
+ });
1141
+ registered = true;
1142
+ return dispose;
1143
+ } catch (error) {
1144
+ ctx.logger.warn(`evolution-skill-history: the locale seat refused the "${NS}" dictionary registration (${error instanceof Error ? error.message : String(error)}) — the panel label falls back to the bundle's own copy and the remaining registrations continue`);
1145
+ return () => {};
1146
+ }
1137
1147
  }, "evolution-skill-history: locale");
1138
- const t = seam.locale.bind(NS);
1148
+ const seatTranslator = registered ? seam.locale.bind(NS) : null;
1149
+ const t = (key) => seatTranslator === null ? message("zh", key) : seatTranslator(key);
1139
1150
  ctx.effect(() => {
1140
1151
  if (document.querySelector("style[data-plugin-css=\"@lmzhen/dsh-evolution-skill-history/panel.css\"]") === null) {
1141
1152
  const tag = document.createElement("style");
package/lib/index.js CHANGED
@@ -23,7 +23,7 @@ const SKILL_HISTORY_ROUTES = {
23
23
  */
24
24
  const MAX_BODY_CHARS = 2e4;
25
25
  /**
26
- * Build the four routes over the curator seam.
26
+ * Build the six routes over the curator seam.
27
27
  * @param services - the host services the handlers read.
28
28
  * @returns the route list for ctx.webServer.register.
29
29
  */
@@ -35,7 +35,7 @@ export interface RouteServices {
35
35
  readonly getCurator: () => CuratorFace | undefined;
36
36
  }
37
37
  /**
38
- * Build the four routes over the curator seam.
38
+ * Build the six routes over the curator seam.
39
39
  * @param services - the host services the handlers read.
40
40
  * @returns the route list for ctx.webServer.register.
41
41
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-skill-history",
3
3
  "description": "Web surface for skill content history: loopback host routes plus the sidebar panel (community build)",
4
- "version": "0.17.0",
4
+ "version": "0.18.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -30,23 +30,23 @@
30
30
  ],
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
- "@lmzhen/dsh-evolution-core": "^0.17.0"
33
+ "@lmzhen/dsh-evolution-core": "^0.18.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@deepseek-ai/cordis": "^4.0.1",
37
37
  "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
38
38
  "react": "^18.2.0",
39
- "@lmzhen/dsh-evolution-curator": "^0.17.0"
39
+ "@lmzhen/dsh-evolution-curator": "^0.18.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@deepseek-ai/cordis": "^4.0.1",
43
43
  "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
44
44
  "@types/react": "~18.3.1",
45
45
  "react": "^18.2.0",
46
- "@lmzhen/dsh-evolution-core": "^0.17.0",
47
- "@lmzhen/dsh-evolution-curator": "^0.17.0",
48
- "@lmzhen/dsh-evolution-io": "^0.17.0",
49
- "@lmzhen/dsh-evolution-io-node": "^0.17.0"
46
+ "@lmzhen/dsh-evolution-core": "^0.18.0",
47
+ "@lmzhen/dsh-evolution-curator": "^0.18.0",
48
+ "@lmzhen/dsh-evolution-io": "^0.18.0",
49
+ "@lmzhen/dsh-evolution-io-node": "^0.18.0"
50
50
  },
51
51
  "dsh": {
52
52
  "client": {