@lmzhen/dsh-evolution-skill-history 0.13.5 → 0.14.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
@@ -1,6 +1,6 @@
1
1
  # @lmzhen/dsh-evolution-skill-history
2
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 five loopback host routes it reads.
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 six loopback host routes it reads.
4
4
 
5
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
6
 
@@ -23,6 +23,7 @@ The label is a **thunk**: the sidebar re-reads it on every projection, so a lang
23
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
24
  | POST | `/api/dsh-evolution/skill-history/undo` | `curator.undo(name, v?)` — the only write |
25
25
  | 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
+ | 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` |
26
27
  | GET | `/api/dsh-evolution/skill-history/health` | liveness probe for the client |
27
28
 
28
29
  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.
@@ -33,15 +34,23 @@ The row is **inert without a web server** (`ctx.get('webServer')`, not a declare
33
34
 
34
35
  ## What the panel shows
35
36
 
36
- **The left column** lists the skills that recorded versions, one row per skill: its name, how many versions it has, its own one-line description, clipped with an ellipsis (the listing already carried it) and the management state the marker probe reported. A **search box** filters that list by name or description — client state only, no route behind it.
37
+ **The left column** lists the skills that recorded versions, one row per skill: its name, how many versions it has, its own one-line description, clipped with an ellipsis (the listing already carried it), the management state the marker probe reported and — on the description line, where it cannot squeeze the name — **when the content last changed**. That last fact comes from the index this route already reads (`latestVersionAt` across both chains); *last use* is a different fact and stays in the usage store. A **search box** filters that list by name or description — client state only, no route behind it.
37
38
 
38
- **The right pane** shows 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 reads like a sentence rather than a dump — `v3 targeted edit 3 hours ago (+1363 chars)`, where the absolute clock is the row's hover text — with a *current* capsule on the live content, **Restore this version** (an inline two-click confirmation, with a **Cancel** beside it once it is armed) on the others, and **Diff** expanding that version against its predecessor (lines added and removed, plus the changed region, marked truncated when it is long). After a restore the rows are read again — the restore itself is a new version — while the curator's result sentence stays on screen. **Refresh** re-reads the skill list and the open skill.
39
+ **The right pane** shows the selected skill's versions as two groups: **Body versions** (restorable) and **File versions** (history only, because a restore rewrites `SKILL.md`). A support file's bytes land in the second group even when the entry wears the `baseline` label — a support write's predecessor is that FILE's previous content, not a body version — because the entry's `path` says which artifact it holds, and for an entry recorded before that field existed the link the support write left behind (`beforeHash`) says it instead. A body row reads like a sentence rather than a dump — `v3 targeted edit 3 hours ago (+1363 chars)`, where the absolute clock is the row's hover text — with a *current* capsule on the live content, **Restore this version** (an inline two-click confirmation, with a **Cancel** beside it once it is armed) on the others, and **Diff** expanding that version against its predecessor (lines added and removed, plus the changed region, marked truncated when it is long). A support row names the file it holds (`references/notes.md`) once the entry records it, and says so plainly when it does not — entries written before the path was recorded have none, and the blob store cannot recover the name. After a restore the rows are read again — the restore itself is a new version — while the curator's result sentence stays on screen. **Refresh** re-reads the skill list and the open skill.
40
+
41
+ **Preview** renders one version as a document, support files included (the body route above). Inside the diff view, **Source / Rendered** switches between the exact window the host computed and the same two sides rendered as Markdown — removed first, added second, each under a tinted edge. Block level on purpose: line-level interleaving of two rendered documents is a different, much larger problem, and the source view stays one click away. Only one row is expanded at a time (`expanded: {v, kind}`), and each kind keeps its own lazily-filled cache.
39
42
 
40
43
  While nothing is selected the pane says so — a line pointing at the left column, or the empty listing when no skill has a history yet.
41
44
 
42
- **The panel owns no rule and no arithmetic.** The host reports every row with its whole verdict — `undoable` (evolution-core's artifact classification against the live bytes), `actionKind` (a closed vocabulary key), `age` (a bucket plus a count), `charsDelta`, `summary` — and this half substitutes words from its locale dictionary and renders. The time, the action words and the sentences are therefore localized; the facts are computed once, host-side, so the panel and the slash commands cannot drift.
45
+ **The panel owns no rule and no arithmetic.** The host reports every row with its whole verdict — `undoable` (evolution-core's `entryTarget` classification against the live bytes), `actionKind` (a closed vocabulary key), `age` (a bucket plus a count), `charsDelta`, `summary` — and this half substitutes words from its locale dictionary and renders. The time, the action words and the sentences are therefore localized; the facts are computed once, host-side, so the panel and the slash commands cannot drift.
46
+
47
+ **Appearance follows the platform's tokens, not its modules.** The panel carries its own stylesheet (`src/client/styles.ts`, injected once behind `data-plugin-css`) and copies its metrics from the platform's own components — the command card's `<pre>` for the diff block, the sidebar row's hover and focus ring for the controls, and the theme's semantic state pair (`--dsw-alias-state-success-primary` / `--dsw-alias-state-error-primary`) for the diff's added and removed lines — while type follows the shell rather than the conversation: the panel is one relative step below the shell's chrome size (`calc(1em - 1px)`, never the conversation-content setting) and steps again inside itself — `calc(1em + 2px)` for the title, `calc(1em - 1px)` for a group label. Only the diff block takes the platform's code-block token — it renders content, not chrome. The setting behind `--dsh-content-font-size*` is documented as affecting conversation content only, so the panel deliberately does not follow it. Two platform seams are reached differently, and the difference is deliberate. **Controls and styles are drawn here**, because a STATIC import of the platform's control package cannot compile from this tree: the workspace tsconfig maps the specifier to platform sources (`TS6059`/`TS6307`) and this package's `react.d.ts` shadows the real React types (`TS2305`); an ambient `declare module` for that specifier loses to the paths mapping. **The Markdown renderer is borrowed at runtime instead**: `src/client/markdown.ts` is the one seam, taking `MarkdownText` from `@deepseek-ai/dsh-client-ui-primitives` — a `PLATFORM_MODULES` row every dynamic bundle can `require`, exactly like `react` — through the loader-provided `require` (the bundler keeps `@deepseek-ai/*` external, so nothing is inlined and no dependency is installed). It renders with the platform's own mdast pipeline and the shell's own stylesheet, and when the row is absent the seam reports `{ok: false}` and every caller falls back to plain text: a missing renderer degrades a view, never the panel. `verify-arch-guards` rule **N24** keeps the drawn half honest — no literal `font-size`/`font`/`font-family` and no literal colour under `src/client`, only `var(--…)`, `calc(…)` or a CSS-wide keyword. The roll call of what the panel deliberately does not do is in `## Known limitations and deferred work`.
48
+
49
+ ## Where the boundaries are
50
+
51
+ **Restore is confirmed here and deliberately not in the CLI.** The panel's only write is a two-click confirmation with its own Cancel; `/evolution skill undo` keeps no prompt at all, because typing the command IS the intent and the face has to stay scriptable. That asymmetry is a decision, not an oversight — it is written down so the question does not reopen.
43
52
 
44
- **Appearance follows the platform's tokens, not its modules.** The panel carries its own stylesheet (`src/client/styles.ts`, injected once behind `data-plugin-css`) and copies its metrics from the platform's own components — the command card's `<pre>` for the diff block, the sidebar row's hover and focus ring for the controls, and the theme's semantic state pair (`--dsw-alias-state-success-primary` / `--dsw-alias-state-error-primary`) for the diff's added and removed lines — while type follows the shell rather than the conversation: the panel is one relative step below the shell's chrome size (`calc(1em - 1px)`, never the conversation-content setting) and steps again inside itself — `calc(1em + 2px)` for the title, `calc(1em - 1px)` for a group label. Only the diff block takes the platform's code-block token — it renders content, not chrome. The setting behind `--dsh-content-font-size*` is documented as affecting conversation content only, so the panel deliberately does not follow it. Importing the platform's control package is NOT possible from here without splitting this package's compiler faces: the specifier is path-mapped to source, and `tsc -b` then refuses the foreign project (host/client face split, `react` devDependencies and a CI graph change would all be needed). The roll call of what the panel deliberately does not do is in `## Known limitations and deferred work`.
53
+ **Browsing belongs to the skill centre; versions and restore belong here.** The panel lists the skills that recorded versions and shows nothing about curation or lifecycle beyond the marker facts the listing already carries, so it cannot grow into a second skill-management surface. The third-party skill browser (`dsh-client-ui-skill-explorer`) covers discovery; this panel covers what changed and how to go back.
45
54
 
46
55
  ## Build
47
56
 
@@ -53,10 +62,10 @@ This package adds **no model-visible content**: no prompt section, no tool schem
53
62
 
54
63
  ## Known limitations and deferred work
55
64
 
56
- - **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 — 0.11.3 is the cost of not having one: the first installed pass restored the content correctly and re-read the rows correctly while the curator's result sentence never appeared, because the reload that follows a restore cleared the note state it had just set. No Node-level spec and no `tsc` can see the order of two state updates; only clicking the panel showed it.
65
+ - **The rendered lane covers behaviour, not the assembled artifact.** `tests/panel.client.spec.ts` renders the panel under jsdom (result sentence survives the re-read, two-click confirmation with Cancel, a late rows reply for a skill the operator left is discarded, empty state and refusal) and `tests/client-apply.client.spec.ts` drives `apply` over a fake slot registry (one row, one keyed body, one locale namespace, and nothing left behind on dispose). What neither sees is the artifact the platform actually loads: module identity, the shell's stylesheet reaching the borrowed renderer, and the sidebar's own projection still need a live pass on an installed build.
57
66
  - **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.
58
67
  - **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).
59
- - **No rendered component spec yet.** The panel's state machine (result sentence, two-click confirmation, the read ticket that discards a late reply, the lazy diff) is pinned by `tsc`, the bundle build and live passes; a jsdom lane is the follow-up, and it needs `react` in this package's devDependencies plus one `pnpm install` in the tree the specs run in.
68
+ - **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.
60
69
 
61
70
  - **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.
62
71
  - **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
@@ -19,6 +19,7 @@ window.__ModuleLoader__.load({
19
19
  skills: "/api/dsh-evolution/skill-history/skills",
20
20
  versions: "/api/dsh-evolution/skill-history/versions",
21
21
  diff: "/api/dsh-evolution/skill-history/versions/diff",
22
+ body: "/api/dsh-evolution/skill-history/versions/body",
22
23
  undo: "/api/dsh-evolution/skill-history/undo"
23
24
  };
24
25
  /** A refusal the host reported, carrying its own sentence (the curator's, not ours). */
