sophhub 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +29 -0
  2. package/agents/ai-cs-admin/.config.json +34 -0
  3. package/agents/ai-cs-admin/AGENTS.md +293 -0
  4. package/agents/ai-cs-admin/BOOTSTRAP.md +19 -0
  5. package/agents/ai-cs-admin/HEARTBEAT.md +19 -0
  6. package/agents/ai-cs-admin/IDENTITY.md +6 -0
  7. package/agents/ai-cs-admin/MEMORY.md +22 -0
  8. package/agents/ai-cs-admin/SOUL.md +25 -0
  9. package/agents/ai-cs-admin/TOOLS.md +98 -0
  10. package/agents/ai-cs-admin/USER.md +17 -0
  11. package/agents/ai-cs-qa/.config.json +32 -0
  12. package/agents/ai-cs-qa/AGENTS.md +284 -0
  13. package/agents/ai-cs-qa/BOOTSTRAP.md +22 -0
  14. package/agents/ai-cs-qa/HEARTBEAT.md +20 -0
  15. package/agents/ai-cs-qa/IDENTITY.md +6 -0
  16. package/agents/ai-cs-qa/MEMORY.md +22 -0
  17. package/agents/ai-cs-qa/SOUL.md +33 -0
  18. package/agents/ai-cs-qa/TOOLS.md +35 -0
  19. package/agents/ai-cs-qa/USER.md +16 -0
  20. package/bin/sophhub.js +2 -0
  21. package/package.json +3 -2
  22. package/skills/notes-hub-assistant/skill.json +20 -0
  23. package/skills/notes-hub-assistant/src/SKILL.md +233 -0
  24. package/skills/notes-hub-assistant/src/scripts/_resolve_lark_cli.py +48 -0
  25. package/skills/notes-hub-assistant/src/scripts/openclaw_meeting_minutes.py +473 -0
  26. package/skills/notes-hub-assistant/src/scripts/openclaw_notes_crud.py +596 -0
  27. package/skills/notes-hub-assistant/src/scripts/openclaw_wolai_notes_crud.py +364 -0
  28. package/skills/notes-hub-assistant/src/scripts/run_meeting_minutes.py +79 -0
  29. package/skills/notes-hub-assistant/src/scripts/run_note_crud.py +37 -0
  30. package/skills/notes-hub-assistant/src/scripts/run_notionbot.py +36 -0
  31. package/skills/notes-hub-assistant/src/scripts/run_wolai_note_crud.py +27 -0
  32. package/src/commands/agent.js +112 -0
  33. package/src/utils/agents.js +36 -0
  34. package/src/utils/paths.js +12 -0
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env python3
2
+ """OpenClaw downstream meeting-minutes orchestrator.
3
+
4
+ This script implements the reusable half of the OpenClaw flow:
5
+
6
+ minute_token -> lark-cli minutes/vc/docs -> Markdown -> JSON result
7
+
8
+ It intentionally does not upload audio into Feishu Minutes. That step must be
9
+ handled by an upstream ingest adapter which returns a minute_token.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any, Dict, Optional
21
+
22
+
23
+ REQUIRED_SCOPES = (
24
+ "vc:note:read "
25
+ "minutes:minutes:readonly "
26
+ "minutes:minutes.artifacts:read "
27
+ "minutes:minutes.transcript:export "
28
+ "docx:document:create"
29
+ )
30
+
31
+
32
+ class CLIError(Exception):
33
+ def __init__(self, message: str, code: str = "cli_error", detail: Any = None):
34
+ super().__init__(message)
35
+ self.code = code
36
+ self.detail = detail
37
+
38
+
39
+ def parse_args() -> argparse.Namespace:
40
+ parser = argparse.ArgumentParser(
41
+ description="Build meeting minutes Markdown from an existing minute_token."
42
+ )
43
+ parser.add_argument("--minute-token", required=True, help="Feishu minute_token")
44
+ parser.add_argument("--job-id", required=True, help="OpenClaw job ID")
45
+ parser.add_argument("--title", help="Override meeting title")
46
+ parser.add_argument("--folder-token", help="Target Feishu folder token")
47
+ parser.add_argument(
48
+ "--output-dir",
49
+ default=".openclaw-meeting-minutes",
50
+ help="Directory for transcript/artifact files",
51
+ )
52
+ parser.add_argument(
53
+ "--config-dir",
54
+ help="Override LARKSUITE_CLI_CONFIG_DIR for per-user isolation",
55
+ )
56
+ parser.add_argument(
57
+ "--lark-cli-prefix",
58
+ action="append",
59
+ default=[],
60
+ help="repeatable argv prefix, e.g. --lark-cli-prefix npx --lark-cli-prefix -y ...",
61
+ )
62
+ parser.add_argument(
63
+ "--lark-cli-bin",
64
+ default="lark-cli",
65
+ help="(legacy) Path to the lark-cli executable; ignored if --lark-cli-prefix is provided",
66
+ )
67
+ return parser.parse_args()
68
+
69
+
70
+ def build_env(config_dir: Optional[str]) -> Dict[str, str]:
71
+ env = os.environ.copy()
72
+ if config_dir:
73
+ env["LARKSUITE_CLI_CONFIG_DIR"] = config_dir
74
+ return env
75
+
76
+
77
+ def run_cli(
78
+ cli_prefix: list[str],
79
+ env: Dict[str, str],
80
+ args: list[str],
81
+ check: bool = True,
82
+ cwd: Optional[Path] = None,
83
+ ) -> Any:
84
+ proc = subprocess.run(
85
+ cli_prefix + args,
86
+ capture_output=True,
87
+ text=True,
88
+ env=env,
89
+ cwd=str(cwd) if cwd else None,
90
+ )
91
+ stdout = proc.stdout.strip()
92
+ stderr = proc.stderr.strip()
93
+
94
+ parsed: Any = None
95
+ if stdout:
96
+ try:
97
+ parsed = json.loads(stdout)
98
+ except json.JSONDecodeError as exc:
99
+ raise CLIError(
100
+ f"lark-cli returned non-JSON stdout: {stdout[:400]}",
101
+ code="invalid_cli_output",
102
+ detail={"stderr": stderr, "args": args},
103
+ ) from exc
104
+
105
+ if check and proc.returncode != 0:
106
+ error_detail = parsed if isinstance(parsed, dict) else {"stderr": stderr}
107
+ raise CLIError(
108
+ f"lark-cli command failed: {' '.join(args)}",
109
+ code="lark_cli_failed",
110
+ detail=error_detail,
111
+ )
112
+
113
+ return parsed
114
+
115
+
116
+ def unwrap_payload(payload: Any) -> Any:
117
+ if isinstance(payload, dict) and "ok" in payload:
118
+ if not payload.get("ok", False):
119
+ error = payload.get("error") or {}
120
+ raise CLIError(
121
+ error.get("message", "lark-cli returned an error envelope"),
122
+ code=error.get("type", "lark_error"),
123
+ detail=payload,
124
+ )
125
+ return payload.get("data")
126
+ return payload
127
+
128
+
129
+ def ensure_auth(cli_prefix: list[str], env: Dict[str, str]) -> None:
130
+ run_cli(cli_prefix, env, ["auth", "check", "--scope", REQUIRED_SCOPES], check=True)
131
+
132
+
133
+ def minute_info(
134
+ cli_prefix: list[str], env: Dict[str, str], minute_token: str
135
+ ) -> Dict[str, Any]:
136
+ payload = run_cli(
137
+ cli_prefix,
138
+ env,
139
+ [
140
+ "api",
141
+ "GET",
142
+ f"/open-apis/minutes/v1/minutes/{minute_token}",
143
+ "--as",
144
+ "bot",
145
+ "--format",
146
+ "json",
147
+ ],
148
+ )
149
+ data = unwrap_payload(payload)
150
+ if isinstance(data, dict) and "data" in data and isinstance(data["data"], dict):
151
+ data = data["data"]
152
+ if not isinstance(data, dict):
153
+ raise CLIError("unexpected minutes.get payload", code="invalid_minutes_payload")
154
+ minute = data.get("minute")
155
+ if not isinstance(minute, dict):
156
+ raise CLIError("minutes.get missing minute object", code="missing_minute")
157
+ return minute
158
+
159
+
160
+ def notes_info(
161
+ cli_prefix: list[str], env: Dict[str, str], minute_token: str, output_dir: Path
162
+ ) -> Dict[str, Any]:
163
+ output_dir = output_dir.resolve()
164
+ payload = run_cli(
165
+ cli_prefix,
166
+ env,
167
+ [
168
+ "vc",
169
+ "+notes",
170
+ "--minute-tokens",
171
+ minute_token,
172
+ "--output-dir",
173
+ "vc-notes-artifacts",
174
+ "--format",
175
+ "json",
176
+ ],
177
+ cwd=output_dir,
178
+ )
179
+ data = unwrap_payload(payload)
180
+ if not isinstance(data, dict):
181
+ raise CLIError("unexpected vc +notes payload", code="invalid_notes_payload")
182
+ notes = data.get("notes")
183
+ if not isinstance(notes, list) or not notes:
184
+ raise CLIError("vc +notes returned no notes", code="missing_notes")
185
+ first = notes[0]
186
+ if not isinstance(first, dict):
187
+ raise CLIError("invalid note item shape", code="invalid_note_item")
188
+ if first.get("error"):
189
+ raise CLIError(str(first["error"]), code="note_query_failed", detail=first)
190
+ artifacts = first.get("artifacts")
191
+ if isinstance(artifacts, dict):
192
+ transcript_file = artifacts.get("transcript_file")
193
+ if isinstance(transcript_file, str) and transcript_file:
194
+ transcript_path = Path(transcript_file)
195
+ if not transcript_path.is_absolute():
196
+ artifacts["transcript_file"] = str(
197
+ (output_dir / transcript_path).resolve()
198
+ )
199
+ return first
200
+
201
+
202
+ def read_text(path: Optional[str]) -> str:
203
+ if not path:
204
+ return ""
205
+ p = Path(path)
206
+ if not p.exists():
207
+ return ""
208
+ return p.read_text(encoding="utf-8").strip()
209
+
210
+
211
+ def transcript_excerpt(text: str, limit: int = 2000) -> str:
212
+ if not text:
213
+ return ""
214
+ if len(text) <= limit:
215
+ return text
216
+ return text[:limit].rstrip() + "\n..."
217
+
218
+
219
+ def format_inline(value: Any) -> str:
220
+ if value in (None, "", [], {}):
221
+ return "暂无 AI 产物"
222
+ if isinstance(value, str):
223
+ return value.strip() or "暂无 AI 产物"
224
+ if isinstance(value, list):
225
+ items = [format_list_item(item) for item in value]
226
+ items = [item for item in items if item]
227
+ return "\n".join(items) if items else "暂无 AI 产物"
228
+ if isinstance(value, dict):
229
+ return "```json\n" + json.dumps(value, ensure_ascii=False, indent=2) + "\n```"
230
+ return str(value)
231
+
232
+
233
+ def format_list_item(item: Any) -> str:
234
+ if isinstance(item, str):
235
+ return f"- {item}"
236
+ if isinstance(item, dict):
237
+ preferred = [
238
+ item.get("title"),
239
+ item.get("summary"),
240
+ item.get("content"),
241
+ item.get("text"),
242
+ item.get("todo"),
243
+ item.get("speaker"),
244
+ ]
245
+ text = next((str(v).strip() for v in preferred if v), "")
246
+ if text:
247
+ extras: list[str] = []
248
+ for key in ("assignee", "owner", "deadline", "timestamp", "time"):
249
+ if item.get(key):
250
+ extras.append(f"{key}: {item[key]}")
251
+ if extras:
252
+ return f"- {text} ({', '.join(extras)})"
253
+ return f"- {text}"
254
+ return "- " + json.dumps(item, ensure_ascii=False, sort_keys=True)
255
+ return f"- {item}"
256
+
257
+
258
+ def build_markdown(
259
+ title: str,
260
+ minute_token: str,
261
+ minutes_url: str,
262
+ note: Dict[str, Any],
263
+ transcript_text: str,
264
+ doc_url: str = "",
265
+ ) -> str:
266
+ artifacts = note.get("artifacts") or {}
267
+ if not isinstance(artifacts, dict):
268
+ artifacts = {}
269
+ lines = [
270
+ f"# {title}",
271
+ "",
272
+ "## 会议信息",
273
+ "",
274
+ f"- 标题:{title}",
275
+ f"- minute_token:`{minute_token}`",
276
+ f"- 妙记链接:{minutes_url or '暂无'}",
277
+ f"- 纪要文档 Token:`{note.get('note_doc_token') or '暂无'}`",
278
+ f"- 逐字稿文档 Token:`{note.get('verbatim_doc_token') or '暂无'}`",
279
+ f"- 逐字稿文件:{artifacts.get('transcript_file') or '暂无'}",
280
+ "",
281
+ "## 摘要",
282
+ "",
283
+ format_inline(artifacts.get("summary")),
284
+ "",
285
+ "## 待办",
286
+ "",
287
+ format_inline(artifacts.get("todos")),
288
+ "",
289
+ "## 章节",
290
+ "",
291
+ format_inline(artifacts.get("chapters")),
292
+ "",
293
+ "## 逐字稿",
294
+ "",
295
+ transcript_text or "暂无 AI 产物",
296
+ "",
297
+ "## 原始飞书产物",
298
+ "",
299
+ f"- 妙记链接:{minutes_url or '暂无'}",
300
+ f"- 飞书文档链接:{doc_url or '待创建'}",
301
+ f"- 纪要文档 Token:`{note.get('note_doc_token') or '暂无'}`",
302
+ f"- 逐字稿文档 Token:`{note.get('verbatim_doc_token') or '暂无'}`",
303
+ ]
304
+ return "\n".join(lines).strip() + "\n"
305
+
306
+
307
+ def create_doc(
308
+ cli_prefix: list[str],
309
+ env: Dict[str, str],
310
+ title: str,
311
+ markdown: str,
312
+ folder_token: Optional[str],
313
+ ) -> Dict[str, Any]:
314
+ args = [
315
+ "docs",
316
+ "+create",
317
+ "--as",
318
+ "bot",
319
+ "--title",
320
+ title,
321
+ "--markdown",
322
+ markdown,
323
+ ]
324
+ if folder_token:
325
+ args.extend(["--folder-token", folder_token])
326
+ payload = run_cli(cli_prefix, env, args)
327
+ data = unwrap_payload(payload)
328
+ if not isinstance(data, dict):
329
+ raise CLIError("unexpected docs +create payload", code="invalid_doc_payload")
330
+ return data
331
+
332
+
333
+ def partial_result(
334
+ job_id: str,
335
+ title: str,
336
+ minute_token: str,
337
+ minutes_url: str,
338
+ markdown: str,
339
+ transcript_file: str,
340
+ transcript_text: str,
341
+ error: CLIError,
342
+ ) -> Dict[str, Any]:
343
+ return {
344
+ "job_id": job_id,
345
+ "status": "partial",
346
+ "title": title,
347
+ "minute_token": minute_token,
348
+ "minutes_url": minutes_url,
349
+ "markdown": markdown,
350
+ "feishu_doc_url": "",
351
+ "feishu_doc_token": "",
352
+ "transcript_file": transcript_file,
353
+ "transcript_excerpt": transcript_excerpt(transcript_text),
354
+ "error_code": error.code,
355
+ "error_message": str(error),
356
+ }
357
+
358
+
359
+ def failed_result(job_id: str, minute_token: str, error: CLIError) -> Dict[str, Any]:
360
+ return {
361
+ "job_id": job_id,
362
+ "status": "failed",
363
+ "title": "",
364
+ "minute_token": minute_token,
365
+ "minutes_url": "",
366
+ "markdown": "",
367
+ "feishu_doc_url": "",
368
+ "feishu_doc_token": "",
369
+ "transcript_file": "",
370
+ "transcript_excerpt": "",
371
+ "error_code": error.code,
372
+ "error_message": str(error),
373
+ }
374
+
375
+
376
+ def main() -> int:
377
+ args = parse_args()
378
+ env = build_env(args.config_dir)
379
+ from _resolve_lark_cli import as_prefix
380
+
381
+ cli_prefix = as_prefix(args.lark_cli_bin, args.lark_cli_prefix)
382
+ base_output_dir = Path(args.output_dir).resolve()
383
+ job_dir = base_output_dir / args.job_id
384
+ job_dir.mkdir(parents=True, exist_ok=True)
385
+
386
+ try:
387
+ ensure_auth(cli_prefix, env)
388
+ minute = minute_info(cli_prefix, env, args.minute_token)
389
+ note = notes_info(cli_prefix, env, args.minute_token, job_dir)
390
+ except CLIError as exc:
391
+ json.dump(
392
+ failed_result(args.job_id, args.minute_token, exc),
393
+ sys.stdout,
394
+ ensure_ascii=False,
395
+ indent=2,
396
+ )
397
+ sys.stdout.write("\n")
398
+ return 0
399
+
400
+ title = args.title or str(minute.get("title") or "会议纪要")
401
+ minutes_url = str(minute.get("url") or "")
402
+ artifacts = note.get("artifacts") or {}
403
+ transcript_file = ""
404
+ if isinstance(artifacts, dict):
405
+ transcript_file = str(artifacts.get("transcript_file") or "")
406
+ transcript_text = read_text(transcript_file)
407
+
408
+ markdown = build_markdown(
409
+ title=title,
410
+ minute_token=args.minute_token,
411
+ minutes_url=minutes_url,
412
+ note=note,
413
+ transcript_text=transcript_text,
414
+ )
415
+
416
+ try:
417
+ doc = create_doc(
418
+ cli_prefix,
419
+ env,
420
+ title,
421
+ markdown,
422
+ args.folder_token,
423
+ )
424
+ except CLIError as exc:
425
+ json.dump(
426
+ partial_result(
427
+ job_id=args.job_id,
428
+ title=title,
429
+ minute_token=args.minute_token,
430
+ minutes_url=minutes_url,
431
+ markdown=markdown,
432
+ transcript_file=transcript_file,
433
+ transcript_text=transcript_text,
434
+ error=exc,
435
+ ),
436
+ sys.stdout,
437
+ ensure_ascii=False,
438
+ indent=2,
439
+ )
440
+ sys.stdout.write("\n")
441
+ return 0
442
+
443
+ doc_url = str(doc.get("doc_url") or "")
444
+ doc_token = str(doc.get("doc_id") or "")
445
+ final_markdown = build_markdown(
446
+ title=title,
447
+ minute_token=args.minute_token,
448
+ minutes_url=minutes_url,
449
+ note=note,
450
+ transcript_text=transcript_text,
451
+ doc_url=doc_url,
452
+ )
453
+ result = {
454
+ "job_id": args.job_id,
455
+ "status": "succeeded",
456
+ "title": title,
457
+ "minute_token": args.minute_token,
458
+ "minutes_url": minutes_url,
459
+ "markdown": final_markdown,
460
+ "feishu_doc_url": doc_url,
461
+ "feishu_doc_token": doc_token,
462
+ "transcript_file": transcript_file,
463
+ "transcript_excerpt": transcript_excerpt(transcript_text),
464
+ "error_code": "",
465
+ "error_message": "",
466
+ }
467
+ json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
468
+ sys.stdout.write("\n")
469
+ return 0
470
+
471
+
472
+ if __name__ == "__main__":
473
+ raise SystemExit(main())