neoctl-web 0.1.9 → 0.1.11

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,487 @@
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.readVersions = new Map();
17
+ this.storageDir = path.resolve(options.storageDir || path.join(process.cwd(), '.neoctl-web', 'xhs-artifacts'));
18
+ this.sessionsDir = path.resolve(options.sessionsDir || path.join(os.homedir(), '.neoctl', 'sessions'));
19
+ }
20
+
21
+ add(entry) {
22
+ const now = Date.now();
23
+ const artifact = normalizeArtifact({
24
+ id: crypto.randomUUID(),
25
+ type: 'xhs-post',
26
+ title: '',
27
+ payload: {},
28
+ content: '',
29
+ sessionId: undefined,
30
+ createdAt: now,
31
+ updatedAt: now,
32
+ ...entry,
33
+ });
34
+ this.entries.set(artifact.id, artifact);
35
+ this.persist(artifact);
36
+ return cloneArtifact(artifact);
37
+ }
38
+
39
+ get(id, sessionId) {
40
+ const artifactId = safeArtifactId(id);
41
+ if (!artifactId) return undefined;
42
+ const artifact = this.load(artifactId) || this.recoverFromTranscript(artifactId, sessionId);
43
+ if (artifact) this.entries.set(artifact.id, artifact);
44
+ if (!artifactBelongsToSession(artifact, sessionId)) return undefined;
45
+ return artifact ? cloneArtifact(artifact) : undefined;
46
+ }
47
+
48
+ update(id, patch, sessionId, expectedVersion) {
49
+ const artifactId = safeArtifactId(id);
50
+ if (!artifactId || !this.get(artifactId, sessionId)) return undefined;
51
+ fs.mkdirSync(this.storageDir, { recursive: true });
52
+ const lock = `${this.artifactFile(artifactId)}.lock`;
53
+ let fd;
54
+ fd = acquireArtifactLock(lock, expectedVersion);
55
+ try {
56
+ const artifact = this.load(artifactId);
57
+ if (!artifactBelongsToSession(artifact, sessionId)) return undefined;
58
+ if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1 || artifact.version !== expectedVersion) {
59
+ throw versionError('version_conflict', '稿件版本冲突,请读取最新版后处理草稿', artifact, expectedVersion);
60
+ }
61
+ if (patch.payload !== undefined) artifact.payload = validateEditorPayload(patch.payload);
62
+ artifact.title = artifact.payload.title;
63
+ if (typeof patch.content === 'string') artifact.content = patch.content;
64
+ artifact.version += 1;
65
+ artifact.updatedAt = Date.now();
66
+ this.persist(artifact);
67
+ this.entries.set(artifact.id, artifact);
68
+ return cloneArtifact(artifact);
69
+ } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
70
+ }
71
+
72
+ readKey(id, context) {
73
+ return JSON.stringify([context?.session?.sessionId, context?.agentId || '', id]);
74
+ }
75
+
76
+ readForUpdate(id, context) {
77
+ const artifact = this.get(id, context?.session?.sessionId);
78
+ if (!artifact) return undefined;
79
+ for (const [key, read] of this.readVersions) if (read.expires < Date.now()) this.readVersions.delete(key);
80
+ if (this.readVersions.size >= 1000) this.readVersions.delete(this.readVersions.keys().next().value);
81
+ this.readVersions.set(this.readKey(id, context), { version: artifact.version, expires: Date.now() + 30 * 60 * 1000 });
82
+ return artifact;
83
+ }
84
+
85
+ updateFromRead(id, patch, context) {
86
+ const key = this.readKey(id, context);
87
+ const read = this.readVersions.get(key);
88
+ if (!read || read.expires < Date.now()) {
89
+ throw versionError('read_required', 'call read_xhs_artifact first and preserve the returned user edits');
90
+ }
91
+ // Consume before CAS: conflict/replay requires a fresh read, never a blind retry.
92
+ this.readVersions.delete(key);
93
+ return this.update(id, patch, context?.session?.sessionId, read.version);
94
+ }
95
+
96
+ load(id) {
97
+ try {
98
+ return normalizeArtifact(JSON.parse(fs.readFileSync(this.artifactFile(id), 'utf8')));
99
+ } catch (error) {
100
+ if (error?.code !== 'ENOENT') throw error;
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ persist(artifact) {
106
+ fs.mkdirSync(this.storageDir, { recursive: true });
107
+ const target = this.artifactFile(artifact.id);
108
+ const temporary = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
109
+ try {
110
+ fs.writeFileSync(temporary, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
111
+ // Windows may temporarily deny replacement while another registry reads the
112
+ // destination. Keep the caller's CAS lock throughout every retry. Never
113
+ // unlink the destination: it must remain either the old or the new JSON.
114
+ for (let attempt = 0; ; attempt += 1) {
115
+ try { fs.renameSync(temporary, target); break; }
116
+ catch (error) {
117
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt >= 20) throw error;
118
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
119
+ }
120
+ }
121
+ } catch (cause) {
122
+ throw Object.assign(new Error('稿件写入失败,已保存版本未被替换;请重试', { cause }), {
123
+ code: 'artifact_write_failed', storage_code: cause.code,
124
+ });
125
+ } finally {
126
+ // Rename success consumes the temporary file. A rejected write must not
127
+ // leave pending content available for a later accidental publication.
128
+ try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') console.warn('failed to clean xhs temporary file:', error.code); }
129
+ }
130
+ }
131
+
132
+ recoverFromTranscript(id, sessionId) {
133
+ const safeSessionId = safeSessionDirectoryName(sessionId);
134
+ if (!safeSessionId) return undefined;
135
+ try {
136
+ const transcript = fs.readFileSync(path.join(this.sessionsDir, safeSessionId, 'transcript.jsonl'), 'utf8');
137
+ const lines = transcript.split(/\r?\n/).filter(Boolean);
138
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
139
+ let entry;
140
+ try { entry = JSON.parse(lines[index]); } catch { continue; }
141
+ for (const block of entry?.message?.blocks || []) {
142
+ if (block?.type !== 'tool_result' || !['open_xhs_artifact_editor', 'read_xhs_artifact'].includes(block.name)) continue;
143
+ const recovered = parseXhsArtifactToolOutput(block.output);
144
+ if (String(recovered?.id || '') !== id) continue;
145
+ const artifact = normalizeArtifact({ ...recovered, sessionId: recovered.sessionId || safeSessionId });
146
+ if (!artifactBelongsToSession(artifact, sessionId)) continue;
147
+ fs.mkdirSync(this.storageDir, { recursive: true });
148
+ const lock = `${this.artifactFile(id)}.lock`;
149
+ let fd;
150
+ try { fd = acquireArtifactLock(lock); } catch (error) { if (error.code === 'artifact_busy') return this.load(id); throw error; }
151
+ try {
152
+ const existing = this.load(id);
153
+ if (existing) return existing;
154
+ this.persist(artifact);
155
+ return artifact;
156
+ } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
157
+ }
158
+ }
159
+ } catch (error) {
160
+ if (error?.code !== 'ENOENT') console.warn(`failed to recover xhs artifact ${id}:`, error);
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ artifactFile(id) {
166
+ return path.join(this.storageDir, `${id}.json`);
167
+ }
168
+ }
169
+
170
+ export function createOpenXhsArtifactEditorTool(options) {
171
+ return {
172
+ name: 'open_xhs_artifact_editor',
173
+ description: XHS_ARTIFACT_EDITOR_HINT,
174
+ inputSchema: XHS_ARTIFACT_INPUT_SCHEMA,
175
+ metadata: {
176
+ readOnly: false,
177
+ concurrent: true,
178
+ visible: true,
179
+ requiresApproval: false,
180
+ maxResultSizeChars: 60000,
181
+ },
182
+ validate(input) {
183
+ const payload = validateEditorPayload(input?.payload);
184
+ return {
185
+ artifactId: String(input?.artifact_id || '').trim(),
186
+ title: payload.title,
187
+ payload,
188
+ content: '',
189
+ };
190
+ },
191
+ async execute(input, context) {
192
+ const sessionId = context?.session?.sessionId;
193
+ const artifact = input.artifactId
194
+ ? options.registry.updateFromRead(input.artifactId, { title: input.title, payload: input.payload, content: input.content }, context)
195
+ : options.registry.add({
196
+ title: input.title,
197
+ payload: input.payload,
198
+ content: input.content,
199
+ sessionId: context.session?.sessionId,
200
+ });
201
+ 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`);
202
+ const action = input.artifactId ? 'Updated' : 'Opened';
203
+ return {
204
+ ok: true,
205
+ output: {
206
+ artifact: clientArtifact(artifact),
207
+ action: action.toLowerCase(),
208
+ _ui: xhsArtifactPresentation(artifact),
209
+ },
210
+ summary: `${action} Xiaohongshu editor ${artifact.id}`,
211
+ };
212
+ },
213
+ };
214
+ }
215
+
216
+ export function xhsArtifactPresentation(artifact, sessionId = artifact?.sessionId) {
217
+ if (!artifact?.id) return undefined;
218
+ const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
219
+ return {
220
+ title: '编辑小红书笔记',
221
+ bodyTitle: artifact.title || '小红书笔记',
222
+ text: '可编辑笔记已就绪。',
223
+ presentationLevel: 'primary',
224
+ resources: [{
225
+ kind: 'embed',
226
+ url: `/api/xhs-artifacts/${encodeURIComponent(artifact.id)}/editor${query}`,
227
+ label: artifact.title || '小红书笔记编辑器',
228
+ height: 720,
229
+ }],
230
+ };
231
+ }
232
+
233
+ export function createReadXhsArtifactTool(options) {
234
+ return {
235
+ name: 'read_xhs_artifact',
236
+ 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.',
237
+ inputSchema: {
238
+ type: 'object',
239
+ properties: { id: { type: 'string', description: 'Artifact id returned by open_xhs_artifact_editor.' } },
240
+ required: ['id'],
241
+ additionalProperties: false,
242
+ },
243
+ metadata: {
244
+ readOnly: true,
245
+ concurrent: true,
246
+ visible: true,
247
+ requiresApproval: false,
248
+ maxResultSizeChars: 60000,
249
+ },
250
+ validate(input) {
251
+ const id = String(input?.id || '').trim();
252
+ if (!id) throw new Error('id is required');
253
+ return { id };
254
+ },
255
+ async execute(input, context) {
256
+ const artifact = options.registry.readForUpdate(input.id, context);
257
+ if (!artifact) throw new Error(`xhs artifact not found: ${input.id}`);
258
+ return { ok: true, output: { artifact: clientArtifact(artifact) }, summary: '已读取稿件' };
259
+ },
260
+ };
261
+ }
262
+
263
+ export async function serveXhsArtifact(registry, req, res, id, readJsonBody, sessionId) {
264
+ if (req.method === 'GET') {
265
+ const artifact = registry.get(id, sessionId);
266
+ return artifact ? sendJson(res, { ok: true, artifact: clientArtifact(artifact) }) : sendJson(res, { error: 'artifact not found' }, 404);
267
+ }
268
+ if (req.method === 'PUT') {
269
+ const body = await readJsonBody(req);
270
+ if (!Number.isSafeInteger(body?.expected_version) || body.expected_version < 1) {
271
+ const current = registry.get(id, sessionId);
272
+ if (!current) return sendJson(res, { error: 'artifact not found' }, 404);
273
+ return sendJson(res, { error: 'expected_version is required; reload the latest editor before saving', code: 'version_conflict', current_version: current.version }, 409);
274
+ }
275
+ assertExactKeys(body, ['title', 'payload', 'expected_version'], 'request');
276
+ const payload = validateEditorPayload(body?.payload);
277
+ let artifact;
278
+ try { artifact = registry.update(id, { title: payload.title, payload }, sessionId, body.expected_version); }
279
+ catch (error) {
280
+ if (error.code === 'artifact_write_failed') return sendJson(res, { error: error.message, code: error.code, storage_code: error.storage_code }, 500);
281
+ if (['artifact_busy', 'version_conflict'].includes(error.code)) return sendJson(res, { error: error.message, code: error.code, current_version: error.current_version, expected_version: error.expected_version }, error.code === 'artifact_busy' ? 423 : 409);
282
+ throw error;
283
+ }
284
+ return artifact ? sendJson(res, { ok: true, artifact: clientArtifact(artifact) }) : sendJson(res, { error: 'artifact not found' }, 404);
285
+ }
286
+ return sendJson(res, { error: 'method not allowed' }, 405);
287
+ }
288
+
289
+ function normalizeArtifact(value) {
290
+ const payload = validateEditorPayload(value.payload);
291
+ return {
292
+ id: String(value.id),
293
+ type: String(value.type || 'xhs-post'),
294
+ title: payload.title,
295
+ payload,
296
+ content: String(value.content || ''),
297
+ sessionId: value.sessionId,
298
+ version: Number.isSafeInteger(value.version) && value.version > 0 ? value.version : 1,
299
+ createdAt: Number(value.createdAt || Date.now()),
300
+ updatedAt: Number(value.updatedAt || Date.now()),
301
+ };
302
+ }
303
+
304
+ function clientArtifact(artifact) {
305
+ return {
306
+ id: artifact.id,
307
+ type: artifact.type,
308
+ title: artifact.title,
309
+ payload: clientPayload(artifact.payload),
310
+ sessionId: artifact.sessionId,
311
+ version: artifact.version,
312
+ createdAt: artifact.createdAt,
313
+ updatedAt: artifact.updatedAt,
314
+ };
315
+ }
316
+
317
+ function clientPayload(payload) {
318
+ return {
319
+ title: payload.title,
320
+ body: payload.body,
321
+ interaction: payload.interaction,
322
+ hashtags: [...payload.hashtags],
323
+ images: payload.images.map(clientImage),
324
+ review: payload.review,
325
+ };
326
+ }
327
+
328
+ function clientImage(image) {
329
+ return {
330
+ url: safeImageUrl(image.url),
331
+ caption: image.caption,
332
+ overlay: image.overlay,
333
+ note: image.note,
334
+ };
335
+ }
336
+
337
+ function validateEditorPayload(value) {
338
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
339
+ throw contractError('payload must be an object');
340
+ }
341
+ assertExactKeys(value, XHS_PAYLOAD_FIELDS, 'payload');
342
+
343
+ const title = requireEditorString(value.title, 'payload.title').trim().slice(0, 160);
344
+ const body = requireEditorString(value.body, 'payload.body').trim();
345
+ const interaction = requireEditorString(value.interaction, 'payload.interaction').trim();
346
+ const review = requireEditorString(value.review, 'payload.review').trim();
347
+ if (!title) throw contractError('payload.title must contain the final post title');
348
+ if (!body) throw contractError('payload.body must contain final publish-ready正文 only');
349
+ validateBodyBoundaries(body, title);
350
+ if (!Array.isArray(value.hashtags)) throw contractError('payload.hashtags must be an array of strings such as ["#话题"]');
351
+ if (!Array.isArray(value.images)) throw contractError('payload.images must be an array of {url, caption, overlay, note} objects');
352
+ if (!value.images.length) throw contractError('payload.images must contain at least one image or planned placeholder');
353
+
354
+ const hashtags = value.hashtags.map((tag, index) => {
355
+ if (typeof tag !== 'string') throw contractError(`payload.hashtags[${index}] must be a string`);
356
+ const normalized = tag.trim().replace(/^#/u, '#');
357
+ if (!/^#[^\s#]+$/u.test(normalized)) throw contractError(`payload.hashtags[${index}] must be exactly one #topic without spaces`);
358
+ return normalized.slice(0, 100);
359
+ });
360
+ const images = value.images.map((image, index) => validateEditorImage(image, index));
361
+
362
+ return {
363
+ title,
364
+ body,
365
+ interaction,
366
+ hashtags: [...new Set(hashtags)].slice(0, 40),
367
+ images,
368
+ review,
369
+ };
370
+ }
371
+
372
+ function validateEditorImage(value, index) {
373
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
374
+ throw contractError(`payload.images[${index}] must be an object with url, caption, overlay, note`);
375
+ }
376
+ assertExactKeys(value, XHS_IMAGE_FIELDS, `payload.images[${index}]`);
377
+ const rawUrl = requireEditorString(value.url, `payload.images[${index}].url`).trim();
378
+ const url = safeImageUrl(rawUrl);
379
+ if (rawUrl && !url) {
380
+ throw contractError(`payload.images[${index}].url must be a real http(s) URL, /api/ URL, or absolute local image path; use "" for a placeholder`);
381
+ }
382
+ const caption = requireEditorString(value.caption, `payload.images[${index}].caption`).trim();
383
+ const overlay = requireEditorString(value.overlay, `payload.images[${index}].overlay`).trim();
384
+ const note = requireEditorString(value.note, `payload.images[${index}].note`).trim();
385
+ if (!url && !caption && !note) throw contractError(`payload.images[${index}] is an empty placeholder; describe the planned image in caption or note`);
386
+ return {
387
+ url,
388
+ caption: caption.slice(0, 1000),
389
+ overlay: overlay.slice(0, 500),
390
+ note: note.slice(0, 2000),
391
+ };
392
+ }
393
+
394
+ function requireEditorString(value, field) {
395
+ if (typeof value !== 'string') throw contractError(`${field} must be a string`);
396
+ return value;
397
+ }
398
+
399
+ function safeImageUrl(...values) {
400
+ const url = String(values.find((value) => typeof value === 'string') || '').trim();
401
+ if (!url || /^data:/i.test(url)) return '';
402
+ if (/^(?:https?:|\/api\/)/i.test(url)) return url.slice(0, 2048);
403
+ if (isLocalFilePath(url)) return `/api/local-images/${encodeURIComponent(Buffer.from(url, 'utf8').toString('base64url'))}`;
404
+ return '';
405
+ }
406
+
407
+ function isLocalFilePath(value) {
408
+ return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('/');
409
+ }
410
+
411
+ function assertExactKeys(value, expected, path) {
412
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw contractError(`${path} must be an object`);
413
+ const actual = Object.keys(value);
414
+ const missing = expected.filter((key) => !actual.includes(key));
415
+ const extra = actual.filter((key) => !expected.includes(key));
416
+ if (missing.length || extra.length) {
417
+ const details = [
418
+ missing.length ? `missing ${missing.join(', ')}` : '',
419
+ extra.length ? `unexpected ${extra.join(', ')}` : '',
420
+ ].filter(Boolean).join('; ');
421
+ throw contractError(`${path} must contain exactly ${expected.join(', ')} (${details})`);
422
+ }
423
+ }
424
+
425
+ function validateBodyBoundaries(body, title) {
426
+ const firstLine = body.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
427
+ if (firstLine === title || firstLine.replace(/^#{1,6}\s*/, '') === title) {
428
+ throw contractError('payload.body must not repeat payload.title as its first line');
429
+ }
430
+ if (/```|~~~/.test(body)) throw contractError('payload.body must not contain fenced Markdown or JSON');
431
+ if (/^\s*#{1,6}\s*(?:标题|正文|笔记正文|发布文案|配图|图片方案|话题标签|标签|审核|review)\s*[::]?\s*$/imu.test(body)) {
432
+ throw contractError('payload.body must not contain editor section headings');
433
+ }
434
+ if (/(?:^|\s)#[^\s#]+/u.test(body)) throw contractError('payload.body must not contain hashtags; put them in payload.hashtags');
435
+ if (/^\s*\{[\s\S]*"(?:title|body|images|hashtags)"\s*:/u.test(body)) {
436
+ throw contractError('payload.body must not contain serialized tool JSON');
437
+ }
438
+ }
439
+
440
+ function contractError(message) {
441
+ 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.`);
442
+ }
443
+
444
+ function safeArtifactId(value) {
445
+ const id = String(value || '').trim();
446
+ return id && /^[A-Za-z0-9._-]+$/u.test(id) && path.basename(id) === id ? id : '';
447
+ }
448
+
449
+ function safeSessionDirectoryName(value) {
450
+ const id = String(value || '').trim();
451
+ return id && /^[A-Za-z0-9._:-]+$/u.test(id) && !id.includes('..') && path.basename(id) === id ? id : '';
452
+ }
453
+
454
+ function artifactBelongsToSession(artifact, sessionId) {
455
+ const expected = String(sessionId || '').trim();
456
+ return Boolean(artifact) && Boolean(expected) && String(artifact.sessionId || '') === expected;
457
+ }
458
+
459
+ function cloneArtifact(artifact) {
460
+ return JSON.parse(JSON.stringify(artifact));
461
+ }
462
+
463
+ function acquireArtifactLock(lock, expectedVersion) {
464
+ for (let attempt = 0; ; attempt += 1) {
465
+ try { return fs.openSync(lock, 'wx'); }
466
+ catch (cause) {
467
+ if (cause.code === 'EEXIST') throw versionError('artifact_busy', '稿件正在保存,请重试', undefined, expectedVersion);
468
+ // Windows can report EPERM instead of EEXIST for a delete-pending lock.
469
+ // Retry exclusive creation; never assume ownership from existsSync, and
470
+ // never label a persistent ACL/storage error as a version conflict.
471
+ if (process.platform === 'win32' && ['EPERM', 'EACCES', 'EBUSY'].includes(cause.code) && attempt < 20) {
472
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
473
+ continue;
474
+ }
475
+ throw Object.assign(new Error('稿件保存锁不可用,请重试', { cause }), { code: 'artifact_write_failed', storage_code: cause.code });
476
+ }
477
+ }
478
+ }
479
+
480
+ function versionError(code, message, artifact, expectedVersion) {
481
+ return Object.assign(new Error(message), { code, current_version: artifact?.version, expected_version: expectedVersion });
482
+ }
483
+
484
+ function sendJson(res, value, status = 200) {
485
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
486
+ res.end(JSON.stringify(value));
487
+ }