@lmzhen/dsh-evolution-skill-history 0.15.2 → 0.16.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 +5 -1
- package/lib/client.js +6 -3
- package/lib/index.js +2 -86
- package/lib/types/routes.d.ts +0 -30
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -40,7 +40,11 @@ The row is **inert without a web server** (`ctx.get('webServer')`, not a declare
|
|
|
40
40
|
|
|
41
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.
|
|
42
42
|
|
|
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.
|
|
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. **Reading and empty are different states** (E7, 2026-09-30): until the
|
|
44
|
+
first list read lands the column says it is reading, and a FAILED read lands on the empty listing plus the error sentence instead of a spinner that never stops.
|
|
45
|
+
|
|
46
|
+
A removed support file now shows up too: its removal is recorded as its own entry (see evolution-core's note),
|
|
47
|
+
so the file's chain ends with a `support file removed` row whose undo restores the bytes.
|
|
44
48
|
|
|
45
49
|
**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 the row's cells**: `evolution-core`'s `skill-row-cells.ts` decides what the row says, in which order and in which slot (`lead` / `meta` / `aside`), and the host ships that list with the row, because a browser half cannot import core at runtime. This half substitutes words from its locale dictionary and renders by slot, so a decision fact (`aside`) can no longer be eaten by the browser's ellipsis. 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
50
|
|
package/lib/client.js
CHANGED
|
@@ -850,6 +850,7 @@ window.__ModuleLoader__.load({
|
|
|
850
850
|
*/
|
|
851
851
|
function SkillHistoryPanel(face) {
|
|
852
852
|
const [skills, setSkills] = (0, react.useState)([]);
|
|
853
|
+
const [skillsLoaded, setSkillsLoaded] = (0, react.useState)(false);
|
|
853
854
|
const [selected, setSelected] = (0, react.useState)(void 0);
|
|
854
855
|
const [payload, setPayload] = (0, react.useState)(void 0);
|
|
855
856
|
const [noteText, setNoteText] = (0, react.useState)(void 0);
|
|
@@ -870,7 +871,9 @@ window.__ModuleLoader__.load({
|
|
|
870
871
|
};
|
|
871
872
|
/** Read the skills again, keeping the sentence: a re-read is not an answer to anything. */
|
|
872
873
|
const reloadSkills = () => {
|
|
873
|
-
face.loadSkills().then(setSkills).catch(failed)
|
|
874
|
+
face.loadSkills().then(setSkills).catch(failed).finally(() => {
|
|
875
|
+
setSkillsLoaded(true);
|
|
876
|
+
});
|
|
874
877
|
};
|
|
875
878
|
/**
|
|
876
879
|
* Show one skill's chains, keeping the sentence. A re-read that FAILS reports through the caller's
|
|
@@ -1028,9 +1031,9 @@ window.__ModuleLoader__.load({
|
|
|
1028
1031
|
}
|
|
1029
1032
|
})));
|
|
1030
1033
|
};
|
|
1031
|
-
return (0, react.createElement)("div", { className: "evo-hist-root" }, (0, react.createElement)("aside", { className: "evo-hist-aside" }, searchField(face, query, setQuery), (0, react.createElement)("div", { className: "evo-hist-list" }, skills.length === 0 ? emptyState(face.t("empty.skills")) : visible.length === 0 ? emptyState(face.t("search.none")) : visible.map((skill) => skillLine(face, skill, skill.name === selected, () => {
|
|
1034
|
+
return (0, react.createElement)("div", { className: "evo-hist-root" }, (0, react.createElement)("aside", { className: "evo-hist-aside" }, searchField(face, query, setQuery), (0, react.createElement)("div", { className: "evo-hist-list" }, skills.length === 0 ? emptyState(face.t(skillsLoaded ? "empty.skills" : "loading")) : visible.length === 0 ? emptyState(face.t("search.none")) : visible.map((skill) => skillLine(face, skill, skill.name === selected, () => {
|
|
1032
1035
|
open(skill.name);
|
|
1033
|
-
})))), (0, react.createElement)("div", { className: "evo-hist-main" }, panelHead(face, refresh), notice(noteText, noteError), selected === void 0 ? [emptyState(face.t(skills.length === 0 ? "empty.skills" : "empty.pick")), (0, react.createElement)("p", {
|
|
1036
|
+
})))), (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", {
|
|
1034
1037
|
key: "hint",
|
|
1035
1038
|
className: "evo-hist-hint"
|
|
1036
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));
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { contentHash, displayBodyOf, elapsedSince, errorText, latestVersionAt, partitionVersions, skillRowCells, textDiffFacts, versionActionKind, versionRowCells } from "@lmzhen/dsh-evolution-core";
|
|
1
|
+
import { contentHash, displayBodyOf, elapsedSince, errorText, isLoopbackRequest, latestVersionAt, partitionVersions, readJsonObject, skillRowCells, textDiffFacts, versionActionKind, versionRowCells, writeJson } from "@lmzhen/dsh-evolution-core";
|
|
2
2
|
//#region lib/types/routes.js
|
|
3
3
|
/**
|
|
4
4
|
* The skill-history HTTP surface: six loopback routes over the curator's read/write seam (five reads plus the one write).
|
|
@@ -22,89 +22,6 @@ const SKILL_HISTORY_ROUTES = {
|
|
|
22
22
|
* deployment may store: a long skill arrives as its head plus `truncated`, so the reader is told.
|
|
23
23
|
*/
|
|
24
24
|
const MAX_BODY_CHARS = 2e4;
|
|
25
|
-
function header(request, name) {
|
|
26
|
-
const value = request.headers[name];
|
|
27
|
-
return typeof value === "string" ? value : void 0;
|
|
28
|
-
}
|
|
29
|
-
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
30
|
-
function isIPv4Loopback(value) {
|
|
31
|
-
const parts = value.split(".");
|
|
32
|
-
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
33
|
-
}
|
|
34
|
-
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
35
|
-
function isLoopbackAddress(address) {
|
|
36
|
-
if (address === void 0) return false;
|
|
37
|
-
const normalized = address.toLowerCase();
|
|
38
|
-
if (normalized === "::1") return true;
|
|
39
|
-
if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
|
|
40
|
-
return isIPv4Loopback(normalized);
|
|
41
|
-
}
|
|
42
|
-
/** Whether a hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
43
|
-
function isLoopbackHostname(hostname) {
|
|
44
|
-
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
45
|
-
return isIPv4Loopback(hostname);
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Whether one request may enter these routes.
|
|
49
|
-
*
|
|
50
|
-
* The same fence the platform puts on its own /api bridge, kept local because the canonical
|
|
51
|
-
* implementation (packages/client/connection/src/api-request-trust.ts) is not part of that package's
|
|
52
|
-
* published surface. Rules, in order: the SOCKET must be loopback (authoritative — X-Forwarded-For is
|
|
53
|
-
* never trusted), the Host header must name a loopback authority (DNS-rebinding defense), an explicit
|
|
54
|
-
* cross-site marker is refused, and an attached Origin must be exactly this authority.
|
|
55
|
-
* @param request - the request to judge.
|
|
56
|
-
* @returns true when the request may proceed.
|
|
57
|
-
*/
|
|
58
|
-
function isLoopbackRequest(request) {
|
|
59
|
-
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
|
|
60
|
-
const host = header(request, "host");
|
|
61
|
-
if (host === void 0) return false;
|
|
62
|
-
let hostUrl;
|
|
63
|
-
try {
|
|
64
|
-
hostUrl = new URL("http://" + host);
|
|
65
|
-
} catch {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
69
|
-
if (header(request, "sec-fetch-site") === "cross-site") return false;
|
|
70
|
-
const origin = header(request, "origin");
|
|
71
|
-
if (origin === void 0) return true;
|
|
72
|
-
try {
|
|
73
|
-
return new URL(origin).host === hostUrl.host;
|
|
74
|
-
} catch {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
/** Write one JSON response. The routes own their response lifecycle, so every path ends here. */
|
|
79
|
-
function writeJson(res, status, value) {
|
|
80
|
-
const body = JSON.stringify(value);
|
|
81
|
-
res.writeHead(status, {
|
|
82
|
-
"content-type": "application/json; charset=utf-8",
|
|
83
|
-
"content-length": Buffer.byteLength(body)
|
|
84
|
-
});
|
|
85
|
-
res.end(body);
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Read one JSON request body, bounded.
|
|
89
|
-
* @param req - the request.
|
|
90
|
-
* @returns the parsed value, or null when the body is oversized, empty, or not JSON.
|
|
91
|
-
*/
|
|
92
|
-
async function readJsonBody(req) {
|
|
93
|
-
const chunks = [];
|
|
94
|
-
let size = 0;
|
|
95
|
-
for await (const chunk of req) {
|
|
96
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
97
|
-
size += buffer.byteLength;
|
|
98
|
-
if (size > 65536) return null;
|
|
99
|
-
chunks.push(buffer);
|
|
100
|
-
}
|
|
101
|
-
if (size === 0) return null;
|
|
102
|
-
try {
|
|
103
|
-
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
104
|
-
} catch {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
25
|
/**
|
|
109
26
|
* Build the four routes over the curator seam.
|
|
110
27
|
* @param services - the host services the handlers read.
|
|
@@ -323,8 +240,7 @@ function makeSkillHistoryRoutes(services) {
|
|
|
323
240
|
path: SKILL_HISTORY_ROUTES.undo,
|
|
324
241
|
handler: async (req, res) => {
|
|
325
242
|
if (!guard(req, res, "POST")) return;
|
|
326
|
-
const
|
|
327
|
-
const payload = body !== null && typeof body === "object" && !Array.isArray(body) ? body : null;
|
|
243
|
+
const payload = await readJsonObject(req);
|
|
328
244
|
const name = typeof payload?.name === "string" ? payload.name.trim() : "";
|
|
329
245
|
if (name === "") {
|
|
330
246
|
writeJson(res, 400, {
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* curator.undo. The panel never writes the skill tree itself.
|
|
7
7
|
* @module @lmzhen/dsh-evolution-skill-history/routes
|
|
8
8
|
*/
|
|
9
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
10
9
|
import type { Context } from '@deepseek-ai/cordis';
|
|
11
10
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
12
11
|
/** Route paths. The client bundle mirrors these literals; a spec asserts the two sides agree. */
|
|
@@ -18,40 +17,11 @@ export declare const SKILL_HISTORY_ROUTES: {
|
|
|
18
17
|
readonly undo: "/api/dsh-evolution/skill-history/undo";
|
|
19
18
|
readonly health: "/api/dsh-evolution/skill-history/health";
|
|
20
19
|
};
|
|
21
|
-
/** Largest request body these routes accept (the undo payload is a name and a number). */
|
|
22
|
-
export declare const MAX_REQUEST_BODY_BYTES: number;
|
|
23
20
|
/**
|
|
24
21
|
* How much of one version's body the read route hands back. A bound on the ANSWER, not on what the
|
|
25
22
|
* deployment may store: a long skill arrives as its head plus `truncated`, so the reader is told.
|
|
26
23
|
*/
|
|
27
24
|
export declare const MAX_BODY_CHARS = 20000;
|
|
28
|
-
/** The request facts the trust fence reads: a Node request, or the same fields in a spec fixture. */
|
|
29
|
-
export interface FenceRequest {
|
|
30
|
-
readonly headers: Record<string, string | string[] | undefined>;
|
|
31
|
-
readonly socket?: {
|
|
32
|
-
readonly remoteAddress?: string | undefined;
|
|
33
|
-
} | undefined;
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Whether one request may enter these routes.
|
|
37
|
-
*
|
|
38
|
-
* The same fence the platform puts on its own /api bridge, kept local because the canonical
|
|
39
|
-
* implementation (packages/client/connection/src/api-request-trust.ts) is not part of that package's
|
|
40
|
-
* published surface. Rules, in order: the SOCKET must be loopback (authoritative — X-Forwarded-For is
|
|
41
|
-
* never trusted), the Host header must name a loopback authority (DNS-rebinding defense), an explicit
|
|
42
|
-
* cross-site marker is refused, and an attached Origin must be exactly this authority.
|
|
43
|
-
* @param request - the request to judge.
|
|
44
|
-
* @returns true when the request may proceed.
|
|
45
|
-
*/
|
|
46
|
-
export declare function isLoopbackRequest(request: FenceRequest): boolean;
|
|
47
|
-
/** Write one JSON response. The routes own their response lifecycle, so every path ends here. */
|
|
48
|
-
export declare function writeJson(res: ServerResponse, status: number, value: unknown): void;
|
|
49
|
-
/**
|
|
50
|
-
* Read one JSON request body, bounded.
|
|
51
|
-
* @param req - the request.
|
|
52
|
-
* @returns the parsed value, or null when the body is oversized, empty, or not JSON.
|
|
53
|
-
*/
|
|
54
|
-
export declare function readJsonBody(req: IncomingMessage): Promise<unknown>;
|
|
55
25
|
/** The curator surface the routes read: history() and the library listing, undo() as the only write. */
|
|
56
26
|
export type CuratorFace = Pick<Context['evolutionCurator'], 'history' | 'undo' | 'skills'>;
|
|
57
27
|
/**
|
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.
|
|
4
|
+
"version": "0.16.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.
|
|
33
|
+
"@lmzhen/dsh-evolution-core": "^0.16.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.
|
|
39
|
+
"@lmzhen/dsh-evolution-curator": "^0.16.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
44
|
"@types/react": "~18.3.1",
|
|
45
45
|
"react": "^18.2.0",
|
|
46
|
-
"@lmzhen/dsh-evolution-core": "^0.
|
|
47
|
-
"@lmzhen/dsh-evolution-curator": "^0.
|
|
48
|
-
"@lmzhen/dsh-evolution-io": "^0.
|
|
49
|
-
"@lmzhen/dsh-evolution-io-node": "^0.
|
|
46
|
+
"@lmzhen/dsh-evolution-core": "^0.16.0",
|
|
47
|
+
"@lmzhen/dsh-evolution-curator": "^0.16.0",
|
|
48
|
+
"@lmzhen/dsh-evolution-io": "^0.16.0",
|
|
49
|
+
"@lmzhen/dsh-evolution-io-node": "^0.16.0"
|
|
50
50
|
},
|
|
51
51
|
"dsh": {
|
|
52
52
|
"client": {
|