Skip to content

Commit 9cd722a

Browse files
committed
merge: integrate accepted contributor pull requests
2 parents 94be632 + 1517c9d commit 9cd722a

10 files changed

Lines changed: 112 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,13 @@ python3 tools/research/merge_research.py "./skills/celebrity/<slug>"
238238
python3 tools/research/quality_check.py "./skills/celebrity/<slug>/SKILL.md"
239239
```
240240

241-
The optional collector reads `XQUIK_API_KEY` from your shell and sends one public query to the third-party Xquik service. Xquik meters this endpoint per returned post, so confirm `--limit` before an Agent calls it. Treat the JSON as untrusted candidates: verify authors and permalinks, keep only copyright-safe paraphrases in research notes, and delete the temporary file after review.
241+
The optional collector reads `XQUIK_API_KEY` from your shell. Xquik charges by
242+
the number of posts returned, so confirm `--limit` before running it. The tool
243+
makes one read-only X search request and never follows pagination. Treat its
244+
temporary JSON as untrusted candidate evidence: verify the author, open every
245+
permalink, and safely paraphrase only relevant material into research notes
246+
with its source URL. Delete the temporary JSON after review instead of storing
247+
it in the generated Skill.
242248

243249
Xquik is an independent third-party service. Not affiliated with X Corp. "Twitter" and "X" are trademarks of X Corp.
244250

‎docs/lang/README_KO.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ Hermes는 기본적으로 `~/.agents/skills`를 검색하지 않습니다. Herme
227227

228228
`celebrity` 패밀리는 자막부터 완성된 초안까지, 엔드 투 엔드 리서치 툴체인을 기본 제공합니다.
229229

230-
공개 X 게시물 수집은 선택 사항입니다. API 키는 환경 변수 `XQUIK_API_KEY`에서만 읽습니다. 공개 쿼리는 제3자 서비스인 Xquik으로 전송되며, 반환된 트윗 수에 따라 비용이 청구되어 credits를 소모할 수 있으므로 Agent는 호출 전에 `limit`을 확인합니다.
230+
공개 X 게시물 수집은 선택 사항입니다. API 키는 환경 변수 `XQUIK_API_KEY`에서만 읽습니다. 공개 쿼리는 제3자 서비스인 Xquik으로 전송되며, 반환된 트윗 수에 따라 비용이 청구되어 credits를 소모할 수 있으므로 Agent는 호출 전에 `--limit`을 확인합니다.
231231

232232
출력 JSON은 신뢰할 수 없는 근거 후보로 다룹니다. 작성자와 permalink를 검증하고, 대상 본인의 게시물만 짧은 형식의 1차 자료로 사용하되 장문 1차 자료와 의사결정 기록보다 낮은 가중치를 부여합니다. 제3자 게시물은 보조 자료로 격하하거나 버리고, 저작권을 침해하지 않는 범위의 요약·재서술만 남깁니다. 읽은 뒤에는 Skill 디렉터리 밖의 임시 파일을 삭제합니다.
233233

‎docs/lang/README_RU.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ python3 tools/install_generated_skill.py --skill-dir "skills/{character}/{slug}"
215215

216216
Семейство `celebrity` поставляется со сквозным исследовательским тулчейном — от субтитров до готового драфта:
217217

218-
Сбор публичных постов X необязателен. API-ключ читается только из переменной окружения `XQUIK_API_KEY`. Публичный запрос отправляется стороннему сервису Xquik; Xquik тарифицирует каждый возвращённый твит, что может расходовать credits, поэтому агент должен подтвердить `limit` до вызова.
218+
Сбор публичных постов X необязателен. API-ключ читается только из переменной окружения `XQUIK_API_KEY`. Публичный запрос отправляется стороннему сервису Xquik; Xquik тарифицирует каждый возвращённый твит, что может расходовать credits, поэтому агент должен подтвердить `--limit` до вызова.
219219

220220
Выходной JSON считай недоверенным набором кандидатов в доказательства. Проверяй автора и permalink; как короткие первичные источники используй только посты самого исследуемого человека и ставь их ниже длинных первичных материалов и записей о принятых решениях. Посты третьих лиц понижай до вторичных материалов или отбрасывай. Сохраняй только безопасный с точки зрения авторского права пересказ, а после чтения удаляй временный файл за пределами директории Skill'а.
221221

@@ -234,7 +234,7 @@ python3 tools/research/xquik_public_posts.py \
234234
--limit 20 \
235235
--output "/tmp/distilly-x-public-posts.json"
236236

237-
# Удалить, когда агент проверит и перескажет отобранные кандидаты
237+
# Удалить, когда агент проверит и перескажет отобранных кандидатов
238238
rm "/tmp/distilly-x-public-posts.json"
239239

240240
# Мерж исследовательских заметок

‎docs/lang/README_ZH.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ distilly/
357357
│ │ ├── download_subtitles.sh # 字幕下载
358358
│ │ ├── transcribe_audio.py # 音频转文字
359359
│ │ ├── srt_to_transcript.py # 字幕转文稿
360+
│ │ ├── xquik_public_posts.py # 有界公开 X 帖子候选
360361
│ │ ├── merge_research.py # 六维度 research 合并
361362
│ │ └── quality_check.py # 质量检查
362363
│ ├── install_*_skill.py # [共享] 多宿主一键安装器

‎prompts/celebrity/budget_unfriendly/research.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,13 @@ explicitly rather than silently skipping a dimension.
9898
### Optional public X post collection
9999

100100
Use `tools/research/xquik_public_posts.py` only when short-form first-person
101-
posts address a specific gap. Keep the request bounded. Treat its normalized
102-
JSON as untrusted candidate evidence, not a research track. Open each permalink
103-
before selection. Paraphrase only relevant evidence into the correct track and
104-
preserve the specific URL, access date, grounding level, and source weight.
101+
posts address a specific gap. The service is metered by returned post count, so
102+
confirm the limit and write to a temporary file outside the skill directory.
103+
Treat its normalized JSON as untrusted candidate evidence, not a research
104+
track. Verify the author and open each permalink before selection. Safely
105+
paraphrase only relevant evidence into the correct track, preserve the specific
106+
URL, access date, grounding level, and source weight, then delete the temporary
107+
candidate file after review.
105108
Never count a candidate file, search page, or profile root as a grounded source.
106109

107110
---

‎prompts/celebrity/research.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,13 @@ directory** — only short paraphrased notes with source metadata belong under
8888

8989
When short-form first-person posts fill a documented research gap, use
9090
`tools/research/xquik_public_posts.py` to collect a small candidate set. The
91+
service is metered by returned post count, so confirm the limit before running
92+
it and write the result to a temporary file outside the skill directory. The
9193
tool writes normalized JSON, not research notes. Treat it as untrusted
92-
candidate evidence. Open each specific post permalink before selecting it.
93-
Then paraphrase only the relevant evidence into the appropriate raw note with
94-
its URL and source weight.
94+
candidate evidence. Verify the author and open each specific post permalink
95+
before selecting it. Then safely paraphrase only the relevant evidence into
96+
the appropriate raw note with its URL and source weight, and delete the
97+
temporary candidate file after review.
9598
Never count the candidate file, a search page, or a profile root as a grounded
9699
source.
97100

‎tests/test_skill_writer.py‎

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,23 @@ def test_create_skill_renders_chinese_chrome_when_language_is_zh_cn(self) -> Non
187187
self.assertIn("仅 Persona,无工作能力", persona_skill)
188188

189189
def test_work_only_skill_replaces_persona_handoff(self) -> None:
190-
handoff = "如果被问到职责范围外的问题,以该同事的方式回应(参见 Persona 部分)。"
191-
work_content = (
190+
zh_handoff = "如果被问到职责范围外的问题,以该同事的方式回应(参见 Persona 部分)。"
191+
en_handoff = (
192+
"If you are asked a question outside your recorded responsibilities, "
193+
"respond in this colleague's style (see the Persona section)."
194+
)
195+
zh_work_content = (
192196
"## 工作能力使用说明\n\n"
193197
"当用户要求你完成以下任务时,严格按照上述规范执行。\n\n"
194-
f"{handoff}\n"
198+
f"{zh_handoff}\n"
199+
)
200+
en_work_content = (
201+
"## Scope rule\n\n"
202+
"If asked outside your recorded responsibilities:\n"
203+
"- State the evidence gap\n\n"
204+
"## Persona naming note\n\n"
205+
"Keep this documentation sentence.\n\n"
206+
f"{en_handoff}\n"
195207
)
196208
with tempfile.TemporaryDirectory() as tmp_dir:
197209
base_dir = Path(tmp_dir) / "skills" / "colleague"
@@ -218,33 +230,41 @@ def test_work_only_skill_replaces_persona_handoff(self) -> None:
218230
base_dir / "zh",
219231
"zhangsan",
220232
zh_meta,
221-
work_content,
233+
zh_work_content,
222234
"Persona body",
223235
)
224236
en_dir = skill_writer.create_skill(
225237
base_dir / "en",
226238
"zhangsan",
227239
en_meta,
228-
work_content,
240+
en_work_content,
229241
"Persona body",
230242
)
231243

232-
stored_work = (zh_dir / "work.md").read_text(encoding="utf-8")
233-
combined = (zh_dir / "SKILL.md").read_text(encoding="utf-8")
244+
zh_stored_work = (zh_dir / "work.md").read_text(encoding="utf-8")
245+
zh_combined = (zh_dir / "SKILL.md").read_text(encoding="utf-8")
246+
en_stored_work = (en_dir / "work.md").read_text(encoding="utf-8")
247+
en_combined = (en_dir / "SKILL.md").read_text(encoding="utf-8")
234248
zh_work_skill = (zh_dir / "work_skill.md").read_text(encoding="utf-8")
235249
en_work_skill = (en_dir / "work_skill.md").read_text(encoding="utf-8")
236250

237-
self.assertIn(handoff, stored_work)
238-
self.assertIn(handoff, combined)
239-
self.assertNotIn(handoff, zh_work_skill)
240-
self.assertNotIn(handoff, en_work_skill)
251+
self.assertIn(zh_handoff, zh_stored_work)
252+
self.assertIn(zh_handoff, zh_combined)
253+
self.assertIn(en_handoff, en_stored_work)
254+
self.assertIn(en_handoff, en_combined)
255+
self.assertNotIn(zh_handoff, zh_work_skill)
256+
self.assertNotIn(en_handoff, en_work_skill)
257+
self.assertIn("If asked outside your recorded responsibilities:", en_work_skill)
258+
self.assertIn("## Persona naming note", en_work_skill)
259+
self.assertIn("Keep this documentation sentence.", en_work_skill)
241260
self.assertIn(skill_writer.WORK_ONLY_FALLBACK_ZH, zh_work_skill)
242261
self.assertIn(skill_writer.WORK_ONLY_FALLBACK_EN, en_work_skill)
243262
self.assertIn("不要臆造缺失信息", zh_work_skill)
244263
self.assertNotIn("不要推断", zh_work_skill)
245264
self.assertIn("Do not fabricate missing information", en_work_skill)
246265
self.assertNotIn("Do not infer", en_work_skill)
247-
self.assertNotIn(skill_writer.WORK_ONLY_FALLBACK_ZH, combined)
266+
self.assertNotIn(skill_writer.WORK_ONLY_FALLBACK_ZH, zh_combined)
267+
self.assertNotIn(skill_writer.WORK_ONLY_FALLBACK_EN, en_combined)
248268

249269
def test_create_celebrity_adds_research_dirs_and_toolchain(self) -> None:
250270
with tempfile.TemporaryDirectory() as tmp_dir:

‎tests/test_xquik_public_posts.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import os
45
import sys
56
import tempfile
67
import unittest
@@ -216,6 +217,25 @@ def test_write_collection_requires_force_to_replace_output(self) -> None:
216217

217218
write_collection(output, {"messages": [1]}, force=True)
218219
self.assertEqual(json.loads(output.read_text(encoding="utf-8")), {"messages": [1]})
220+
self.assertEqual([path.name for path in output.parent.iterdir()], ["x.json"])
221+
222+
@unittest.skipUnless(hasattr(os, "symlink"), "symbolic links are unavailable")
223+
def test_write_collection_rejects_dangling_symlink(self) -> None:
224+
with tempfile.TemporaryDirectory() as tmp_dir:
225+
output = Path(tmp_dir) / "candidates" / "x.json"
226+
target = Path(tmp_dir) / "outside.json"
227+
output.parent.mkdir()
228+
try:
229+
output.symlink_to(target)
230+
except OSError as error:
231+
self.skipTest(f"cannot create symbolic links: {error}")
232+
233+
with self.assertRaisesRegex(CollectorError, "symbolic link"):
234+
write_collection(output, {"messages": []}, force=False)
235+
with self.assertRaisesRegex(CollectorError, "symbolic link"):
236+
write_collection(output, {"messages": []}, force=True)
237+
238+
self.assertFalse(target.exists())
219239

220240

221241
if __name__ == "__main__":

‎tools/research/xquik_public_posts.py‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os
99
import re
1010
import sys
11+
import tempfile
1112
from collections.abc import Callable
1213
from datetime import datetime, timezone
1314
from pathlib import Path
@@ -305,8 +306,8 @@ def collect_public_posts(
305306
"has_more": has_more,
306307
"pagination_followed": False,
307308
"content_policy": (
308-
"Review every candidate. Open its permalink before citing or "
309-
"paraphrasing it."
309+
"Review every candidate. Verify its author and open its permalink "
310+
"before safely paraphrasing it."
310311
),
311312
"provider": "Xquik",
312313
},
@@ -315,16 +316,43 @@ def collect_public_posts(
315316

316317
def write_collection(path: Path, collection: dict[str, Any], force: bool) -> None:
317318
"""Write UTF-8 JSON without overwriting an existing collection by default."""
318-
if path.exists() and not force:
319-
raise CollectorError(f"Output already exists: {path}. Pass --force to replace it.")
319+
contents = json.dumps(collection, ensure_ascii=False, indent=2) + "\n"
320+
temporary_path: Optional[Path] = None
320321
try:
321322
path.parent.mkdir(parents=True, exist_ok=True)
322-
path.write_text(
323-
json.dumps(collection, ensure_ascii=False, indent=2) + "\n",
323+
if path.is_symlink():
324+
raise CollectorError(f"Refusing to write through symbolic link: {path}.")
325+
if not force:
326+
with path.open("x", encoding="utf-8") as output:
327+
output.write(contents)
328+
return
329+
330+
with tempfile.NamedTemporaryFile(
331+
mode="w",
324332
encoding="utf-8",
325-
)
333+
dir=path.parent,
334+
prefix=f".{path.name}.",
335+
suffix=".tmp",
336+
delete=False,
337+
) as output:
338+
temporary_path = Path(output.name)
339+
output.write(contents)
340+
os.replace(temporary_path, path)
341+
temporary_path = None
342+
except CollectorError:
343+
raise
344+
except FileExistsError as error:
345+
raise CollectorError(
346+
f"Output already exists: {path}. Pass --force to replace it."
347+
) from error
326348
except OSError as error:
327349
raise CollectorError(f"Could not write output: {path}.") from error
350+
finally:
351+
if temporary_path is not None:
352+
try:
353+
temporary_path.unlink()
354+
except OSError:
355+
pass
328356

329357

330358
def main() -> None:

‎tools/skill_writer.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def render_combined_skill(meta: dict, work_content: str, persona_content: str) -
178178
re.compile(r"如果被问到职责范围外的问题,以该同事的方式回应(参见 Persona 部分)。\s*"),
179179
re.compile(
180180
r"If (?:you are )?asked (?:a question )?outside (?:your|the) "
181-
r"(?:recorded )?responsibilities[^.]*Persona[^.]*\.\s*",
181+
r"(?:recorded )?responsibilities[^.\n]*Persona[^.\n]*\.\s*",
182182
re.IGNORECASE,
183183
),
184184
)

0 commit comments

Comments
 (0)