neoctl-web 0.1.0 → 0.1.2

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.
@@ -1,388 +1,388 @@
1
- import crypto from 'node:crypto';
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import {
6
- parseXhsArtifactToolOutput,
7
- XHS_ARTIFACT_EDITOR_HINT,
8
- XHS_ARTIFACT_INPUT_SCHEMA,
9
- XHS_IMAGE_FIELDS,
10
- XHS_PAYLOAD_FIELDS,
11
- } from './xhs-artifact-contract.mjs';
12
-
13
- export class XhsArtifactRegistry {
14
- constructor(options = {}) {
15
- this.entries = new Map();
16
- this.storageDir = path.resolve(options.storageDir || path.join(process.cwd(), '.neoctl-web', 'xhs-artifacts'));
17
- this.sessionsDir = path.resolve(options.sessionsDir || path.join(os.homedir(), '.neoctl', 'sessions'));
18
- }
19
-
20
- add(entry) {
21
- const now = Date.now();
22
- const artifact = normalizeArtifact({
23
- id: crypto.randomUUID(),
24
- type: 'xhs-post',
25
- title: '',
26
- payload: {},
27
- content: '',
28
- sessionId: undefined,
29
- createdAt: now,
30
- updatedAt: now,
31
- ...entry,
32
- });
33
- this.entries.set(artifact.id, artifact);
34
- this.persist(artifact);
35
- return cloneArtifact(artifact);
36
- }
37
-
38
- get(id, sessionId) {
39
- const artifactId = safeArtifactId(id);
40
- if (!artifactId) return undefined;
41
- const artifact = this.entries.get(artifactId) || this.load(artifactId) || this.recoverFromTranscript(artifactId, sessionId);
42
- if (artifact) this.entries.set(artifact.id, artifact);
43
- if (!artifactBelongsToSession(artifact, sessionId)) return undefined;
44
- return artifact ? cloneArtifact(artifact) : undefined;
45
- }
46
-
47
- update(id, patch, sessionId) {
48
- const artifact = this.get(id, sessionId);
49
- if (!artifact) return undefined;
50
- if (typeof patch.title === 'string') artifact.title = patch.title.trim().slice(0, 160);
51
- if (patch.payload !== undefined) artifact.payload = validateEditorPayload(patch.payload);
52
- if (typeof patch.content === 'string') artifact.content = patch.content;
53
- artifact.updatedAt = Date.now();
54
- this.entries.set(artifact.id, artifact);
55
- this.persist(artifact);
56
- return cloneArtifact(artifact);
57
- }
58
-
59
- load(id) {
60
- try {
61
- return normalizeArtifact(JSON.parse(fs.readFileSync(this.artifactFile(id), 'utf8')));
62
- } catch (error) {
63
- if (error?.code !== 'ENOENT') console.warn(`failed to load xhs artifact ${id}:`, error);
64
- return undefined;
65
- }
66
- }
67
-
68
- persist(artifact) {
69
- fs.mkdirSync(this.storageDir, { recursive: true });
70
- const target = this.artifactFile(artifact.id);
71
- const temporary = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
72
- fs.writeFileSync(temporary, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
73
- fs.renameSync(temporary, target);
74
- }
75
-
76
- recoverFromTranscript(id, sessionId) {
77
- const safeSessionId = safeSessionDirectoryName(sessionId);
78
- if (!safeSessionId) return undefined;
79
- try {
80
- const transcript = fs.readFileSync(path.join(this.sessionsDir, safeSessionId, 'transcript.jsonl'), 'utf8');
81
- const lines = transcript.split(/\r?\n/).filter(Boolean);
82
- for (let index = lines.length - 1; index >= 0; index -= 1) {
83
- let entry;
84
- try { entry = JSON.parse(lines[index]); } catch { continue; }
85
- for (const block of entry?.message?.blocks || []) {
86
- if (block?.type !== 'tool_result' || !['open_xhs_artifact_editor', 'read_xhs_artifact'].includes(block.name)) continue;
87
- const recovered = parseXhsArtifactToolOutput(block.output);
88
- if (String(recovered?.id || '') !== id) continue;
89
- const artifact = normalizeArtifact({ ...recovered, sessionId: recovered.sessionId || safeSessionId });
90
- this.persist(artifact);
91
- return artifact;
92
- }
93
- }
94
- } catch (error) {
95
- if (error?.code !== 'ENOENT') console.warn(`failed to recover xhs artifact ${id}:`, error);
96
- }
97
- return undefined;
98
- }
99
-
100
- artifactFile(id) {
101
- return path.join(this.storageDir, `${id}.json`);
102
- }
103
- }
104
-
105
- export function createOpenXhsArtifactEditorTool(options) {
106
- return {
107
- name: 'open_xhs_artifact_editor',
108
- description: XHS_ARTIFACT_EDITOR_HINT,
109
- inputSchema: XHS_ARTIFACT_INPUT_SCHEMA,
110
- metadata: {
111
- readOnly: false,
112
- concurrent: true,
113
- visible: true,
114
- requiresApproval: false,
115
- maxResultSizeChars: 60000,
116
- },
117
- validate(input) {
118
- const payload = validateEditorPayload(input?.payload);
119
- return {
120
- artifactId: String(input?.artifact_id || '').trim(),
121
- title: payload.title,
122
- payload,
123
- content: '',
124
- };
125
- },
126
- async execute(input, context) {
127
- const sessionId = context?.session?.sessionId;
128
- const artifact = input.artifactId
129
- ? options.registry.update(input.artifactId, { title: input.title, payload: input.payload, content: input.content }, sessionId)
130
- : options.registry.add({
131
- title: input.title,
132
- payload: input.payload,
133
- content: input.content,
134
- sessionId: context.session?.sessionId,
135
- });
136
- if (!artifact) throw new Error(`xhs artifact not found: ${input.artifactId}; call read_xhs_artifact first or omit artifact_id to create a new editor`);
137
- const action = input.artifactId ? 'Updated' : 'Opened';
138
- return {
139
- ok: true,
140
- output: {
141
- artifact: clientArtifact(artifact),
142
- action: action.toLowerCase(),
143
- _ui: xhsArtifactPresentation(artifact),
144
- },
145
- summary: `${action} Xiaohongshu editor ${artifact.id}`,
146
- };
147
- },
148
- };
149
- }
150
-
151
- export function xhsArtifactPresentation(artifact, sessionId = artifact?.sessionId) {
152
- if (!artifact?.id) return undefined;
153
- const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
154
- return {
155
- title: '编辑小红书笔记',
156
- bodyTitle: artifact.title || '小红书笔记',
157
- text: '可编辑笔记已就绪。',
158
- presentationLevel: 'primary',
159
- resources: [{
160
- kind: 'embed',
161
- url: `/api/xhs-artifacts/${encodeURIComponent(artifact.id)}/editor${query}`,
162
- label: artifact.title || '小红书笔记编辑器',
163
- height: 720,
164
- }],
165
- };
166
- }
167
-
168
- export function createReadXhsArtifactTool(options) {
169
- return {
170
- name: 'read_xhs_artifact',
171
- description: 'Read the latest user-edited Xiaohongshu editor state. Before revising, call this with the artifact id, preserve the returned user edits, then call open_xhs_artifact_editor with artifact_id set to the same id and a complete exact payload.',
172
- inputSchema: {
173
- type: 'object',
174
- properties: { id: { type: 'string', description: 'Artifact id returned by open_xhs_artifact_editor.' } },
175
- required: ['id'],
176
- additionalProperties: false,
177
- },
178
- metadata: {
179
- readOnly: true,
180
- concurrent: true,
181
- visible: true,
182
- requiresApproval: false,
183
- maxResultSizeChars: 60000,
184
- },
185
- validate(input) {
186
- const id = String(input?.id || '').trim();
187
- if (!id) throw new Error('id is required');
188
- return { id };
189
- },
190
- async execute(input, context) {
191
- const artifact = options.registry.get(input.id, context?.session?.sessionId);
192
- if (!artifact) throw new Error(`xhs artifact not found: ${input.id}`);
193
- return { ok: true, output: { artifact: clientArtifact(artifact) }, summary: '已读取稿件' };
194
- },
195
- };
196
- }
197
-
198
- export async function serveXhsArtifact(registry, req, res, id, readJsonBody, sessionId) {
199
- if (req.method === 'GET') {
200
- const artifact = registry.get(id, sessionId);
201
- return artifact ? sendJson(res, { ok: true, artifact: clientArtifact(artifact) }) : sendJson(res, { error: 'artifact not found' }, 404);
202
- }
203
- if (req.method === 'PUT') {
204
- const body = await readJsonBody(req);
205
- assertExactKeys(body, ['title', 'payload'], 'request');
206
- const payload = validateEditorPayload(body?.payload);
207
- const artifact = registry.update(id, { title: payload.title, payload }, sessionId);
208
- return artifact ? sendJson(res, { ok: true, artifact: clientArtifact(artifact) }) : sendJson(res, { error: 'artifact not found' }, 404);
209
- }
210
- return sendJson(res, { error: 'method not allowed' }, 405);
211
- }
212
-
213
- function normalizeArtifact(value) {
214
- const payload = validateEditorPayload(value.payload);
215
- return {
216
- id: String(value.id),
217
- type: String(value.type || 'xhs-post'),
218
- title: payload.title,
219
- payload,
220
- content: String(value.content || ''),
221
- sessionId: value.sessionId,
222
- createdAt: Number(value.createdAt || Date.now()),
223
- updatedAt: Number(value.updatedAt || Date.now()),
224
- };
225
- }
226
-
227
- function clientArtifact(artifact) {
228
- return {
229
- id: artifact.id,
230
- type: artifact.type,
231
- title: artifact.title,
232
- payload: clientPayload(artifact.payload),
233
- sessionId: artifact.sessionId,
234
- createdAt: artifact.createdAt,
235
- updatedAt: artifact.updatedAt,
236
- };
237
- }
238
-
239
- function clientPayload(payload) {
240
- return {
241
- title: payload.title,
242
- body: payload.body,
243
- interaction: payload.interaction,
244
- hashtags: [...payload.hashtags],
245
- images: payload.images.map(clientImage),
246
- review: payload.review,
247
- };
248
- }
249
-
250
- function clientImage(image) {
251
- return {
252
- url: safeImageUrl(image.url),
253
- caption: image.caption,
254
- overlay: image.overlay,
255
- note: image.note,
256
- };
257
- }
258
-
259
- function validateEditorPayload(value) {
260
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
261
- throw contractError('payload must be an object');
262
- }
263
- assertExactKeys(value, XHS_PAYLOAD_FIELDS, 'payload');
264
-
265
- const title = requireEditorString(value.title, 'payload.title').trim().slice(0, 160);
266
- const body = requireEditorString(value.body, 'payload.body').trim();
267
- const interaction = requireEditorString(value.interaction, 'payload.interaction').trim();
268
- const review = requireEditorString(value.review, 'payload.review').trim();
269
- if (!title) throw contractError('payload.title must contain the final post title');
270
- if (!body) throw contractError('payload.body must contain final publish-ready正文 only');
271
- validateBodyBoundaries(body, title);
272
- if (!Array.isArray(value.hashtags)) throw contractError('payload.hashtags must be an array of strings such as ["#话题"]');
273
- if (!Array.isArray(value.images)) throw contractError('payload.images must be an array of {url, caption, overlay, note} objects');
274
- if (!value.images.length) throw contractError('payload.images must contain at least one image or planned placeholder');
275
-
276
- const hashtags = value.hashtags.map((tag, index) => {
277
- if (typeof tag !== 'string') throw contractError(`payload.hashtags[${index}] must be a string`);
278
- const normalized = tag.trim().replace(/^#/u, '#');
279
- if (!/^#[^\s#]+$/u.test(normalized)) throw contractError(`payload.hashtags[${index}] must be exactly one #topic without spaces`);
280
- return normalized.slice(0, 100);
281
- });
282
- const images = value.images.map((image, index) => validateEditorImage(image, index));
283
-
284
- return {
285
- title,
286
- body,
287
- interaction,
288
- hashtags: [...new Set(hashtags)].slice(0, 40),
289
- images,
290
- review,
291
- };
292
- }
293
-
294
- function validateEditorImage(value, index) {
295
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
296
- throw contractError(`payload.images[${index}] must be an object with url, caption, overlay, note`);
297
- }
298
- assertExactKeys(value, XHS_IMAGE_FIELDS, `payload.images[${index}]`);
299
- const rawUrl = requireEditorString(value.url, `payload.images[${index}].url`).trim();
300
- const url = safeImageUrl(rawUrl);
301
- if (rawUrl && !url) {
302
- throw contractError(`payload.images[${index}].url must be a real http(s) URL, /api/ URL, or absolute local image path; use "" for a placeholder`);
303
- }
304
- const caption = requireEditorString(value.caption, `payload.images[${index}].caption`).trim();
305
- const overlay = requireEditorString(value.overlay, `payload.images[${index}].overlay`).trim();
306
- const note = requireEditorString(value.note, `payload.images[${index}].note`).trim();
307
- if (!url && !caption && !note) throw contractError(`payload.images[${index}] is an empty placeholder; describe the planned image in caption or note`);
308
- return {
309
- url,
310
- caption: caption.slice(0, 1000),
311
- overlay: overlay.slice(0, 500),
312
- note: note.slice(0, 2000),
313
- };
314
- }
315
-
316
- function requireEditorString(value, field) {
317
- if (typeof value !== 'string') throw contractError(`${field} must be a string`);
318
- return value;
319
- }
320
-
321
- function safeImageUrl(...values) {
322
- const url = String(values.find((value) => typeof value === 'string') || '').trim();
323
- if (!url || /^data:/i.test(url)) return '';
324
- if (/^(?:https?:|\/api\/)/i.test(url)) return url.slice(0, 2048);
325
- if (isLocalFilePath(url)) return `/api/local-images/${encodeURIComponent(Buffer.from(url, 'utf8').toString('base64url'))}`;
326
- return '';
327
- }
328
-
329
- function isLocalFilePath(value) {
330
- return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('/');
331
- }
332
-
333
- function assertExactKeys(value, expected, path) {
334
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw contractError(`${path} must be an object`);
335
- const actual = Object.keys(value);
336
- const missing = expected.filter((key) => !actual.includes(key));
337
- const extra = actual.filter((key) => !expected.includes(key));
338
- if (missing.length || extra.length) {
339
- const details = [
340
- missing.length ? `missing ${missing.join(', ')}` : '',
341
- extra.length ? `unexpected ${extra.join(', ')}` : '',
342
- ].filter(Boolean).join('; ');
343
- throw contractError(`${path} must contain exactly ${expected.join(', ')} (${details})`);
344
- }
345
- }
346
-
347
- function validateBodyBoundaries(body, title) {
348
- const firstLine = body.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
349
- if (firstLine === title || firstLine.replace(/^#{1,6}\s*/, '') === title) {
350
- throw contractError('payload.body must not repeat payload.title as its first line');
351
- }
352
- if (/```|~~~/.test(body)) throw contractError('payload.body must not contain fenced Markdown or JSON');
353
- if (/^\s*#{1,6}\s*(?:标题|正文|笔记正文|发布文案|配图|图片方案|话题标签|标签|审核|review)\s*[::]?\s*$/imu.test(body)) {
354
- throw contractError('payload.body must not contain editor section headings');
355
- }
356
- if (/(?:^|\s)#[^\s#]+/u.test(body)) throw contractError('payload.body must not contain hashtags; put them in payload.hashtags');
357
- if (/^\s*\{[\s\S]*"(?:title|body|images|hashtags)"\s*:/u.test(body)) {
358
- throw contractError('payload.body must not contain serialized tool JSON');
359
- }
360
- }
361
-
362
- function contractError(message) {
363
- return new Error(`Invalid Xiaohongshu editor payload: ${message}. Retry open_xhs_artifact_editor with the exact documented schema; do not send Markdown or alternate fields.`);
364
- }
365
-
366
- function safeArtifactId(value) {
367
- const id = String(value || '').trim();
368
- return id && /^[A-Za-z0-9._-]+$/u.test(id) && path.basename(id) === id ? id : '';
369
- }
370
-
371
- function safeSessionDirectoryName(value) {
372
- const id = String(value || '').trim();
373
- return id && /^[A-Za-z0-9._:-]+$/u.test(id) && !id.includes('..') && path.basename(id) === id ? id : '';
374
- }
375
-
376
- function artifactBelongsToSession(artifact, sessionId) {
377
- const expected = String(sessionId || '').trim();
378
- return Boolean(artifact) && (!expected || !artifact.sessionId || artifact.sessionId === expected);
379
- }
380
-
381
- function cloneArtifact(artifact) {
382
- return JSON.parse(JSON.stringify(artifact));
383
- }
384
-
385
- function sendJson(res, value, status = 200) {
386
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
387
- res.end(JSON.stringify(value));
388
- }
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import {
6
+ parseXhsArtifactToolOutput,
7
+ XHS_ARTIFACT_EDITOR_HINT,
8
+ XHS_ARTIFACT_INPUT_SCHEMA,
9
+ XHS_IMAGE_FIELDS,
10
+ XHS_PAYLOAD_FIELDS,
11
+ } from './xhs-artifact-contract.mjs';
12
+
13
+ export class XhsArtifactRegistry {
14
+ constructor(options = {}) {
15
+ this.entries = new Map();
16
+ this.storageDir = path.resolve(options.storageDir || path.join(process.cwd(), '.neoctl-web', 'xhs-artifacts'));
17
+ this.sessionsDir = path.resolve(options.sessionsDir || path.join(os.homedir(), '.neoctl', 'sessions'));
18
+ }
19
+
20
+ add(entry) {
21
+ const now = Date.now();
22
+ const artifact = normalizeArtifact({
23
+ id: crypto.randomUUID(),
24
+ type: 'xhs-post',
25
+ title: '',
26
+ payload: {},
27
+ content: '',
28
+ sessionId: undefined,
29
+ createdAt: now,
30
+ updatedAt: now,
31
+ ...entry,
32
+ });
33
+ this.entries.set(artifact.id, artifact);
34
+ this.persist(artifact);
35
+ return cloneArtifact(artifact);
36
+ }
37
+
38
+ get(id, sessionId) {
39
+ const artifactId = safeArtifactId(id);
40
+ if (!artifactId) return undefined;
41
+ const artifact = this.entries.get(artifactId) || this.load(artifactId) || this.recoverFromTranscript(artifactId, sessionId);
42
+ if (artifact) this.entries.set(artifact.id, artifact);
43
+ if (!artifactBelongsToSession(artifact, sessionId)) return undefined;
44
+ return artifact ? cloneArtifact(artifact) : undefined;
45
+ }
46
+
47
+ update(id, patch, sessionId) {
48
+ const artifact = this.get(id, sessionId);
49
+ if (!artifact) return undefined;
50
+ if (typeof patch.title === 'string') artifact.title = patch.title.trim().slice(0, 160);
51
+ if (patch.payload !== undefined) artifact.payload = validateEditorPayload(patch.payload);
52
+ if (typeof patch.content === 'string') artifact.content = patch.content;
53
+ artifact.updatedAt = Date.now();
54
+ this.entries.set(artifact.id, artifact);
55
+ this.persist(artifact);
56
+ return cloneArtifact(artifact);
57
+ }
58
+
59
+ load(id) {
60
+ try {
61
+ return normalizeArtifact(JSON.parse(fs.readFileSync(this.artifactFile(id), 'utf8')));
62
+ } catch (error) {
63
+ if (error?.code !== 'ENOENT') console.warn(`failed to load xhs artifact ${id}:`, error);
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ persist(artifact) {
69
+ fs.mkdirSync(this.storageDir, { recursive: true });
70
+ const target = this.artifactFile(artifact.id);
71
+ const temporary = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
72
+ fs.writeFileSync(temporary, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
73
+ fs.renameSync(temporary, target);
74
+ }
75
+
76
+ recoverFromTranscript(id, sessionId) {
77
+ const safeSessionId = safeSessionDirectoryName(sessionId);
78
+ if (!safeSessionId) return undefined;
79
+ try {
80
+ const transcript = fs.readFileSync(path.join(this.sessionsDir, safeSessionId, 'transcript.jsonl'), 'utf8');
81
+ const lines = transcript.split(/\r?\n/).filter(Boolean);
82
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
83
+ let entry;
84
+ try { entry = JSON.parse(lines[index]); } catch { continue; }
85
+ for (const block of entry?.message?.blocks || []) {
86
+ if (block?.type !== 'tool_result' || !['open_xhs_artifact_editor', 'read_xhs_artifact'].includes(block.name)) continue;
87
+ const recovered = parseXhsArtifactToolOutput(block.output);
88
+ if (String(recovered?.id || '') !== id) continue;
89
+ const artifact = normalizeArtifact({ ...recovered, sessionId: recovered.sessionId || safeSessionId });
90
+ this.persist(artifact);
91
+ return artifact;
92
+ }
93
+ }
94
+ } catch (error) {
95
+ if (error?.code !== 'ENOENT') console.warn(`failed to recover xhs artifact ${id}:`, error);
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ artifactFile(id) {
101
+ return path.join(this.storageDir, `${id}.json`);
102
+ }
103
+ }
104
+
105
+ export function createOpenXhsArtifactEditorTool(options) {
106
+ return {
107
+ name: 'open_xhs_artifact_editor',
108
+ description: XHS_ARTIFACT_EDITOR_HINT,
109
+ inputSchema: XHS_ARTIFACT_INPUT_SCHEMA,
110
+ metadata: {
111
+ readOnly: false,
112
+ concurrent: true,
113
+ visible: true,
114
+ requiresApproval: false,
115
+ maxResultSizeChars: 60000,
116
+ },
117
+ validate(input) {
118
+ const payload = validateEditorPayload(input?.payload);
119
+ return {
120
+ artifactId: String(input?.artifact_id || '').trim(),
121
+ title: payload.title,
122
+ payload,
123
+ content: '',
124
+ };
125
+ },
126
+ async execute(input, context) {
127
+ const sessionId = context?.session?.sessionId;
128
+ const artifact = input.artifactId
129
+ ? options.registry.update(input.artifactId, { title: input.title, payload: input.payload, content: input.content }, sessionId)
130
+ : options.registry.add({
131
+ title: input.title,
132
+ payload: input.payload,
133
+ content: input.content,
134
+ sessionId: context.session?.sessionId,
135
+ });
136
+ if (!artifact) throw new Error(`xhs artifact not found: ${input.artifactId}; call read_xhs_artifact first or omit artifact_id to create a new editor`);
137
+ const action = input.artifactId ? 'Updated' : 'Opened';
138
+ return {
139
+ ok: true,
140
+ output: {
141
+ artifact: clientArtifact(artifact),
142
+ action: action.toLowerCase(),
143
+ _ui: xhsArtifactPresentation(artifact),
144
+ },
145
+ summary: `${action} Xiaohongshu editor ${artifact.id}`,
146
+ };
147
+ },
148
+ };
149
+ }
150
+
151
+ export function xhsArtifactPresentation(artifact, sessionId = artifact?.sessionId) {
152
+ if (!artifact?.id) return undefined;
153
+ const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
154
+ return {
155
+ title: '编辑小红书笔记',
156
+ bodyTitle: artifact.title || '小红书笔记',
157
+ text: '可编辑笔记已就绪。',
158
+ presentationLevel: 'primary',
159
+ resources: [{
160
+ kind: 'embed',
161
+ url: `/api/xhs-artifacts/${encodeURIComponent(artifact.id)}/editor${query}`,
162
+ label: artifact.title || '小红书笔记编辑器',
163
+ height: 720,
164
+ }],
165
+ };
166
+ }
167
+
168
+ export function createReadXhsArtifactTool(options) {
169
+ return {
170
+ name: 'read_xhs_artifact',
171
+ description: 'Read the latest user-edited Xiaohongshu editor state. Before revising, call this with the artifact id, preserve the returned user edits, then call open_xhs_artifact_editor with artifact_id set to the same id and a complete exact payload.',
172
+ inputSchema: {
173
+ type: 'object',
174
+ properties: { id: { type: 'string', description: 'Artifact id returned by open_xhs_artifact_editor.' } },
175
+ required: ['id'],
176
+ additionalProperties: false,
177
+ },
178
+ metadata: {
179
+ readOnly: true,
180
+ concurrent: true,
181
+ visible: true,
182
+ requiresApproval: false,
183
+ maxResultSizeChars: 60000,
184
+ },
185
+ validate(input) {
186
+ const id = String(input?.id || '').trim();
187
+ if (!id) throw new Error('id is required');
188
+ return { id };
189
+ },
190
+ async execute(input, context) {
191
+ const artifact = options.registry.get(input.id, context?.session?.sessionId);
192
+ if (!artifact) throw new Error(`xhs artifact not found: ${input.id}`);
193
+ return { ok: true, output: { artifact: clientArtifact(artifact) }, summary: '已读取稿件' };
194
+ },
195
+ };
196
+ }
197
+
198
+ export async function serveXhsArtifact(registry, req, res, id, readJsonBody, sessionId) {
199
+ if (req.method === 'GET') {
200
+ const artifact = registry.get(id, sessionId);
201
+ return artifact ? sendJson(res, { ok: true, artifact: clientArtifact(artifact) }) : sendJson(res, { error: 'artifact not found' }, 404);
202
+ }
203
+ if (req.method === 'PUT') {
204
+ const body = await readJsonBody(req);
205
+ assertExactKeys(body, ['title', 'payload'], 'request');
206
+ const payload = validateEditorPayload(body?.payload);
207
+ const artifact = registry.update(id, { title: payload.title, payload }, sessionId);
208
+ return artifact ? sendJson(res, { ok: true, artifact: clientArtifact(artifact) }) : sendJson(res, { error: 'artifact not found' }, 404);
209
+ }
210
+ return sendJson(res, { error: 'method not allowed' }, 405);
211
+ }
212
+
213
+ function normalizeArtifact(value) {
214
+ const payload = validateEditorPayload(value.payload);
215
+ return {
216
+ id: String(value.id),
217
+ type: String(value.type || 'xhs-post'),
218
+ title: payload.title,
219
+ payload,
220
+ content: String(value.content || ''),
221
+ sessionId: value.sessionId,
222
+ createdAt: Number(value.createdAt || Date.now()),
223
+ updatedAt: Number(value.updatedAt || Date.now()),
224
+ };
225
+ }
226
+
227
+ function clientArtifact(artifact) {
228
+ return {
229
+ id: artifact.id,
230
+ type: artifact.type,
231
+ title: artifact.title,
232
+ payload: clientPayload(artifact.payload),
233
+ sessionId: artifact.sessionId,
234
+ createdAt: artifact.createdAt,
235
+ updatedAt: artifact.updatedAt,
236
+ };
237
+ }
238
+
239
+ function clientPayload(payload) {
240
+ return {
241
+ title: payload.title,
242
+ body: payload.body,
243
+ interaction: payload.interaction,
244
+ hashtags: [...payload.hashtags],
245
+ images: payload.images.map(clientImage),
246
+ review: payload.review,
247
+ };
248
+ }
249
+
250
+ function clientImage(image) {
251
+ return {
252
+ url: safeImageUrl(image.url),
253
+ caption: image.caption,
254
+ overlay: image.overlay,
255
+ note: image.note,
256
+ };
257
+ }
258
+
259
+ function validateEditorPayload(value) {
260
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
261
+ throw contractError('payload must be an object');
262
+ }
263
+ assertExactKeys(value, XHS_PAYLOAD_FIELDS, 'payload');
264
+
265
+ const title = requireEditorString(value.title, 'payload.title').trim().slice(0, 160);
266
+ const body = requireEditorString(value.body, 'payload.body').trim();
267
+ const interaction = requireEditorString(value.interaction, 'payload.interaction').trim();
268
+ const review = requireEditorString(value.review, 'payload.review').trim();
269
+ if (!title) throw contractError('payload.title must contain the final post title');
270
+ if (!body) throw contractError('payload.body must contain final publish-ready正文 only');
271
+ validateBodyBoundaries(body, title);
272
+ if (!Array.isArray(value.hashtags)) throw contractError('payload.hashtags must be an array of strings such as ["#话题"]');
273
+ if (!Array.isArray(value.images)) throw contractError('payload.images must be an array of {url, caption, overlay, note} objects');
274
+ if (!value.images.length) throw contractError('payload.images must contain at least one image or planned placeholder');
275
+
276
+ const hashtags = value.hashtags.map((tag, index) => {
277
+ if (typeof tag !== 'string') throw contractError(`payload.hashtags[${index}] must be a string`);
278
+ const normalized = tag.trim().replace(/^#/u, '#');
279
+ if (!/^#[^\s#]+$/u.test(normalized)) throw contractError(`payload.hashtags[${index}] must be exactly one #topic without spaces`);
280
+ return normalized.slice(0, 100);
281
+ });
282
+ const images = value.images.map((image, index) => validateEditorImage(image, index));
283
+
284
+ return {
285
+ title,
286
+ body,
287
+ interaction,
288
+ hashtags: [...new Set(hashtags)].slice(0, 40),
289
+ images,
290
+ review,
291
+ };
292
+ }
293
+
294
+ function validateEditorImage(value, index) {
295
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
296
+ throw contractError(`payload.images[${index}] must be an object with url, caption, overlay, note`);
297
+ }
298
+ assertExactKeys(value, XHS_IMAGE_FIELDS, `payload.images[${index}]`);
299
+ const rawUrl = requireEditorString(value.url, `payload.images[${index}].url`).trim();
300
+ const url = safeImageUrl(rawUrl);
301
+ if (rawUrl && !url) {
302
+ throw contractError(`payload.images[${index}].url must be a real http(s) URL, /api/ URL, or absolute local image path; use "" for a placeholder`);
303
+ }
304
+ const caption = requireEditorString(value.caption, `payload.images[${index}].caption`).trim();
305
+ const overlay = requireEditorString(value.overlay, `payload.images[${index}].overlay`).trim();
306
+ const note = requireEditorString(value.note, `payload.images[${index}].note`).trim();
307
+ if (!url && !caption && !note) throw contractError(`payload.images[${index}] is an empty placeholder; describe the planned image in caption or note`);
308
+ return {
309
+ url,
310
+ caption: caption.slice(0, 1000),
311
+ overlay: overlay.slice(0, 500),
312
+ note: note.slice(0, 2000),
313
+ };
314
+ }
315
+
316
+ function requireEditorString(value, field) {
317
+ if (typeof value !== 'string') throw contractError(`${field} must be a string`);
318
+ return value;
319
+ }
320
+
321
+ function safeImageUrl(...values) {
322
+ const url = String(values.find((value) => typeof value === 'string') || '').trim();
323
+ if (!url || /^data:/i.test(url)) return '';
324
+ if (/^(?:https?:|\/api\/)/i.test(url)) return url.slice(0, 2048);
325
+ if (isLocalFilePath(url)) return `/api/local-images/${encodeURIComponent(Buffer.from(url, 'utf8').toString('base64url'))}`;
326
+ return '';
327
+ }
328
+
329
+ function isLocalFilePath(value) {
330
+ return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('/');
331
+ }
332
+
333
+ function assertExactKeys(value, expected, path) {
334
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw contractError(`${path} must be an object`);
335
+ const actual = Object.keys(value);
336
+ const missing = expected.filter((key) => !actual.includes(key));
337
+ const extra = actual.filter((key) => !expected.includes(key));
338
+ if (missing.length || extra.length) {
339
+ const details = [
340
+ missing.length ? `missing ${missing.join(', ')}` : '',
341
+ extra.length ? `unexpected ${extra.join(', ')}` : '',
342
+ ].filter(Boolean).join('; ');
343
+ throw contractError(`${path} must contain exactly ${expected.join(', ')} (${details})`);
344
+ }
345
+ }
346
+
347
+ function validateBodyBoundaries(body, title) {
348
+ const firstLine = body.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
349
+ if (firstLine === title || firstLine.replace(/^#{1,6}\s*/, '') === title) {
350
+ throw contractError('payload.body must not repeat payload.title as its first line');
351
+ }
352
+ if (/```|~~~/.test(body)) throw contractError('payload.body must not contain fenced Markdown or JSON');
353
+ if (/^\s*#{1,6}\s*(?:标题|正文|笔记正文|发布文案|配图|图片方案|话题标签|标签|审核|review)\s*[::]?\s*$/imu.test(body)) {
354
+ throw contractError('payload.body must not contain editor section headings');
355
+ }
356
+ if (/(?:^|\s)#[^\s#]+/u.test(body)) throw contractError('payload.body must not contain hashtags; put them in payload.hashtags');
357
+ if (/^\s*\{[\s\S]*"(?:title|body|images|hashtags)"\s*:/u.test(body)) {
358
+ throw contractError('payload.body must not contain serialized tool JSON');
359
+ }
360
+ }
361
+
362
+ function contractError(message) {
363
+ return new Error(`Invalid Xiaohongshu editor payload: ${message}. Retry open_xhs_artifact_editor with the exact documented schema; do not send Markdown or alternate fields.`);
364
+ }
365
+
366
+ function safeArtifactId(value) {
367
+ const id = String(value || '').trim();
368
+ return id && /^[A-Za-z0-9._-]+$/u.test(id) && path.basename(id) === id ? id : '';
369
+ }
370
+
371
+ function safeSessionDirectoryName(value) {
372
+ const id = String(value || '').trim();
373
+ return id && /^[A-Za-z0-9._:-]+$/u.test(id) && !id.includes('..') && path.basename(id) === id ? id : '';
374
+ }
375
+
376
+ function artifactBelongsToSession(artifact, sessionId) {
377
+ const expected = String(sessionId || '').trim();
378
+ return Boolean(artifact) && (!expected || !artifact.sessionId || artifact.sessionId === expected);
379
+ }
380
+
381
+ function cloneArtifact(artifact) {
382
+ return JSON.parse(JSON.stringify(artifact));
383
+ }
384
+
385
+ function sendJson(res, value, status = 200) {
386
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
387
+ res.end(JSON.stringify(value));
388
+ }