@lightcone-ai/daemon 0.16.0 → 0.16.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -904,11 +904,16 @@ server.tool('search_messages', 'Search messages within a specific workspace. You
904
904
  const data = await api('GET', `/search?${params}`);
905
905
  if (!data.results || data.results.length === 0)
906
906
  return { content: [{ type: 'text', text: 'No search results.' }] };
907
+ // Use full content rather than snippet — the snippet truncates at ~200 chars,
908
+ // which routinely cuts URLs / structured data in half (e.g. a forwarded list
909
+ // of mp.weixin.qq.com article URLs becomes useless if only the second one
910
+ // shrinks below its query-string). For search the agent's intent is usually
911
+ // "give me the original message verbatim so I can act on it", not a teaser.
907
912
  const formatted = data.results.map((r, i) => [
908
913
  `[${i + 1}] msg=${r.id} seq=${r.seq} time=${r.createdAt}`,
909
914
  `workspace: #${r.workspaceName}`,
910
915
  `sender: @${r.senderName}${r.senderType === 'agent' ? ' (agent)' : ''}`,
911
- `content: ${r.snippet}`,
916
+ `content: ${r.content ?? r.snippet}`,
912
917
  ].join('\n')).join('\n\n');
913
918
  return { content: [{ type: 'text', text: `## Search Results for "${trimmed}" (${data.results.length} results)\n\n${formatted}` }] };
914
919
  } catch (err) {
@@ -1482,7 +1487,21 @@ server.tool('record_url_narration',
1482
1487
  'Record a silent video of a URL by driving Chromium on an Xvfb display and capturing it with Playwright recordVideo, driven by a video plan; ffmpeg then transcodes the recording to mp4. Outputs a silent mp4 that can be passed to compose_video_v2 as a video-kind segment with an audio_path for narration.\n\nUse this as the canonical recording step for URL-narration videos. Falls back: if the page needs interactions outside the visual_action vocabulary (clicks, waits, OCR loops), use Monitor (Bash) with custom Playwright instead.\n\nRuntime requirements: this tool only works on a Linux daemon machine with Xvfb + Chromium + ffmpeg installed (ffmpeg is used to transcode the recording to mp4; no x11grab device support needed). macOS / Windows daemons will fail at startup.',
1483
1488
  {
1484
1489
  url: z.string().describe('Page URL to record'),
1485
- plan: z.record(z.any()).describe('A video plan: an object with `phases` (or `sections`), each a "visual beat" with `action` (scroll_to_dwell / linear_scroll_during / scroll_back / hold / ...), a target (`target_y` or `focus_region:[y1,y2]`) for scroll-type actions, and `dwell_ms` (how long to hold that beat — should match the segment\'s TTS duration). It can be hand-written or the output of plan_video_segments (whose returned segments array doubles as a valid plan).'),
1490
+ plan: z.record(z.any()).describe(
1491
+ 'A video plan: an object with `phases` (or `sections`), each a "visual beat" with '
1492
+ + '`action` (scroll_to_dwell / linear_scroll_during / scroll_back / hold / ...), a '
1493
+ + 'target (`target_y` or `focus_region:[y1,y2]`) for scroll-type actions, and '
1494
+ + '`dwell_ms` (how long to hold that beat — should match the segment\'s TTS duration).\n\n'
1495
+ + 'For RECRUITMENT URLs (mp.weixin.qq.com / 校招 / 实习 / 岗位 content), each section MUST '
1496
+ + 'also declare `target_y_content_label` — a short Chinese label describing what content '
1497
+ + 'sits at that pixel y position on the page (e.g. "标题区" / "岗位信息卡片" / "公司介绍" / '
1498
+ + '"届别说明"). Look at the take_page_screenshot output, find the y-pixel, and label it. '
1499
+ + 'Labels matching forbidden regions ("二维码" / "扫码" / "投递入口" / "投递方式" / "联系方式" / '
1500
+ + '"微信号" / "QR" / "阅读原文" / "外链") will cause the tool to refuse the recording — '
1501
+ + 'recruitment content must NOT dwell on these areas (see fragments.md '
1502
+ + 'frag.short.recruitment_url_mode_policy). Pick a different target_y in the 标题/岗位 '
1503
+ + 'information area and rewrite that section.'
1504
+ ),
1486
1505
  output_path: z.string().optional().describe('Workspace-relative output mp4 path. Default tmp/wx3_video/recorded-{ts}.mp4'),
1487
1506
  events_path: z.string().optional().describe('Workspace-relative events.json path. Default ${output_path}.events.json'),
1488
1507
  viewport: z.object({
@@ -69,6 +69,74 @@ function assertPipelineCompliance(plan) {
69
69
  }
70
70
  }
71
71
 
72
+ // Forbidden region keywords for recruitment content. If a section's
73
+ // target_y_content_label matches, we refuse to record — the resulting video
74
+ // would show 投递入口 / 二维码 / contact info, which violates the recruitment
75
+ // content policy (see fragments.md frag.short.recruitment_url_mode_policy).
76
+ //
77
+ // Discovered after Task #25 v1 ended up dwelling on FunPlus's QR/投递 area:
78
+ // the agent's plan declared target_y=2180 with dwell_ms=8500 without checking
79
+ // what content lived at that pixel position. This is a prompt-level rule
80
+ // that's been ignored often enough that we enforce it at the tool layer.
81
+ const FORBIDDEN_REGION_PATTERNS = [
82
+ /二维码/, /扫码/, /扫一扫/,
83
+ /投递入口/, /投递方式/, /投递通道/, /投递渠道/, /报名入口/, /报名方式/,
84
+ /联系方式/, /联系人/, /微信号/, /\bWeChat\b/i, /\bQQ群\b/,
85
+ /阅读原文/, /外链/, /\bQR\b/i,
86
+ ];
87
+
88
+ function isRecruitmentLikeUrl(url) {
89
+ // Conservative URL-based heuristic: mp.weixin.qq.com pages forwarding 招聘 /
90
+ // 校招 / 实习 / job content. Until we have content classification, treat
91
+ // mp.weixin.qq.com URLs as recruitment-class for safety — the cost of a
92
+ // mis-flag is "agent must add a label", not "recording fails permanently".
93
+ if (typeof url !== 'string') return false;
94
+ return /mp\.weixin\.qq\.com/.test(url);
95
+ }
96
+
97
+ function describeForbiddenMatch(label) {
98
+ for (const pattern of FORBIDDEN_REGION_PATTERNS) {
99
+ if (pattern.test(label)) return pattern.source;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * For recruitment-class URLs, every plan section must declare what content
106
+ * sits at its target_y, and the label must NOT match the forbidden-region
107
+ * patterns. Returns null on pass, error message string on fail.
108
+ */
109
+ function checkSafeRegionLabels({ url, plan }) {
110
+ if (!isRecruitmentLikeUrl(url)) return null;
111
+ const segments = planSegments(plan);
112
+ if (!segments) return null;
113
+ for (let i = 0; i < segments.length; i += 1) {
114
+ const seg = segments[i] ?? {};
115
+ const label = normalizeText(seg.target_y_content_label ?? seg.targetYContentLabel ?? '');
116
+ if (!label) {
117
+ return (
118
+ `record_url_narration: section[${i}] is missing required field `
119
+ + `\`target_y_content_label\`. For recruitment URLs (mp.weixin.qq.com / `
120
+ + `校招 / 实习等) you MUST label what content lives at target_y so the `
121
+ + `tool can verify it is not 二维码/投递入口/联系方式. Look at the page `
122
+ + `screenshot, find what is at target_y=${seg.target_y ?? '<unset>'}, `
123
+ + `and add a short label like "标题区" / "岗位信息卡片" / "公司介绍".`
124
+ );
125
+ }
126
+ const match = describeForbiddenMatch(label);
127
+ if (match) {
128
+ return (
129
+ `record_url_narration: section[${i}] target_y=${seg.target_y ?? '?'} `
130
+ + `is labeled "${label}", which matches a forbidden region pattern `
131
+ + `/${match}/. Recruitment content must NOT dwell on 投递入口 / 二维码 / `
132
+ + `联系方式 areas. Pick a different target_y inside the 标题区 / 岗位 `
133
+ + `信息卡片 / 公司介绍 area and rewrite this section.`
134
+ );
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
72
140
  export function validateRecordUrlNarrationArgs(args = {}) {
73
141
  const normalizedUrl = normalizeText(args.url);
74
142
  if (!normalizedUrl) {
@@ -140,6 +208,18 @@ export async function runRecordUrlNarrationTool({
140
208
  return toolError(`Error: ${error.message}`);
141
209
  }
142
210
 
211
+ // Safe-region check for recruitment URLs — refuse plans that dwell on
212
+ // forbidden regions (二维码 / 投递入口 / 联系方式) before we even start
213
+ // Chromium. The agent must label each target_y with the content that lives
214
+ // there, and the labels are pattern-matched against a forbidden list.
215
+ const safeRegionError = checkSafeRegionLabels({
216
+ url: validatedInput.url,
217
+ plan: validatedInput.plan,
218
+ });
219
+ if (safeRegionError) {
220
+ return toolError(`Error: ${safeRegionError}`);
221
+ }
222
+
143
223
  try {
144
224
  const result = await runMandatoryLocalToolFn({
145
225
  toolName: 'record_url_narration',
@@ -35,6 +35,12 @@ export const PART_RETRY_ATTEMPTS = 3;
35
35
  export const PART_RETRY_BASE_MS = 1_000; // 1s, 3s, 9s
36
36
  export const TERMINAL_JOB_TTL_MS = 7 * 24 * 3600 * 1000; // sweep done/dead_letter after 7 days
37
37
  export const HOUSEKEEPING_INTERVAL_MS = 6 * 3600 * 1000; // run housekeeping every 6h
38
+ // Per-PUT timeout — Node's fetch has no overall request timeout. Without this
39
+ // a stalled COS connection wedges the chunk loop forever (observed during the
40
+ // first Task #25 upload: chunk 1 PUT hung 7+ minutes with no progress, no
41
+ // error). 5 minutes covers slow networks for an 8MB chunk (~25kB/s floor)
42
+ // while still letting failures surface to the chunk-level retry loop.
43
+ export const PUT_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
38
44
 
39
45
  function nowIso() { return new Date().toISOString(); }
40
46
 
@@ -61,6 +67,23 @@ function partBackoffMs(attempt) {
61
67
 
62
68
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
63
69
 
70
+ async function fetchWithTimeout(fetchFn, url, init, timeoutMs) {
71
+ const controller = new AbortController();
72
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
73
+ try {
74
+ return await fetchFn(url, { ...init, signal: controller.signal });
75
+ } catch (err) {
76
+ if (err?.name === 'AbortError' || controller.signal.aborted) {
77
+ const wrapped = new Error(`PUT timed out after ${Math.round(timeoutMs / 1000)}s (COS unresponsive)`);
78
+ wrapped.cause = err;
79
+ throw wrapped;
80
+ }
81
+ throw err;
82
+ } finally {
83
+ clearTimeout(timer);
84
+ }
85
+ }
86
+
64
87
  /**
65
88
  * UploadJobManager — singleton in daemon process.
66
89
  *
@@ -319,7 +342,7 @@ export class UploadJobManager {
319
342
  this._persist(job);
320
343
 
321
344
  const fileBuf = await fsPromises.readFile(job.localPath);
322
- const resp = await this.fetchFn(presign.uploadUrl, {
345
+ const resp = await fetchWithTimeout(this.fetchFn, presign.uploadUrl, {
323
346
  method: presign.method ?? 'PUT',
324
347
  headers: {
325
348
  'Content-Type': job.mime,
@@ -327,7 +350,7 @@ export class UploadJobManager {
327
350
  ...(presign.headers ?? {}),
328
351
  },
329
352
  body: fileBuf,
330
- });
353
+ }, PUT_REQUEST_TIMEOUT_MS);
331
354
  if (!resp.ok) {
332
355
  const text = await resp.text().catch(() => '');
333
356
  throw new Error(`single PUT failed: HTTP ${resp.status} ${text.slice(0, 200)}`);
@@ -403,14 +426,14 @@ export class UploadJobManager {
403
426
  cosUploadId: job.cosUploadId,
404
427
  partNumber,
405
428
  });
406
- const resp = await this.fetchFn(presign.url, {
429
+ const resp = await fetchWithTimeout(this.fetchFn, presign.url, {
407
430
  method: presign.method ?? 'PUT',
408
431
  headers: {
409
432
  'Content-Length': String(buf.length),
410
433
  ...(presign.headers ?? {}),
411
434
  },
412
435
  body: buf,
413
- });
436
+ }, PUT_REQUEST_TIMEOUT_MS);
414
437
  if (!resp.ok) {
415
438
  const text = await resp.text().catch(() => '');
416
439
  throw new Error(`HTTP ${resp.status} ${text.slice(0, 200)}`);