@@ -40,6 +41,7 @@ window.__ModuleLoader__.load({
40
41
  skills: async () => await request(HOST_ROUTES.skills),
41
42
  versions: async (name) => await request(HOST_ROUTES.versions + "?name=" + encodeURIComponent(name)),
42
43
  diff: async (name, v) => await request(HOST_ROUTES.diff + "?name=" + encodeURIComponent(name) + "&v=" + String(v)),
44
+ body: async (name, v) => await request(HOST_ROUTES.body + "?name=" + encodeURIComponent(name) + "&v=" + String(v)),
43
45
  undo: async (name, v) => {
44
46
  return (await request(HOST_ROUTES.undo, {
45
47
  method: "POST",
@@ -89,6 +91,15 @@ window.__ModuleLoader__.load({
89
91
  "row.delta.up": "(+{n} 字符)",
90
92
  "row.delta.down": "(-{n} 字符)",
91
93
  "diff.show": "差异",
94
+ "diff.source": "源码",
95
+ "preview.show": "预览",
96
+ "preview.hide": "收起预览",
97
+ "preview.truncated": "正文过长,只显示前一部分",
98
+ "path.unrecorded": "(路径未记录)",
99
+ "diff.rendered": "渲染",
100
+ "markdown.copy": "复制",
101
+ "markdown.copied": "已复制",
102
+ "markdown.footnotes": "脚注",
92
103
  "diff.hide": "收起差异",
93
104
  "diff.loading": "读取差异…",
94
105
  "diff.against": "与 v{n} 比较",
@@ -153,6 +164,15 @@ window.__ModuleLoader__.load({
153
164
  "row.delta.up": "(+{n} chars)",
154
165
  "row.delta.down": "(-{n} chars)",
155
166
  "diff.show": "Diff",
167
+ "diff.source": "Source",
168
+ "preview.show": "Preview",
169
+ "preview.hide": "Hide preview",
170
+ "preview.truncated": "the body is long; only its beginning is shown",
171
+ "path.unrecorded": "(path not recorded)",
172
+ "diff.rendered": "Rendered",
173
+ "markdown.copy": "Copy",
174
+ "markdown.copied": "Copied",
175
+ "markdown.footnotes": "Footnotes",
156
176
  "diff.hide": "Hide diff",
157
177
  "diff.loading": "Loading the diff…",
158
178
  "diff.against": "compared with v{n}",
@@ -268,10 +288,97 @@ window.__ModuleLoader__.load({
268
288
  ".evo-hist-button:focus-visible{outline:2px solid var(--dsw-alias-label-primary);outline-offset:-2px}",
269
289
  ".evo-hist-button[data-tone=\"primary\"]{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}",
270
290
  ".evo-hist-pre{margin:6px 0 2px;padding:12px 16px;max-height:260px;overflow:auto;border:.5px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-markdown-code-block);color:var(--dsw-alias-label-primary);font:var(--dsw-font-markdown-code-block-small);white-space:pre-wrap}",
291
+ ".evo-hist-diff-head{display:flex;align-items:center;gap:8px}",
292
+ ".evo-hist-diff-head .evo-hist-note{flex:1 1 auto;margin:0}",
293
+ ".evo-hist-diff-toggle{display:flex;flex:none;align-items:center;gap:6px}",
294
+ ".evo-hist-preview{margin:6px 0 2px;border:.5px solid var(--dsw-alias-border-l2);border-radius:12px;padding:4px 12px 12px;background:var(--dsw-alias-bg-layer-2);max-height:420px;overflow:auto}",
295
+ ".evo-hist-render{display:flex;flex-direction:column;gap:8px;margin:6px 0 2px}",
296
+ ".evo-hist-render-block{position:relative;padding:8px 12px 8px 22px;border:.5px solid var(--dsw-alias-border-l2);border-left-width:2px;border-radius:12px;background:var(--dsw-alias-markdown-code-block);overflow-x:auto}",
297
+ ".evo-hist-render-del{border-left-color:var(--dsw-alias-state-error-primary)}",
298
+ ".evo-hist-render-add{border-left-color:var(--dsw-alias-state-success-primary)}",
299
+ ".evo-hist-render-tag{position:absolute;left:8px;top:8px;color:var(--dsw-alias-label-tertiary)}",
271
300
  ".evo-hist-pre-add{color:var(--dsw-alias-state-success-primary)}",
272
301
  ".evo-hist-pre-del{color:var(--dsw-alias-state-error-primary)}"
273
302
  ].join("\n");
274
303
  //#endregion
304
+ //#region src/client/markdown.ts
305
+ /**
306
+ * The one seam onto the platform's Markdown renderer.
307
+ *
308
+ * Everything that renders Markdown in this panel goes through here, so the implementation can be
309
+ * swapped in one file: today it is the platform's own `MarkdownText` (reached through the client
310
+ * module table — zero bytes shipped, styles already in the shell, the same mdast pipeline the chat
311
+ * uses); if that ever stops being reachable, a self-drawn renderer replaces this module and nothing
312
+ * else changes.
313
+ *
314
+ * The words are NOT this module's: the face passes its locale dictionary in, because the platform
315
+ * component takes its chrome as props.
316
+ * @module @lmzhen/dsh-evolution-skill-history/client
317
+ */
318
+ /** The module-table specifier; kept in one place so the seam is the only reader. */
319
+ const PLATFORM_UI_MODULE = "@deepseek-ai/dsh-client-ui-primitives";
320
+ /**
321
+ * The platform component, when this deployment's module table carries one.
322
+ *
323
+ * A module that IS reachable without the export is not a renderer: reporting a render for it would
324
+ * move the failure into the render itself, where only the slot's error boundary would catch it.
325
+ * @returns the component, or undefined when it cannot be reached.
326
+ */
327
+ function markdownText() {
328
+ try {
329
+ const row = require(PLATFORM_UI_MODULE);
330
+ return typeof row.MarkdownText === "function" ? row.MarkdownText : void 0;
331
+ } catch {
332
+ return;
333
+ }
334
+ }
335
+ /**
336
+ * One document, rendered by the platform component under a labels object that survives re-renders.
337
+ *
338
+ * The component is memoized and the platform's own call sites memoize their labels for that reason:
339
+ * this panel re-renders on every keystroke in its search field, and a fresh labels object per render
340
+ * would take the whole document through the render again.
341
+ * @param props - the resolved component, the Markdown source, and the localized words.
342
+ * @returns the rendered document.
343
+ */
344
+ function MarkdownBlock(props) {
345
+ const { component: MarkdownText, text, words } = props;
346
+ const { copy, copied, footnotes } = words;
347
+ return (0, react.createElement)(MarkdownText, {
348
+ text,
349
+ labels: (0, react.useMemo)(() => ({
350
+ code: {
351
+ copyLabel: copy,
352
+ copiedLabel: copied
353
+ },
354
+ footnotes
355
+ }), [
356
+ copy,
357
+ copied,
358
+ footnotes
359
+ ])
360
+ });
361
+ }
362
+ /**
363
+ * Render one Markdown document to an element tree.
364
+ * @param text - the Markdown source (already bounded by the caller).
365
+ * @param words - the localized Markdown chrome.
366
+ * @returns the element, or `{ ok: false }` when the platform renderer is not reachable (the caller
367
+ * then keeps its own source view: a missing renderer degrades the view, never the panel).
368
+ */
369
+ function renderMarkdown(text, words) {
370
+ const component = markdownText();
371
+ if (component === void 0) return { ok: false };
372
+ return {
373
+ ok: true,
374
+ node: (0, react.createElement)(MarkdownBlock, {
375
+ component,
376
+ text,
377
+ words
378
+ })
379
+ };
380
+ }
381
+ //#endregion
275
382
  //#region src/client/Panel.ts
276
383
  /**
277
384
  * The skill-history panel: the skills that recorded versions, the two chains of the selected skill,
@@ -318,6 +425,21 @@ window.__ModuleLoader__.load({
318
425
  if (row.actionKind === "delete") return face.t("action.delete.note");
319
426
  if (row.actionKind === "archive") return face.t("action.archive.note");
320
427
  }
428
+ /**
429
+ * The lazy-read cache without one key.
430
+ *
431
+ * A read that a later toggle superseded must leave nothing behind: `loading` is not an answer, and
432
+ * the cache also serves as the "do not read twice" guard, so an abandoned entry would refuse every
433
+ * later read of that row and leave it saying "loading" until the page is reloaded.
434
+ * @param current - the cache.
435
+ * @param key - the version whose entry is dropped.
436
+ * @returns a new cache without that key.
437
+ */
438
+ function without(current, key) {
439
+ const next = new Map(current);
440
+ next.delete(key);
441
+ return next;
442
+ }
321
443
  /** The relative-time sentence for one row's bucket. */
322
444
  function ageText(face, age) {
323
445
  if (age.unit === "now") return face.t("time.now");
@@ -349,8 +471,47 @@ window.__ModuleLoader__.load({
349
471
  }
350
472
  return (0, react.createElement)("pre", { className: "evo-hist-pre" }, rows.length === 0 ? "±" : rows);
351
473
  }
474
+ /**
475
+ * The same diff, both sides rendered as Markdown and stacked: the removed text first, then the
476
+ * added one. Block level on purpose — line-level interleaving of two rendered documents is a
477
+ * different (and much larger) problem, and the source view stays one click away for exact bytes.
478
+ * @param face - copy plus the data callbacks.
479
+ * @param diff - the windowed change the host reported.
480
+ * @returns the stacked blocks.
481
+ */
482
+ function renderedBlocks(face, diff) {
483
+ const blocks = [];
484
+ for (const [index, hunk] of diff.hunks.entries()) {
485
+ if (hunk.oldText !== "") {
486
+ const side = renderedSide(face, "−", "del", hunk.oldText);
487
+ if (side === null) return null;
488
+ blocks.push((0, react.createElement)("div", { key: "del-" + String(index) }, side));
489
+ }
490
+ if (hunk.newText !== "") {
491
+ const side = renderedSide(face, "+", "add", hunk.newText);
492
+ if (side === null) return null;
493
+ blocks.push((0, react.createElement)("div", { key: "add-" + String(index) }, side));
494
+ }
495
+ }
496
+ return blocks.length === 0 ? null : blocks;
497
+ }
498
+ /**
499
+ * One diff in the mode the reader asked for, degrading to the source view when the renderer is out.
500
+ * @param face - copy plus the data callbacks.
501
+ * @param diff - the windowed change the host reported.
502
+ * @param mode - source or rendered.
503
+ * @returns the element to draw.
504
+ */
505
+ function diffView(face, diff, mode) {
506
+ if (mode === "rendered") {
507
+ const blocks = renderedBlocks(face, diff);
508
+ if (blocks !== null) return (0, react.createElement)("div", { className: "evo-hist-render" }, blocks);
509
+ }
510
+ return diffBlock(diff);
511
+ }
352
512
  /** One version row: what happened, when, how big, and what it is against. */
353
- function versionRow(face, row, confirming, openDiff, diff, restore, cancelRestore, toggleDiff) {
513
+ function versionRow(face, row, confirming, expanded, diff, body, restore, cancelRestore, toggle) {
514
+ const showing = (kind) => expanded !== void 0 && expanded.v === row.v && expanded.kind === kind;
354
515
  const parts = [
355
516
  (0, react.createElement)("span", {
356
517
  key: "v",
@@ -366,6 +527,15 @@ window.__ModuleLoader__.load({
366
527
  title: localTime(row.at)
367
528
  }, ageText(face, row.age))
368
529
  ];
530
+ if (row.path !== void 0) parts.push((0, react.createElement)("span", {
531
+ key: "path",
532
+ className: "evo-hist-row-meta",
533
+ title: row.path
534
+ }, row.path));
535
+ else if (row.actionKind === "support-write" || row.actionKind === "support-remove") parts.push((0, react.createElement)("span", {
536
+ key: "path",
537
+ className: "evo-hist-row-meta"
538
+ }, face.t("path.unrecorded")));
369
539
  const delta = deltaText(face, row);
370
540
  if (delta !== null) parts.push((0, react.createElement)("span", {
371
541
  key: "delta",
@@ -400,27 +570,90 @@ window.__ModuleLoader__.load({
400
570
  type: "button",
401
571
  className: "evo-hist-button",
402
572
  onClick: () => {
403
- toggleDiff(row.v);
573
+ toggle(row.v, "diff");
404
574
  }
405
- }, openDiff === row.v ? face.t("diff.hide") : face.t("diff.show")));
575
+ }, showing("diff") ? face.t("diff.hide") : face.t("diff.show")));
576
+ actions.push((0, react.createElement)("button", {
577
+ key: "preview",
578
+ type: "button",
579
+ className: "evo-hist-button",
580
+ onClick: () => {
581
+ toggle(row.v, "preview");
582
+ }
583
+ }, showing("preview") ? face.t("preview.hide") : face.t("preview.show")));
406
584
  return (0, react.createElement)("div", {
407
585
  key: String(row.v),
408
586
  className: "evo-hist-row"
409
- }, (0, react.createElement)("div", { className: "evo-hist-row-body" }, (0, react.createElement)("div", { className: "evo-hist-row-title" }, parts), row.summary === void 0 || row.summary === "" ? null : (0, react.createElement)("div", { className: "evo-hist-row-summary" }, row.summary), openDiff === row.v && diff !== void 0 ? diffBody(face, diff) : null), (0, react.createElement)("div", { className: "evo-hist-actions" }, actions));
587
+ }, (0, react.createElement)("div", { className: "evo-hist-row-body" }, (0, react.createElement)("div", { className: "evo-hist-row-title" }, parts), row.summary === void 0 || row.summary === "" ? null : (0, react.createElement)("div", { className: "evo-hist-row-summary" }, row.summary), showing("diff") && diff !== void 0 ? (0, react.createElement)(DiffBody, {
588
+ face,
589
+ diff
590
+ }) : null, showing("preview") && body !== void 0 ? (0, react.createElement)(BodyView, {
591
+ face,
592
+ body
593
+ }) : null), (0, react.createElement)("div", { className: "evo-hist-actions" }, actions));
594
+ }
595
+ /** One rendered side of a diff: an "old" or "new" block under a tinted edge. */
596
+ function renderedSide(face, mark, side, text) {
597
+ const rendered = renderMarkdown(text, face.markdownWords);
598
+ if (!rendered.ok) return null;
599
+ return (0, react.createElement)("div", { className: "evo-hist-render-block evo-hist-render-" + side }, (0, react.createElement)("span", { className: "evo-hist-render-tag" }, mark), rendered.node);
410
600
  }
411
- /** The expanded body of one row: the diff, a loading line, or the refusal. */
412
- function diffBody(face, diff) {
601
+ /**
602
+ * One version read as a document: the platform renderer when it is there, plain text when it is not.
603
+ * A preview therefore degrades to the exact bytes instead of blanking.
604
+ * @param props - the face and the body state to draw.
605
+ * @returns the element.
606
+ */
607
+ function BodyView(props) {
608
+ const { face, body } = props;
609
+ if (body.kind === "loading") return (0, react.createElement)("p", { className: "evo-hist-note" }, face.t("diff.loading"));
610
+ if (body.kind === "failed") return (0, react.createElement)("p", {
611
+ className: "evo-hist-note",
612
+ "data-error": "true"
613
+ }, body.message);
614
+ const rendered = renderMarkdown(body.body.text, face.markdownWords);
615
+ return (0, react.createElement)("div", { className: "evo-hist-preview" }, body.body.truncated ? (0, react.createElement)("p", { className: "evo-hist-note" }, face.t("preview.truncated")) : null, rendered.ok ? rendered.node : (0, react.createElement)("pre", { className: "evo-hist-pre" }, body.body.text));
616
+ }
617
+ /**
618
+ * The expanded body of one row: the diff, a loading line, or the refusal.
619
+ *
620
+ * The source/rendered toggle is LOCAL to this view: which row is open is the panel's business, but
621
+ * how one open diff is displayed is not, so the state does not climb into the parent.
622
+ * @param props - the face and the diff state to draw.
623
+ * @returns the element.
624
+ */
625
+ function DiffBody(props) {
626
+ const { face, diff } = props;
627
+ const [mode, setMode] = (0, react.useState)("source");
413
628
  if (diff.kind === "loading") return (0, react.createElement)("p", { className: "evo-hist-note" }, face.t("diff.loading"));
414
629
  if (diff.kind === "failed") return (0, react.createElement)("p", {
415
630
  className: "evo-hist-note",
416
631
  "data-error": "true"
417
632
  }, diff.message);
418
- return (0, react.createElement)("div", null, (0, react.createElement)("p", { className: "evo-hist-note" }, (diff.diff.against === null ? face.t("diff.first") : face.format("diff.against", { n: diff.diff.against })) + " · " + face.format("diff.added", { n: diff.diff.linesAdded }) + " · " + face.format("diff.removed", { n: diff.diff.linesRemoved }) + (diff.diff.truncated ? " · " + face.t("diff.truncated") : "")), diffBlock(diff.diff));
633
+ const against = diff.diff.against === null ? face.t("diff.first") : face.format("diff.against", { n: diff.diff.against });
634
+ const toggle = (0, react.createElement)("div", { className: "evo-hist-diff-toggle" }, (0, react.createElement)("button", {
635
+ key: "source",
636
+ type: "button",
637
+ className: "evo-hist-button",
638
+ "data-tone": mode === "source" ? "primary" : void 0,
639
+ onClick: () => {
640
+ setMode("source");
641
+ }
642
+ }, face.t("diff.source")), (0, react.createElement)("button", {
643
+ key: "rendered",
644
+ type: "button",
645
+ className: "evo-hist-button",
646
+ "data-tone": mode === "rendered" ? "primary" : void 0,
647
+ onClick: () => {
648
+ setMode("rendered");
649
+ }
650
+ }, face.t("diff.rendered")));
651
+ return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "evo-hist-diff-head" }, (0, react.createElement)("p", { className: "evo-hist-note" }, against + " · " + face.format("diff.added", { n: diff.diff.linesAdded }) + " · " + face.format("diff.removed", { n: diff.diff.linesRemoved }) + (diff.diff.truncated ? " · " + face.t("diff.truncated") : "")), toggle), diffView(face, diff.diff, mode));
419
652
  }
420
653
  /** One group: a heading, an optional note, and its rows. */
421
654
  function group(face, title, entries, noteText, state) {
422
655
  if (entries.length === 0) return null;
423
- return (0, react.createElement)("section", { key: title }, (0, react.createElement)("h3", { className: "evo-hist-group" }, title), noteText === void 0 ? null : (0, react.createElement)("p", { className: "evo-hist-hint" }, noteText), ...entries.map((entry) => versionRow(face, entry, state.confirming, state.openDiff, state.diffs.get(entry.v), state.restore, state.cancelRestore, state.toggleDiff)));
656
+ return (0, react.createElement)("section", { key: title }, (0, react.createElement)("h3", { className: "evo-hist-group" }, title), noteText === void 0 ? null : (0, react.createElement)("p", { className: "evo-hist-hint" }, noteText), ...entries.map((entry) => versionRow(face, entry, state.confirming, state.expanded, state.diffs.get(entry.v), state.bodies.get(entry.v), state.restore, state.cancelRestore, state.toggle)));
424
657
  }
425
658
  /**
426
659
  * The panel body.
@@ -435,8 +668,9 @@ window.__ModuleLoader__.load({
435
668
  const [noteError, setNoteError] = (0, react.useState)(false);
436
669
  const [pending, setPending] = (0, react.useState)(void 0);
437
670
  const [query, setQuery] = (0, react.useState)("");
438
- const [openDiff, setOpenDiff] = (0, react.useState)(void 0);
671
+ const [expanded, setExpanded] = (0, react.useState)(void 0);
439
672
  const [diffs, setDiffs] = (0, react.useState)(/* @__PURE__ */ new Map());
673
+ const [bodies, setBodies] = (0, react.useState)(/* @__PURE__ */ new Map());
440
674
  const readTicket = (0, react.useRef)(0);
441
675
  const failed = (error) => {
442
676
  setNoteError(true);
@@ -462,8 +696,9 @@ window.__ModuleLoader__.load({
462
696
  setSelected(name);
463
697
  setPayload(void 0);
464
698
  setPending(void 0);
465
- setOpenDiff(void 0);
699
+ setExpanded(void 0);
466
700
  setDiffs(/* @__PURE__ */ new Map());
701
+ setBodies(/* @__PURE__ */ new Map());
467
702
  face.loadVersions(name).then((next) => {
468
703
  if (readTicket.current === ticket) setPayload(next);
469
704
  }).catch((error) => {
@@ -499,30 +734,67 @@ window.__ModuleLoader__.load({
499
734
  succeeded(message);
500
735
  }).catch(failed);
501
736
  };
502
- const toggleDiff = (v) => {
737
+ /**
738
+ * Expand one row, or collapse it: one row is open at a time, and each kind keeps its own lazy read.
739
+ * @param v - the version whose row was clicked.
740
+ * @param kind - which expansion the row should show.
741
+ */
742
+ const toggle = (v, kind) => {
503
743
  if (selected === void 0) return;
504
- if (openDiff === v) {
505
- setOpenDiff(void 0);
744
+ if (expanded !== void 0 && expanded.v === v && expanded.kind === kind) {
745
+ setExpanded(void 0);
506
746
  return;
507
747
  }
508
- setOpenDiff(v);
509
- if (diffs.has(v)) return;
748
+ setExpanded({
749
+ v,
750
+ kind
751
+ });
752
+ if (kind === "diff" ? diffs.has(v) : bodies.has(v)) return;
510
753
  const name = selected;
511
754
  const ticket = readTicket.current + 1;
512
755
  readTicket.current = ticket;
513
- setDiffs((current) => new Map(current).set(v, { kind: "loading" }));
514
- face.loadDiff(name, v).then((diff) => {
515
- if (readTicket.current !== ticket) return;
516
- setDiffs((current) => new Map(current).set(v, {
756
+ const failedMessage = (error) => face.t("error") + ": " + (error instanceof Error ? error.message : String(error));
757
+ if (kind === "diff") {
758
+ setDiffs((current) => new Map(current).set(v, { kind: "loading" }));
759
+ face.loadDiff(name, v).then((diff) => {
760
+ if (readTicket.current !== ticket) {
761
+ setDiffs((current) => without(current, v));
762
+ return;
763
+ }
764
+ setDiffs((current) => new Map(current).set(v, {
765
+ kind: "ready",
766
+ diff
767
+ }));
768
+ }).catch((error) => {
769
+ if (readTicket.current !== ticket) {
770
+ setDiffs((current) => without(current, v));
771
+ return;
772
+ }
773
+ setDiffs((current) => new Map(current).set(v, {
774
+ kind: "failed",
775
+ message: failedMessage(error)
776
+ }));
777
+ });
778
+ return;
779
+ }
780
+ setBodies((current) => new Map(current).set(v, { kind: "loading" }));
781
+ face.loadBody(name, v).then((body) => {
782
+ if (readTicket.current !== ticket) {
783
+ setBodies((current) => without(current, v));
784
+ return;
785
+ }
786
+ setBodies((current) => new Map(current).set(v, {
517
787
  kind: "ready",
518
- diff
788
+ body
519
789
  }));
520
790
  }).catch((error) => {
521
- if (readTicket.current !== ticket) return;
522
- const message = face.t("error") + ": " + (error instanceof Error ? error.message : String(error));
523
- setDiffs((current) => new Map(current).set(v, {
791
+ if (readTicket.current !== ticket) {
792
+ setBodies((current) => without(current, v));
793
+ return;
794
+ }
795
+ setBodies((current) => new Map(current).set(v, {
524
796
  kind: "failed",
525
- message
797
+ message: failedMessage(error)
526
798
  }));
527
799
  });
528
800
  };
@@ -536,11 +808,12 @@ window.__ModuleLoader__.load({
536
808
  };
537
809
  const rowState = {
538
810
  confirming: pending,
539
- openDiff,
811
+ expanded,
540
812
  diffs,
813
+ bodies,
541
814
  restore,
542
815
  cancelRestore,
543
- toggleDiff
816
+ toggle
544
817
  };
545
818
  return (0, react.createElement)("div", { className: "evo-hist-root" }, (0, react.createElement)("aside", { className: "evo-hist-aside" }, (0, react.createElement)("div", { className: "evo-hist-search" }, (0, react.createElement)("input", {
546
819
  type: "search",
@@ -559,7 +832,7 @@ window.__ModuleLoader__.load({
559
832
  onClick: () => {
560
833
  open(skill.name);
561
834
  }
562
- }, (0, react.createElement)("span", { className: "evo-hist-skill-line" }, (0, react.createElement)("span", { className: "evo-hist-skill-name" }, skill.name), (0, react.createElement)("span", { className: "evo-hist-skill-count" }, String(skill.versions) + " " + face.t("versions.count"))), (0, react.createElement)("span", { className: "evo-hist-skill-desc" }, oneLine(skill.description) + " · " + face.t(stateKey(skill))))))), (0, react.createElement)("div", { className: "evo-hist-main" }, (0, react.createElement)("div", { className: "evo-hist-head" }, (0, react.createElement)("h2", { className: "evo-hist-title" }, face.t("title")), (0, react.createElement)("button", {
835
+ }, (0, react.createElement)("span", { className: "evo-hist-skill-line" }, (0, react.createElement)("span", { className: "evo-hist-skill-name" }, skill.name), (0, react.createElement)("span", { className: "evo-hist-skill-count" }, String(skill.versions) + " " + face.t("versions.count"))), (0, react.createElement)("span", { className: "evo-hist-skill-desc" }, oneLine(skill.description) + " · " + face.t(stateKey(skill)) + (skill.age === null ? "" : " · " + ageText(face, skill.age))))))), (0, react.createElement)("div", { className: "evo-hist-main" }, (0, react.createElement)("div", { className: "evo-hist-head" }, (0, react.createElement)("h2", { className: "evo-hist-title" }, face.t("title")), (0, react.createElement)("button", {
563
836
  type: "button",
564
837
  className: "evo-hist-button",
565
838
  onClick: refresh
@@ -679,7 +952,13 @@ window.__ModuleLoader__.load({
679
952
  loadSkills: api.skills,
680
953
  loadVersions: api.versions,
681
954
  loadDiff: api.diff,
682
- undo: api.undo
955
+ loadBody: api.body,
956
+ undo: api.undo,
957
+ markdownWords: {
958
+ copy: t("markdown.copy"),
959
+ copied: t("markdown.copied"),
960
+ footnotes: t("markdown.footnotes")
961
+ }
683
962
  })
684
963
  }, SkillHistoryPanel);
685
964
  });
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { contentHash, elapsedSince, errorText, partitionVersions, textDiffFacts, versionActionKind } from "@lmzhen/dsh-evolution-core";
1
+ import { contentHash, elapsedSince, errorText, latestVersionAt, partitionVersions, textDiffFacts, versionActionKind } from "@lmzhen/dsh-evolution-core";
2
2
  //#region lib/types/routes.js
3
3
  /**
4
- * The skill-history HTTP surface: five loopback routes over the curator's read/write seam (four reads plus the one write).
4
+ * The skill-history HTTP surface: six loopback routes over the curator's read/write seam (five reads plus the one write).
5
5
  *
6
6
  * Layering: this module maps a route to a USE CASE and does nothing else. "Which artifact does this
7
7
  * version hold" and "how do the two chains split" live in evolution-core; the only write is
@@ -13,9 +13,15 @@ const SKILL_HISTORY_ROUTES = {
13
13
  skills: "/api/dsh-evolution/skill-history/skills",
14
14
  versions: "/api/dsh-evolution/skill-history/versions",
15
15
  diff: "/api/dsh-evolution/skill-history/versions/diff",
16
+ body: "/api/dsh-evolution/skill-history/versions/body",
16
17
  undo: "/api/dsh-evolution/skill-history/undo",
17
18
  health: "/api/dsh-evolution/skill-history/health"
18
19
  };
20
+ /**
21
+ * How much of one version's body the read route hands back. A bound on the ANSWER, not on what the
22
+ * deployment may store: a long skill arrives as its head plus `truncated`, so the reader is told.
23
+ */
24
+ const MAX_BODY_CHARS = 2e4;
19
25
  function header(request, name) {
20
26
  const value = request.headers[name];
21
27
  return typeof value === "string" ? value : void 0;
@@ -139,16 +145,20 @@ function makeSkillHistoryRoutes(services) {
139
145
  await withCurator(res, async (curator) => {
140
146
  const listed = await curator.skills.list();
141
147
  const withHistory = [];
148
+ const now = Date.now();
142
149
  for (const skill of listed) {
143
150
  const versions = await curator.skills.listVersions(skill.name);
144
151
  if (versions.length === 0) continue;
152
+ const lastAt = latestVersionAt(versions);
145
153
  withHistory.push({
146
154
  name: skill.name,
147
155
  versions: versions.length,
148
156
  description: skill.description,
149
157
  managed: skill.managed,
150
158
  protectedBy: skill.protectedBy,
151
- protectionUnknown: skill.protectionUnknown
159
+ protectionUnknown: skill.protectionUnknown,
160
+ lastAt,
161
+ age: lastAt === null ? null : elapsedSince(lastAt, now)
152
162
  });
153
163
  }
154
164
  writeJson(res, 200, {
@@ -252,6 +262,46 @@ function makeSkillHistoryRoutes(services) {
252
262
  });
253
263
  }
254
264
  },
265
+ {
266
+ kind: "exact",
267
+ path: SKILL_HISTORY_ROUTES.body,
268
+ handler: async (req, res) => {
269
+ if (!guard(req, res, "GET")) return;
270
+ const params = new URL(req.url ?? "/", "http://loopback").searchParams;
271
+ const name = params.get("name")?.trim() ?? "";
272
+ const rawV = params.get("v")?.trim() ?? "";
273
+ const v = Number.parseInt(rawV, 10);
274
+ if (name === "" || !/^\d+$/.test(rawV) || !Number.isInteger(v) || v < 1) {
275
+ writeJson(res, 400, {
276
+ ok: false,
277
+ code: "bad-request",
278
+ message: "name and a positive integer v are required"
279
+ });
280
+ return;
281
+ }
282
+ await withCurator(res, async (curator) => {
283
+ const text = await curator.skills.readVersion(name, v);
284
+ if (text === null) {
285
+ writeJson(res, 404, {
286
+ ok: false,
287
+ code: "not-found",
288
+ message: "the stored content of that version could not be read"
289
+ });
290
+ return;
291
+ }
292
+ const truncated = text.length > MAX_BODY_CHARS;
293
+ writeJson(res, 200, {
294
+ ok: true,
295
+ data: {
296
+ v,
297
+ text: truncated ? text.slice(0, MAX_BODY_CHARS) : text,
298
+ chars: text.length,
299
+ truncated
300
+ }
301
+ });
302
+ });
303
+ }
304
+ },
255
305
  {
256
306
  kind: "exact",
257
307
  path: SKILL_HISTORY_ROUTES.undo,
@@ -14,7 +14,8 @@
14
14
  * @module @lmzhen/dsh-evolution-skill-history/client
15
15
  */
16
16
  import { type ReactNode } from 'react';
17
- import type { SkillRow, VersionDiffRow, VersionsPayload } from './api.ts';
17
+ import type { SkillRow, VersionBodyRow, VersionDiffRow, VersionsPayload } from './api.ts';
18
+ import { type MarkdownWords } from './markdown.ts';
18
19
  /** The face the plugin hands the component: copy plus callbacks, never a service handle. */
19
20
  export interface PanelFace {
20
21
  readonly t: (key: string) => string;
@@ -23,7 +24,10 @@ export interface PanelFace {
23
24
  readonly loadSkills: () => Promise<readonly SkillRow[]>;
24
25
  readonly loadVersions: (name: string) => Promise<VersionsPayload>;
25
26
  readonly loadDiff: (name: string, v: number) => Promise<VersionDiffRow>;
27
+ readonly loadBody: (name: string, v: number) => Promise<VersionBodyRow>;
26
28
  readonly undo: (name: string, v?: number) => Promise<string>;
29
+ /** The two words the platform Markdown renderer needs; the dictionary owns them. */
30
+ readonly markdownWords: MarkdownWords;
27
31
  }
28
32
  /**
29
33
  * The panel body.
@@ -11,6 +11,7 @@ export declare const HOST_ROUTES: {
11
11
  readonly skills: "/api/dsh-evolution/skill-history/skills";
12
12
  readonly versions: "/api/dsh-evolution/skill-history/versions";
13
13
  readonly diff: "/api/dsh-evolution/skill-history/versions/diff";
14
+ readonly body: "/api/dsh-evolution/skill-history/versions/body";
14
15
  readonly undo: "/api/dsh-evolution/skill-history/undo";
15
16
  };
16
17
  /** One changed region, as the host reports it (the platform diff card's own shape). */
@@ -49,6 +50,11 @@ export interface VersionRow {
49
50
  };
50
51
  /** The one line the optional summarizer wrote, when the deployment turned it on. */
51
52
  readonly summary?: string;
53
+ /**
54
+ * Which support file these bytes came from, relative to the skill directory. Absent on body rows,
55
+ * and absent on every support row recorded before the field existed (the bytes carry no name).
56
+ */
57
+ readonly path?: string;
52
58
  }
53
59
  /** One skill that has recorded versions, with what the listing already knows about it. */
54
60
  export interface SkillRow {
@@ -58,6 +64,22 @@ export interface SkillRow {
58
64
  readonly managed: boolean;
59
65
  readonly protectedBy: string | null;
60
66
  readonly protectionUnknown: boolean;
67
+ /** When this skill's content last changed (either chain), or null when the index is empty. */
68
+ readonly lastAt: string | null;
69
+ /** That moment as a bucket plus a count; null exactly when `lastAt` is. */
70
+ readonly age: {
71
+ readonly unit: string;
72
+ readonly n: number;
73
+ } | null;
74
+ }
75
+ /** One version's whole body, as the read route hands it over. */
76
+ export interface VersionBodyRow {
77
+ readonly v: number;
78
+ /** The body, or its head when `truncated` — the reader is told rather than silently cut. */
79
+ readonly text: string;
80
+ /** How long the stored body is, whatever came back. */
81
+ readonly chars: number;
82
+ readonly truncated: boolean;
61
83
  }
62
84
  /** The two chains, plus the live body hash the panel marks as "current". */
63
85
  export interface VersionsPayload {
@@ -71,6 +93,7 @@ export interface SkillHistoryApi {
71
93
  readonly versions: (name: string) => Promise<VersionsPayload>;
72
94
  readonly undo: (name: string, v?: number) => Promise<string>;
73
95
  readonly diff: (name: string, v: number) => Promise<VersionDiffRow>;
96
+ readonly body: (name: string, v: number) => Promise<VersionBodyRow>;
74
97
  }
75
98
  /** A refusal the host reported, carrying its own sentence (the curator's, not ours). */
76
99
  export declare class SkillHistoryRefusal extends Error {
@@ -0,0 +1,23 @@
1
+ /** The three localized words the platform renderer needs; the face owns them. */
2
+ export interface MarkdownWords {
3
+ readonly copy: string;
4
+ readonly copied: string;
5
+ /** The heading of the generated footnote section. */
6
+ readonly footnotes: string;
7
+ }
8
+ /** One render attempt: the element, or the news that this deployment has no renderer. */
9
+ export type MarkdownRender = {
10
+ readonly ok: true;
11
+ readonly node: unknown;
12
+ } | {
13
+ readonly ok: false;
14
+ };
15
+ /**
16
+ * Render one Markdown document to an element tree.
17
+ * @param text - the Markdown source (already bounded by the caller).
18
+ * @param words - the localized Markdown chrome.
19
+ * @returns the element, or `{ ok: false }` when the platform renderer is not reachable (the caller
20
+ * then keeps its own source view: a missing renderer degrades the view, never the panel).
21
+ */
22
+ export declare function renderMarkdown(text: string, words: MarkdownWords): MarkdownRender;
23
+ //# sourceMappingURL=markdown.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The skill-history HTTP surface: five loopback routes over the curator's read/write seam (four reads plus the one write).
2
+ * The skill-history HTTP surface: six loopback routes over the curator's read/write seam (five reads plus the one write).
3
3
  *
4
4
  * Layering: this module maps a route to a USE CASE and does nothing else. "Which artifact does this
5
5
  * version hold" and "how do the two chains split" live in evolution-core; the only write is
@@ -14,11 +14,17 @@ export declare const SKILL_HISTORY_ROUTES: {
14
14
  readonly skills: "/api/dsh-evolution/skill-history/skills";
15
15
  readonly versions: "/api/dsh-evolution/skill-history/versions";
16
16
  readonly diff: "/api/dsh-evolution/skill-history/versions/diff";
17
+ readonly body: "/api/dsh-evolution/skill-history/versions/body";
17
18
  readonly undo: "/api/dsh-evolution/skill-history/undo";
18
19
  readonly health: "/api/dsh-evolution/skill-history/health";
19
20
  };
20
21
  /** Largest request body these routes accept (the undo payload is a name and a number). */
21
22
  export declare const MAX_REQUEST_BODY_BYTES: number;
23
+ /**
24
+ * How much of one version's body the read route hands back. A bound on the ANSWER, not on what the
25
+ * deployment may store: a long skill arrives as its head plus `truncated`, so the reader is told.
26
+ */
27
+ export declare const MAX_BODY_CHARS = 20000;
22
28
  /** The request facts the trust fence reads: a Node request, or the same fields in a spec fixture. */
23
29
  export interface FenceRequest {
24
30
  readonly headers: Record<string, string | string[] | undefined>;
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.13.5",
4
+ "version": "0.14.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -30,21 +30,23 @@
30
30
  ],
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
- "@lmzhen/dsh-evolution-core": "^0.13.5"
33
+ "@lmzhen/dsh-evolution-core": "^0.14.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@deepseek-ai/cordis": "^4.0.1",
37
37
  "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
38
38
  "react": "^18.2.0",
39
- "@lmzhen/dsh-evolution-curator": "^0.13.5"
39
+ "@lmzhen/dsh-evolution-curator": "^0.14.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@deepseek-ai/cordis": "^4.0.1",
43
43
  "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
44
- "@lmzhen/dsh-evolution-core": "^0.13.5",
45
- "@lmzhen/dsh-evolution-curator": "^0.13.5",
46
- "@lmzhen/dsh-evolution-io": "^0.13.5",
47
- "@lmzhen/dsh-evolution-io-node": "^0.13.5"
44
+ "@types/react": "~18.3.1",
45
+ "react": "^18.2.0",
46
+ "@lmzhen/dsh-evolution-core": "^0.14.0",
47
+ "@lmzhen/dsh-evolution-curator": "^0.14.0",
48
+ "@lmzhen/dsh-evolution-io": "^0.14.0",
49
+ "@lmzhen/dsh-evolution-io-node": "^0.14.0"
48
50
  },
49
51
  "dsh": {
50
52
  "client": {