codeer-cli 0.1.13__py3-none-any.whl → 0.1.15__py3-none-any.whl

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.
codeer_cli/cli.py CHANGED
@@ -3,7 +3,7 @@
3
3
  codeer check
4
4
  codeer agent list|get|apply|diff|versions
5
5
  codeer model list
6
- codeer kb list|files|upload|node-rename|node-delete|faq-list|faq-get|faq-create|faq-update|faq-delete
6
+ codeer kb list|files|export|upload|node-rename|node-delete|faq-list|faq-get|faq-create|faq-update|faq-delete
7
7
  codeer eval list|label-list|label-create|label-update|label-delete|case-update|case-delete|evaluators|evaluator-create|evaluator-update|run|export|reconcile|cases-apply|rubrics|rubrics-apply
8
8
  codeer history list|get|conversations|negative-feedback|create|send
9
9
  """
@@ -31,6 +31,8 @@ Safe workflow for coding agents:
31
31
  codeer agent get <agent-id> --full
32
32
  codeer kb list
33
33
  codeer kb files --kb-id <kb-id>
34
+ codeer kb export --node-id <file-id> --file guide.md
35
+ codeer kb export --node-id <folder-id> --dir kb-export
34
36
  codeer eval list --agent <agent-id>
35
37
  codeer eval label-list
36
38
  codeer eval case-update --case <case-id> --input "..." --dry-run
@@ -128,6 +128,12 @@ def register(subparsers):
128
128
  g.add_argument("--system-prompt-template", help="Evaluator system prompt template text")
129
129
  g.add_argument("--system-prompt-template-file", help="Path to evaluator system prompt template")
130
130
  p.add_argument("--description", default=None)
131
+ p.add_argument(
132
+ "--judge-model",
133
+ default=None,
134
+ metavar="MODEL_ID",
135
+ help="Judge LLM model ID (default: system default)",
136
+ )
131
137
  p.add_argument("--dry-run", action="store_true",
132
138
  help="Validate inputs and print intended mutation without writing server state.")
133
139
  p.set_defaults(func=run_evaluator_create)
@@ -140,6 +146,18 @@ def register(subparsers):
140
146
  g.add_argument("--system-prompt-template", help="Evaluator system prompt template text")
141
147
  g.add_argument("--system-prompt-template-file", help="Path to evaluator system prompt template")
142
148
  p.add_argument("--description", default=None)
149
+ g = p.add_mutually_exclusive_group()
150
+ g.add_argument(
151
+ "--judge-model",
152
+ default=None,
153
+ metavar="MODEL_ID",
154
+ help="Set the judge LLM model ID",
155
+ )
156
+ g.add_argument(
157
+ "--clear-judge-model",
158
+ action="store_true",
159
+ help="Clear the evaluator override and use the system default judge model",
160
+ )
143
161
  p.add_argument("--dry-run", action="store_true",
144
162
  help="Validate inputs and print intended mutation without writing server state.")
145
163
  p.set_defaults(func=run_evaluator_update)
@@ -249,6 +267,7 @@ def _evaluator_summary(evaluator: dict, *, full: bool = False) -> dict:
249
267
  "id": evaluator.get("id"),
250
268
  "name": evaluator.get("name"),
251
269
  "description": evaluator.get("description"),
270
+ "judge_llm_model_id": evaluator.get("judge_llm_model_id"),
252
271
  "system_prompt_template_chars": len(template),
253
272
  "has_tool_steps_placeholder": "{tool_steps}" in template,
254
273
  "has_output_placeholder": "{output}" in template,
@@ -534,6 +553,10 @@ def run_evaluator_create(args, client) -> int:
534
553
  "workspace_id": workspace_id,
535
554
  "name": args.name,
536
555
  "description": args.description,
556
+ "judge_model": {
557
+ "action": "set" if args.judge_model is not None else "use_system_default",
558
+ "model_id": args.judge_model,
559
+ },
537
560
  "system_prompt_template_chars": len(system_prompt_template or ""),
538
561
  "would_write_server_state": True,
539
562
  "next_step": "Review this summary, then rerun without --dry-run after approval.",
@@ -545,6 +568,7 @@ def run_evaluator_create(args, client) -> int:
545
568
  name=args.name,
546
569
  system_prompt_template=system_prompt_template,
547
570
  description=args.description,
571
+ judge_llm_model_id=args.judge_model,
548
572
  )
549
573
  print_json(_evaluator_summary(evaluator, full=True))
550
574
  return 0
@@ -560,13 +584,27 @@ def run_evaluator_update(args, client) -> int:
560
584
  else:
561
585
  system_prompt_template = args.system_prompt_template
562
586
 
563
- if args.name is None and args.description is None and system_prompt_template is None:
587
+ if (
588
+ args.name is None
589
+ and args.description is None
590
+ and system_prompt_template is None
591
+ and args.judge_model is None
592
+ and not args.clear_judge_model
593
+ ):
564
594
  log(
565
595
  "error: provide at least one of --name, --description, "
566
- "--system-prompt-template, --system-prompt-template-file"
596
+ "--system-prompt-template, --system-prompt-template-file, "
597
+ "--judge-model, --clear-judge-model"
567
598
  )
568
599
  return 2
569
600
 
601
+ if args.clear_judge_model:
602
+ judge_model = {"action": "clear_to_system_default", "model_id": None}
603
+ elif args.judge_model is not None:
604
+ judge_model = {"action": "set", "model_id": args.judge_model}
605
+ else:
606
+ judge_model = {"action": "unchanged"}
607
+
570
608
  if args.dry_run:
571
609
  print_json({
572
610
  "dry_run": True,
@@ -574,6 +612,7 @@ def run_evaluator_update(args, client) -> int:
574
612
  "evaluator_id": args.evaluator,
575
613
  "name": args.name,
576
614
  "description": args.description,
615
+ "judge_model": judge_model,
577
616
  "system_prompt_template_chars": (
578
617
  len(system_prompt_template) if system_prompt_template is not None else None
579
618
  ),
@@ -582,12 +621,20 @@ def run_evaluator_update(args, client) -> int:
582
621
  })
583
622
  return 0
584
623
 
624
+ evaluator_kwargs: dict[str, Any] = {
625
+ "name": args.name,
626
+ "system_prompt_template": system_prompt_template,
627
+ "description": args.description,
628
+ }
629
+ if args.clear_judge_model:
630
+ evaluator_kwargs["judge_llm_model_id"] = None
631
+ elif args.judge_model is not None:
632
+ evaluator_kwargs["judge_llm_model_id"] = args.judge_model
633
+
585
634
  evaluator = eval_mod.update_evaluator(
586
635
  client,
587
636
  evaluator_id=args.evaluator,
588
- name=args.name,
589
- system_prompt_template=system_prompt_template,
590
- description=args.description,
637
+ **evaluator_kwargs,
591
638
  )
592
639
  print_json(_evaluator_summary(evaluator, full=True))
593
640
  return 0
@@ -48,13 +48,17 @@ def register(subparsers):
48
48
  # codeer history conversations <id>
49
49
  p = sub.add_parser(
50
50
  "conversations",
51
- help="Summarize all Chat V2 parts in a history. Complete parts/tool payloads require --out.",
51
+ help="Summarize persisted History parts. Complete parts/tool payloads require --out.",
52
52
  )
53
53
  p.add_argument("history_id", type=int)
54
54
  p.add_argument("--full", action="store_true",
55
55
  help="Require --out and include longer stdout previews; the artifact is always complete.")
56
56
  p.add_argument("--out", default=None,
57
- help="Write every unmodified client-visible Chat V2 part to this file.")
57
+ help="Write every persisted part allowed by the selected export contract.")
58
+ p.add_argument("--client-visible", action="store_true",
59
+ help="Use the external client-owner Chat V2 contract instead of the management export.")
60
+ p.add_argument("--user", default=None,
61
+ help="external_user_id required with --client-visible.")
58
62
  p.set_defaults(func=run_conversations)
59
63
 
60
64
  # codeer history negative-feedback
@@ -158,10 +162,12 @@ def _history_summary(row: dict, *, full: bool = False) -> dict:
158
162
 
159
163
  def _part_summary(part: dict, idx: int, *, full: bool = False) -> dict:
160
164
  raw_content = part.get("content")
161
- if isinstance(raw_content, dict):
165
+ part_kind = part.get("part_kind")
166
+ is_tool_part = part_kind in {"tool-call", "tool-return"}
167
+ if is_tool_part:
168
+ content_value = None
169
+ elif isinstance(raw_content, dict):
162
170
  content_value = raw_content.get("content")
163
- if content_value is None and part.get("part_kind") == "tool-call":
164
- content_value = raw_content.get("args")
165
171
  else:
166
172
  content_value = raw_content
167
173
  if isinstance(content_value, str):
@@ -176,7 +182,7 @@ def _part_summary(part: dict, idx: int, *, full: bool = False) -> dict:
176
182
  "conversation_id": part.get("conversation_id"),
177
183
  "conversation_group_id": part.get("conversation_group_id"),
178
184
  "sequence": part.get("sequence"),
179
- "part_kind": part.get("part_kind"),
185
+ "part_kind": part_kind,
180
186
  "source": part.get("source"),
181
187
  "created_at": part.get("created_at"),
182
188
  "content_preview": truncate(content, 600 if full else 240),
@@ -184,6 +190,12 @@ def _part_summary(part: dict, idx: int, *, full: bool = False) -> dict:
184
190
  "attachment_count": len(part.get("attached_files") or []),
185
191
  "feedback_count": len(part.get("feedbacks") or []),
186
192
  }
193
+ if is_tool_part and isinstance(raw_content, dict):
194
+ row.update({
195
+ "tool_name": raw_content.get("tool_name"),
196
+ "tool_call_id": raw_content.get("tool_call_id"),
197
+ "outcome": raw_content.get("outcome"),
198
+ })
187
199
  if full:
188
200
  row["feedbacks"] = [
189
201
  {
@@ -247,24 +259,49 @@ def run_get(args, client) -> int:
247
259
 
248
260
 
249
261
  def run_conversations(args, client) -> int:
250
- result = chats_mod.list_messages(client, args.history_id)
251
- parts = result.get("messages") or []
252
262
  if args.full and not args.out:
253
263
  log("error: full conversation payloads are unbounded; pass --out <path>")
254
264
  return 2
265
+ client_visible = bool(getattr(args, "client_visible", False))
266
+ external_user_id = getattr(args, "user", None)
267
+ if client_visible and not external_user_id:
268
+ log("error: --client-visible requires --user <external_user_id>")
269
+ return 2
270
+ if external_user_id and not client_visible:
271
+ log("error: --user is only valid with --client-visible")
272
+ return 2
273
+
274
+ if client_visible:
275
+ result = chats_mod.list_messages(
276
+ client,
277
+ args.history_id,
278
+ external_user_id=external_user_id,
279
+ )
280
+ export_mode = "client-visible"
281
+ else:
282
+ result = hist_mod.list_messages(client, args.history_id)
283
+ export_mode = "management"
284
+ parts = result.get("messages") or []
255
285
  write_json(args.out, result)
256
286
  group_ids = {
257
287
  p.get("conversation_group_id")
258
288
  for p in parts
259
289
  if p.get("conversation_group_id")
260
290
  }
291
+ stdout_limit = 50 if args.full else 20
292
+ shown_parts = parts[:stdout_limit]
261
293
  print_json({
262
294
  "history_id": args.history_id,
295
+ "export_mode": export_mode,
296
+ "export_contract": result.get("export_contract"),
297
+ "part_revision": result.get("part_revision"),
263
298
  "turn_count": len(group_ids),
264
299
  "part_count": len(parts),
300
+ "part_summaries_shown": len(shown_parts),
301
+ "part_summaries_truncated": len(parts) > len(shown_parts),
265
302
  "wrote_full_detail": bool(args.out),
266
303
  "stdout_is_summary": True,
267
- "parts": [_part_summary(p, i, full=args.full) for i, p in enumerate(parts)],
304
+ "parts": [_part_summary(p, i, full=args.full) for i, p in enumerate(shown_parts)],
268
305
  })
269
306
  return 0
270
307
 
codeer_cli/commands/kb.py CHANGED
@@ -6,6 +6,7 @@ import time
6
6
  from pathlib import Path
7
7
 
8
8
  from .. import kb as kb_mod
9
+ from ..client import AuthError, CodeerError
9
10
  from ._util import log, print_json, strip_noisy_fields, truncate, write_json
10
11
 
11
12
  POLL_INTERVAL = 3
@@ -76,6 +77,28 @@ def register(subparsers):
76
77
  help="Write stripped full file metadata to this file; stdout stays compact unless --full.")
77
78
  p.set_defaults(func=run_files)
78
79
 
80
+ # codeer kb export
81
+ p = sub.add_parser(
82
+ "export",
83
+ help="Export available KB file snapshot contents as local UTF-8 Markdown files.",
84
+ )
85
+ p.add_argument("--node-id", required=True, help="File, folder, or KB root node UUID to export")
86
+ target = p.add_mutually_exclusive_group(required=True)
87
+ target.add_argument("--file", help="Local output path for single-file export")
88
+ target.add_argument("--dir", help="Local output directory for recursive folder export")
89
+ p.add_argument(
90
+ "--overwrite",
91
+ action="store_true",
92
+ help="Overwrite existing export file(s). Without this flag, the export is blocked before writing.",
93
+ )
94
+ p.add_argument("--full", action="store_true", help="Print the full per-file export manifest.")
95
+ p.add_argument(
96
+ "--out",
97
+ default=None,
98
+ help="Write the full export manifest JSON to this file; stdout stays compact unless --full.",
99
+ )
100
+ p.set_defaults(func=run_export)
101
+
79
102
  p = sub.add_parser("upload", help="Create/reuse KB and upload files from a directory; run --dry-run first")
80
103
  p.add_argument("--dir", required=True, help="Directory containing files to upload")
81
104
  p.add_argument("--name", required=True, help="KB display name (idempotent on name)")
@@ -334,6 +357,383 @@ def run_files(args, client) -> int:
334
357
  return 0
335
358
 
336
359
 
360
+ _WINDOWS_RESERVED_NAMES = {
361
+ "CON",
362
+ "PRN",
363
+ "AUX",
364
+ "NUL",
365
+ *(f"COM{i}" for i in range(1, 10)),
366
+ *(f"LPT{i}" for i in range(1, 10)),
367
+ }
368
+
369
+
370
+ def _safe_export_component(value: str | None, *, fallback: str) -> str:
371
+ """Return one portable path component without allowing path traversal."""
372
+ raw = (value or "").strip()
373
+ cleaned = "".join(
374
+ "_" if ord(char) < 32 or char in '<>:"/\\|?*' else char
375
+ for char in raw
376
+ ).rstrip(" .")
377
+ if not cleaned or cleaned in {".", ".."}:
378
+ cleaned = fallback
379
+ if cleaned.split(".", 1)[0].upper() in _WINDOWS_RESERVED_NAMES:
380
+ cleaned = f"_{cleaned}"
381
+ return cleaned
382
+
383
+
384
+ def _snapshot_filename(name: str | None, *, node_id: str) -> str:
385
+ safe_name = _safe_export_component(name, fallback=f"file-{node_id[:8]}")
386
+ if Path(safe_name).suffix.lower() not in {".md", ".markdown"}:
387
+ safe_name = f"{safe_name}.md"
388
+ return safe_name
389
+
390
+
391
+ def _unique_export_component(component: str, *, node_id: str, used: set[str]) -> str:
392
+ candidate = component
393
+ index = 1
394
+ while candidate.casefold() in used:
395
+ suffix = f"__{node_id[:8]}" if index == 1 else f"__{node_id[:8]}-{index}"
396
+ path = Path(component)
397
+ candidate = f"{path.stem}{suffix}{path.suffix}" if path.suffix else f"{component}{suffix}"
398
+ index += 1
399
+ used.add(candidate.casefold())
400
+ return candidate
401
+
402
+
403
+ def _collect_export_files(
404
+ client,
405
+ *,
406
+ organization_id: str,
407
+ workspace_id: str,
408
+ root_node_id: str,
409
+ ) -> tuple[list[dict], list[dict]]:
410
+ """Walk the KB tree and assign deterministic, collision-safe local paths."""
411
+ pending: list[tuple[str, tuple[str, ...], tuple[str, ...]]] = [(root_node_id, (), ())]
412
+ seen: set[str] = set()
413
+ files: list[dict] = []
414
+ issues: list[dict] = []
415
+
416
+ while pending:
417
+ parent_id, output_parts, source_parts = pending.pop()
418
+ if parent_id in seen:
419
+ issues.append({"node_id": parent_id, "reason": "cycle_or_duplicate_folder"})
420
+ continue
421
+ seen.add(parent_id)
422
+
423
+ nodes = kb_mod.list_nodes(
424
+ client,
425
+ organization_id=organization_id,
426
+ workspace_id=workspace_id,
427
+ parent_id=parent_id,
428
+ )
429
+ nodes = sorted(
430
+ nodes,
431
+ key=lambda node: (
432
+ str(node.get("node_type") or node.get("type") or "").upper() != "FOLDER",
433
+ str(node.get("name") or node.get("original_name") or "").casefold(),
434
+ str(node.get("id") or node.get("node_id") or ""),
435
+ ),
436
+ )
437
+ used_components: set[str] = set()
438
+ child_folders: list[tuple[str, tuple[str, ...], tuple[str, ...]]] = []
439
+
440
+ for node in nodes:
441
+ node_id = str(node.get("id") or node.get("node_id") or "")
442
+ node_type = str(node.get("node_type") or node.get("type") or "").upper()
443
+ node_name = str(node.get("name") or node.get("original_name") or "")
444
+ if not node_id:
445
+ issues.append({"name": node_name, "reason": "missing_node_id"})
446
+ continue
447
+
448
+ if node_type == "FOLDER":
449
+ component = _safe_export_component(node_name, fallback=f"folder-{node_id[:8]}")
450
+ component = _unique_export_component(component, node_id=node_id, used=used_components)
451
+ child_folders.append(
452
+ (
453
+ node_id,
454
+ (*output_parts, component),
455
+ (*source_parts, node_name or node_id),
456
+ )
457
+ )
458
+ continue
459
+
460
+ if node_type != "FILE":
461
+ issues.append({"node_id": node_id, "name": node_name, "reason": f"unknown_node_type:{node_type}"})
462
+ continue
463
+
464
+ filename = _snapshot_filename(node_name, node_id=node_id)
465
+ filename = _unique_export_component(filename, node_id=node_id, used=used_components)
466
+ files.append(
467
+ {
468
+ "node_id": node_id,
469
+ "name": node_name,
470
+ "source_path": "/".join((*source_parts, node_name or node_id)),
471
+ "server_status": str(node.get("status") or "").upper() or None,
472
+ "relative_output_path": str(Path(*output_parts, filename)),
473
+ }
474
+ )
475
+
476
+ pending.extend(reversed(child_folders))
477
+
478
+ files.sort(key=lambda item: item["relative_output_path"].casefold())
479
+ return files, issues
480
+
481
+
482
+ def _compact_export_result(result: dict) -> dict:
483
+ problems = [
484
+ {
485
+ "node_id": item.get("node_id"),
486
+ "source_path": item.get("source_path"),
487
+ "result": item.get("result"),
488
+ "reason": item.get("reason"),
489
+ }
490
+ for item in result.get("files", [])
491
+ if item.get("result") != "exported"
492
+ ]
493
+ return {
494
+ key: result.get(key)
495
+ for key in (
496
+ "operation",
497
+ "mode",
498
+ "node_id",
499
+ "directory",
500
+ "output_file",
501
+ "server_status",
502
+ "exported_while_not_ready",
503
+ "complete",
504
+ "file_count",
505
+ "exported_count",
506
+ "non_ready_exported_count",
507
+ "skipped_count",
508
+ "failed_count",
509
+ "issue_count",
510
+ "reason",
511
+ )
512
+ } | {
513
+ "problems": problems[:20],
514
+ "additional_problem_count": max(0, len(problems) - 20),
515
+ "blocked_paths": result.get("blocked_paths", [])[:20],
516
+ "additional_blocked_path_count": max(0, len(result.get("blocked_paths", [])) - 20),
517
+ }
518
+
519
+
520
+ def _print_export_result(args, result: dict) -> None:
521
+ write_json(args.out, result)
522
+ print_json(result if args.full else _compact_export_result(result))
523
+
524
+
525
+ def _read_export_content(
526
+ client,
527
+ *,
528
+ organization_id: str,
529
+ workspace_id: str,
530
+ item: dict,
531
+ ) -> str | None:
532
+ try:
533
+ response = kb_mod.read_file_content(
534
+ client,
535
+ organization_id=organization_id,
536
+ workspace_id=workspace_id,
537
+ node_id=item["node_id"],
538
+ )
539
+ except AuthError:
540
+ raise
541
+ except CodeerError as exc:
542
+ if exc.status not in {400, 404}:
543
+ raise
544
+ item.update(result="failed", reason=f"api_error:{exc.status}:{exc.message}")
545
+ return None
546
+
547
+ response_status = str(response.get("status") or "").upper()
548
+ response_name = str(response.get("name") or "")
549
+ content = response.get("content")
550
+ item["server_status"] = response_status or item.get("server_status")
551
+ if response_name:
552
+ item["name"] = response_name
553
+ item.setdefault("source_path", response_name or item["node_id"])
554
+ if content is None:
555
+ item.update(
556
+ result="skipped",
557
+ reason=f"content_unavailable:{response_status or 'UNKNOWN'}",
558
+ )
559
+ return None
560
+ if not isinstance(content, str):
561
+ item.update(result="failed", reason="content_is_not_text")
562
+ return None
563
+ return content
564
+
565
+
566
+ def _write_export_content(target: Path, content: str, item: dict) -> None:
567
+ try:
568
+ target.parent.mkdir(parents=True, exist_ok=True)
569
+ target.write_text(content, encoding="utf-8")
570
+ except OSError as exc:
571
+ item.update(result="failed", reason=f"local_write_error:{exc}")
572
+ return
573
+ item.update(
574
+ result="exported",
575
+ bytes_written=len(content.encode("utf-8")),
576
+ exported_while_not_ready=item.get("server_status") != "READY",
577
+ )
578
+
579
+
580
+ def _export_counts(files: list[dict]) -> dict:
581
+ exported_count = sum(item.get("result") == "exported" for item in files)
582
+ return {
583
+ "file_count": len(files),
584
+ "exported_count": exported_count,
585
+ "non_ready_exported_count": sum(
586
+ item.get("result") == "exported" and item.get("server_status") != "READY"
587
+ for item in files
588
+ ),
589
+ "skipped_count": sum(item.get("result") == "skipped" for item in files),
590
+ "failed_count": sum(item.get("result") == "failed" for item in files),
591
+ }
592
+
593
+
594
+ def _run_file_export(args, client, *, workspace_id: str, organization_id: str) -> int:
595
+ output_file = Path(args.file).expanduser().resolve()
596
+ if args.out and Path(args.out).expanduser().resolve() == output_file:
597
+ log("error: --out manifest path must differ from --file content path")
598
+ return 2
599
+ if output_file.exists() and (output_file.is_dir() or not args.overwrite):
600
+ log("error: single-file export blocked before writing; use --overwrite only if replacement is intended")
601
+ return 2
602
+
603
+ existing_parent = output_file.parent
604
+ while not existing_parent.exists() and existing_parent != existing_parent.parent:
605
+ existing_parent = existing_parent.parent
606
+ if existing_parent.exists() and not existing_parent.is_dir():
607
+ log(f"error: output parent {existing_parent} is not a directory")
608
+ return 2
609
+
610
+ item = {
611
+ "node_id": args.node_id,
612
+ "output_path": str(output_file),
613
+ }
614
+ content = _read_export_content(
615
+ client,
616
+ organization_id=organization_id,
617
+ workspace_id=workspace_id,
618
+ item=item,
619
+ )
620
+ if content is not None:
621
+ _write_export_content(output_file, content, item)
622
+
623
+ counts = _export_counts([item])
624
+ complete = counts["skipped_count"] == 0 and counts["failed_count"] == 0
625
+ result = {
626
+ "operation": "kb_export",
627
+ "mode": "file",
628
+ "node_id": args.node_id,
629
+ "directory": str(output_file.parent),
630
+ "output_file": str(output_file),
631
+ "server_status": item.get("server_status"),
632
+ "exported_while_not_ready": item.get("exported_while_not_ready", False),
633
+ "complete": complete,
634
+ **counts,
635
+ "issue_count": 0,
636
+ "issues": [],
637
+ "files": [item],
638
+ }
639
+ _print_export_result(args, result)
640
+ return 0 if complete else 1
641
+
642
+
643
+ def _run_folder_export(args, client, *, workspace_id: str, organization_id: str) -> int:
644
+ output_dir = Path(args.dir).expanduser().resolve()
645
+ if output_dir.exists() and not output_dir.is_dir():
646
+ log(f"error: --dir {output_dir} exists and is not a directory")
647
+ return 2
648
+
649
+ files, issues = _collect_export_files(
650
+ client,
651
+ organization_id=organization_id,
652
+ workspace_id=workspace_id,
653
+ root_node_id=args.node_id,
654
+ )
655
+
656
+ blocked_paths: list[str] = []
657
+ for item in files:
658
+ target = output_dir / item["relative_output_path"]
659
+ if target.exists() and (target.is_dir() or not args.overwrite):
660
+ blocked_paths.append(str(target))
661
+ parent = target.parent
662
+ while parent != output_dir:
663
+ if parent.exists() and not parent.is_dir():
664
+ blocked_paths.append(str(parent))
665
+ break
666
+ parent = parent.parent
667
+
668
+ if blocked_paths:
669
+ result = {
670
+ "operation": "kb_export",
671
+ "mode": "folder",
672
+ "node_id": args.node_id,
673
+ "directory": str(output_dir),
674
+ "output_file": None,
675
+ "complete": False,
676
+ "file_count": len(files),
677
+ "exported_count": 0,
678
+ "non_ready_exported_count": 0,
679
+ "skipped_count": 0,
680
+ "failed_count": len(set(blocked_paths)),
681
+ "issue_count": len(issues),
682
+ "issues": issues,
683
+ "files": [],
684
+ "blocked_paths": sorted(set(blocked_paths)),
685
+ "reason": "existing_paths_require_overwrite_or_conflict_with_directories",
686
+ }
687
+ _print_export_result(args, result)
688
+ log("error: export blocked before writing; use --overwrite only if replacing existing files is intended")
689
+ return 2
690
+
691
+ for item in files:
692
+ target = output_dir / item["relative_output_path"]
693
+ content = _read_export_content(
694
+ client,
695
+ organization_id=organization_id,
696
+ workspace_id=workspace_id,
697
+ item=item,
698
+ )
699
+ if content is not None:
700
+ _write_export_content(target, content, item)
701
+
702
+ counts = _export_counts(files)
703
+ complete = not issues and counts["skipped_count"] == 0 and counts["failed_count"] == 0
704
+ result = {
705
+ "operation": "kb_export",
706
+ "mode": "folder",
707
+ "node_id": args.node_id,
708
+ "directory": str(output_dir),
709
+ "output_file": None,
710
+ "complete": complete,
711
+ **counts,
712
+ "issue_count": len(issues),
713
+ "issues": issues,
714
+ "files": files,
715
+ }
716
+ _print_export_result(args, result)
717
+ return 0 if complete else 1
718
+
719
+
720
+ def run_export(args, client) -> int:
721
+ workspace_id, organization_id = client.resolve_scope()
722
+ if args.file:
723
+ return _run_file_export(
724
+ args,
725
+ client,
726
+ workspace_id=workspace_id,
727
+ organization_id=organization_id,
728
+ )
729
+ return _run_folder_export(
730
+ args,
731
+ client,
732
+ workspace_id=workspace_id,
733
+ organization_id=organization_id,
734
+ )
735
+
736
+
337
737
  def run_upload(args, client) -> int:
338
738
  workspace_id, organization_id = client.resolve_scope()
339
739
  kb_dir = Path(args.dir).resolve()
codeer_cli/eval_.py CHANGED
@@ -12,6 +12,13 @@ from typing import Any, List, Optional
12
12
  from .client import CodeerClient
13
13
 
14
14
 
15
+ class _UnsetType:
16
+ pass
17
+
18
+
19
+ _UNSET = _UnsetType()
20
+
21
+
15
22
  # --- cases ----------------------------------------------------------------
16
23
 
17
24
  def create_case(
@@ -122,7 +129,7 @@ def get_case_evaluator_infos(client: CodeerClient, *, case_ids: List[str]) -> li
122
129
  Returns rows shaped like ``{"case_id": str, "evaluators": [...]}``, where
123
130
  each evaluator entry is the assigned ``{"evaluator_id", "rubric"}`` pair.
124
131
  """
125
- return client.post("/eval/case-evaluator-infos:batch", json={"case_ids": case_ids})
132
+ return client.post("/external/eval/case-evaluator-infos:batch", json={"case_ids": case_ids})
126
133
 
127
134
 
128
135
  def replace_case_evaluator_infos(
@@ -136,7 +143,10 @@ def replace_case_evaluator_infos(
136
143
  This is intentionally separate from rubric upsert: replacing removes
137
144
  evaluator assignments that are not included in ``evaluators``.
138
145
  """
139
- return client.put(f"/eval/cases/{case_id}/case-evaluator-infos", json={"evaluators": evaluators})
146
+ return client.put(
147
+ f"/external/eval/cases/{case_id}/case-evaluator-infos",
148
+ json={"evaluators": evaluators},
149
+ )
140
150
 
141
151
 
142
152
  # --- case labels -----------------------------------------------------------
@@ -186,6 +196,7 @@ def create_evaluator(
186
196
  name: str,
187
197
  system_prompt_template: str,
188
198
  description: Optional[str] = None,
199
+ judge_llm_model_id: Optional[str] = None,
189
200
  ) -> dict:
190
201
  body: dict[str, Any] = {
191
202
  "name": name,
@@ -193,6 +204,8 @@ def create_evaluator(
193
204
  }
194
205
  if description is not None:
195
206
  body["description"] = description
207
+ if judge_llm_model_id is not None:
208
+ body["judge_llm_model_id"] = judge_llm_model_id
196
209
  return client.post("/external/eval/evaluators", json=body)
197
210
 
198
211
 
@@ -211,6 +224,7 @@ def update_evaluator(
211
224
  name: Optional[str] = None,
212
225
  system_prompt_template: Optional[str] = None,
213
226
  description: Optional[str] = None,
227
+ judge_llm_model_id: str | None | _UnsetType = _UNSET,
214
228
  ) -> dict:
215
229
  body: dict[str, Any] = {}
216
230
  if name is not None:
@@ -219,6 +233,8 @@ def update_evaluator(
219
233
  body["system_prompt_template"] = system_prompt_template
220
234
  if description is not None:
221
235
  body["description"] = description
236
+ if judge_llm_model_id is not _UNSET:
237
+ body["judge_llm_model_id"] = judge_llm_model_id
222
238
  return client.put(f"/external/eval/evaluators/{evaluator_id}", json=body)
223
239
 
224
240
 
codeer_cli/histories.py CHANGED
@@ -4,14 +4,15 @@ Use this after an agent has been published and running for a while, to pull
4
4
  recent traffic, filter by feedback, and feed the failing cases back into the
5
5
  evaluation loop.
6
6
 
7
- Pagination: ``/histories`` uses ``limit`` + ``offset`` (NOT ``page`` /
8
- ``page_size``). Default ``limit=500`` here is a deliberate choice for analysis
9
- workflows — the backend caps responses anyway and returning everything in one
10
- call removes a common foot-gun where the caller silently truncates at 10.
7
+ Pagination: ``/histories`` and the management parts export use ``limit`` +
8
+ ``offset`` (NOT ``page`` / ``page_size``). History metadata remains a bounded
9
+ caller-selected page. The parts export follows every server page and rejects a
10
+ cross-page revision change instead of returning a mixed snapshot.
11
11
  """
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import builtins
15
16
  from typing import Any, Iterable, Optional
16
17
 
17
18
  from .client import CodeerClient
@@ -94,11 +95,9 @@ def list_negative_feedback_turns(
94
95
  the source channel — usually "system"). Pass the desired sentiment(s)
95
96
  in ``feedback_types``.
96
97
 
97
- Cost: O(N histories) paginated Chat V2 reads. Filter aggressively via
98
+ Cost: O(N histories) paginated management parts reads. Filter aggressively via
98
99
  ``exclude_users`` and ``limit`` before invoking on a busy agent.
99
100
  """
100
- from . import chats
101
-
102
101
  type_set = {t.lower() for t in feedback_types}
103
102
  histories = list(
104
103
  client,
@@ -113,10 +112,7 @@ def list_negative_feedback_turns(
113
112
  hid = h.get("id")
114
113
  if hid is None:
115
114
  continue
116
- try:
117
- parts = (chats.list_messages(client, hid).get("messages") or [])
118
- except Exception:
119
- continue
115
+ parts = (list_messages(client, hid).get("messages") or [])
120
116
 
121
117
  turn_indexes: dict[str, int] = {}
122
118
  user_messages: dict[str, str] = {}
@@ -158,6 +154,92 @@ def get(client: CodeerClient, history_id: int) -> dict:
158
154
  return client.get(f"/external/histories/{history_id}")
159
155
 
160
156
 
157
+ def list_messages(
158
+ client: CodeerClient,
159
+ history_id: int,
160
+ *,
161
+ limit: int = 100,
162
+ ) -> dict:
163
+ """Return every persisted diagnostic part through the management export.
164
+
165
+ This route is for workspace editors analyzing a History. It is distinct
166
+ from the external client-owner Chat V2 route and must return the explicit
167
+ ``history-parts-v1`` contract. Tool calls and returns are preserved in the
168
+ returned artifact. System prompts and provider raw traces are outside the
169
+ contract.
170
+ """
171
+ if limit <= 0:
172
+ raise ValueError("limit must be greater than zero")
173
+
174
+ offset = 0
175
+ pages_fetched = 0
176
+ total_records: int | None = None
177
+ part_revision: str | None = None
178
+ result: dict[str, Any] | None = None
179
+ messages: list[dict] = []
180
+
181
+ while True:
182
+ page = client.get(
183
+ f"/external/histories/{history_id}/messages",
184
+ params={"limit": limit, "offset": offset},
185
+ )
186
+ if not isinstance(page, dict):
187
+ raise ValueError("History parts response must be an object")
188
+ if page.get("export_contract") != "history-parts-v1":
189
+ raise ValueError("History parts response is missing export_contract=history-parts-v1")
190
+
191
+ page_messages = page.get("messages")
192
+ page_info = page.get("page")
193
+ revision = page.get("part_revision")
194
+ if not isinstance(page_messages, builtins.list):
195
+ raise ValueError("History parts response must contain a messages list")
196
+ if not isinstance(page_info, dict):
197
+ raise ValueError("History parts response must contain page metadata")
198
+ if not isinstance(revision, str) or not revision:
199
+ raise ValueError("History parts response must contain part_revision")
200
+
201
+ page_offset = page_info.get("offset")
202
+ page_total = page_info.get("total_records")
203
+ page_limit = page_info.get("limit")
204
+ if not all(isinstance(value, int) for value in (page_offset, page_total, page_limit)):
205
+ raise ValueError("History parts page metadata must contain integer limit/offset/total_records")
206
+ if page_offset != offset:
207
+ raise ValueError(f"History parts page offset mismatch: requested {offset}, received {page_offset}")
208
+ if page_total < 0 or page_limit <= 0:
209
+ raise ValueError("History parts page metadata is invalid")
210
+
211
+ if total_records is None:
212
+ total_records = page_total
213
+ elif page_total != total_records:
214
+ raise ValueError("History parts total_records changed while paging; retry the export")
215
+ if part_revision is None:
216
+ part_revision = revision
217
+ elif revision != part_revision:
218
+ raise ValueError("History parts changed while paging; retry the export")
219
+
220
+ if result is None:
221
+ result = dict(page)
222
+ messages.extend(page_messages)
223
+ pages_fetched += 1
224
+ offset += len(page_messages)
225
+
226
+ if offset >= total_records:
227
+ break
228
+ if not page_messages:
229
+ raise ValueError("History parts pagination stopped before total_records was reached")
230
+
231
+ assert result is not None
232
+ assert total_records is not None
233
+ result["messages"] = messages
234
+ result["page"] = {
235
+ "limit": result["page"]["limit"],
236
+ "offset": 0,
237
+ "total_records": total_records,
238
+ }
239
+ result["pages_fetched"] = pages_fetched
240
+ return result
241
+
242
+
161
243
  def get_conversations(client: CodeerClient, history_id: int) -> list[dict]:
162
244
  """Return legacy V1 conversation rows.
163
245
 
codeer_cli/kb.py CHANGED
@@ -234,7 +234,6 @@ def read_file_content(
234
234
  *,
235
235
  organization_id: str,
236
236
  workspace_id: str,
237
- kb_id: str,
238
237
  node_id: str,
239
238
  ) -> dict:
240
239
  return client.get(f"{_base(organization_id, workspace_id)}/files/{node_id}/content")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codeer-cli
3
- Version: 0.1.13
3
+ Version: 0.1.15
4
4
  Summary: Command line tools for managing Codeer agents over the Codeer API.
5
5
  Project-URL: Homepage, https://www.codeer.ai
6
6
  Author: Codeer.AI
@@ -98,7 +98,8 @@ CODEER_AGENT_ID=<agent-id>
98
98
 
99
99
  ## Development install
100
100
 
101
- Use an editable install while the CLI is changing quickly:
101
+ Codeer contributors should use an editable install from this checkout, not the
102
+ PyPI package, so the `codeer` command always executes the folder being edited:
102
103
 
103
104
  ```bash
104
105
  cd /path/to/codeer-skills/codeer-cli
@@ -123,6 +124,34 @@ List the active cloud models without opening the Codeer web app:
123
124
  codeer model list --type text
124
125
  ```
125
126
 
127
+ ## Custom evaluator judge models
128
+
129
+ Custom evaluator create/update commands can select a judge LLM model by ID:
130
+
131
+ ```bash
132
+ codeer eval evaluator-create \
133
+ --name "Correctness" \
134
+ --system-prompt-template-file evaluator-prompt.txt \
135
+ --judge-model <model-id> \
136
+ --dry-run
137
+
138
+ codeer eval evaluator-update \
139
+ --evaluator <evaluator-id> \
140
+ --judge-model <model-id> \
141
+ --dry-run
142
+ ```
143
+
144
+ Omit the judge-model flags on update to leave the current setting unchanged.
145
+ Use `--clear-judge-model` to explicitly clear the override and return to the
146
+ system default:
147
+
148
+ ```bash
149
+ codeer eval evaluator-update \
150
+ --evaluator <evaluator-id> \
151
+ --clear-judge-model \
152
+ --dry-run
153
+ ```
154
+
126
155
  ## Agent human handoff
127
156
 
128
157
  `codeer agent apply` accepts the same `human_handoff` object as the Agent API.
@@ -197,10 +226,22 @@ Flags:
197
226
  for raw eval results, full conversation turns, full rubric matrices, and
198
227
  other data that can grow with cases, versions, or turns.
199
228
 
200
- `history conversations` reads Chat V2 parts and follows all pages
201
- automatically. Its stdout is still a bounded summary; the `--out` artifact is
202
- the complete client-visible history, including tool calls/results,
203
- attachments, interactions, feedback, and passthrough metadata.
229
+ `history conversations` defaults to the workspace-editor management export and
230
+ follows all pages automatically. Its stdout is a bounded summary that never
231
+ prints tool arguments or results; the `--out` artifact preserves the complete
232
+ `history-parts-v1` payload, including persisted tool calls/results,
233
+ attachments, feedback, and metadata. System prompts and provider raw traces are
234
+ not part of that export contract, and a missing part does not prove that a tool
235
+ was not executed.
236
+
237
+ Use the external client-owner contract only when that distinction is the point
238
+ of the test:
239
+
240
+ ```bash
241
+ codeer history conversations <history-id> \
242
+ --client-visible --user <external-user-id> \
243
+ --out .codeer/current/client-history-<history-id>.json
244
+ ```
204
245
 
205
246
  Avoid piping large raw JSON directly into agent chat. Prefer `--out`, then ask
206
247
  the coding agent to inspect targeted summaries, IDs, failing cases, or selected
@@ -228,6 +269,45 @@ paths containing `*` so the shell passes the wildcard to the CLI. Advanced
228
269
  settings can still be passed through `--config-json`; explicit crawler flags
229
270
  override matching JSON keys.
230
271
 
272
+ ## Exporting KB snapshot content
273
+
274
+ Export one file directly from the content endpoint:
275
+
276
+ ```bash
277
+ codeer kb export \
278
+ --node-id <file-node-id> \
279
+ --file guide.md
280
+ ```
281
+
282
+ Or recursively export a folder or an entire KB root:
283
+
284
+ ```bash
285
+ codeer kb export \
286
+ --node-id <folder-or-kb-root-node-id> \
287
+ --dir kb-export \
288
+ --out kb-export-manifest.json
289
+ ```
290
+
291
+ `--file` and `--dir` are mutually exclusive. Single-file mode maps directly to
292
+ the external file-content endpoint and lets the caller choose the exact local
293
+ path. Folder mode recursively lists the node tree, calls that endpoint for each
294
+ file, and writes the extracted snapshot content as UTF-8 Markdown. Existing
295
+ `.md`/`.markdown` names are preserved; other folder-export names receive an
296
+ additional `.md` suffix (for example, `guide.pdf` becomes `guide.pdf.md`) so the
297
+ export is not mistaken for the original binary upload.
298
+
299
+ The command asks the content endpoint for every file regardless of indexing
300
+ status. If the endpoint returns text, it is exported even when the status is
301
+ not `READY`; the full manifest preserves that server status and marks the file
302
+ as `exported_while_not_ready`. If the endpoint returns `content: null`, the file
303
+ is skipped and the command exits non-zero. Existing target files block the
304
+ entire export before any content is written; pass `--overwrite` only when
305
+ replacing those local files is intended.
306
+
307
+ This is a snapshot-content export, not an original-file backup. The server API
308
+ returns processed text and does not return the original PDF, DOCX, or other
309
+ binary bytes through this endpoint.
310
+
231
311
  ## KB node rename and delete
232
312
 
233
313
  Knowledge Base roots, folders, and files are all KnowledgeNodes. Use
@@ -2,24 +2,24 @@ codeer_cli/__init__.py,sha256=z3rkUdVCt11c8QE3-YFkG5cFObM6QhPe2yKugA8k3aA,1915
2
2
  codeer_cli/_validate.py,sha256=lqMwkhsBy1g0o5CINBp1HdBCjMawgUaEsZygjJaAPL0,6943
3
3
  codeer_cli/agents.py,sha256=Yt_Rw4c9omsTnXsTe5n_9mD4TpDcx_1WuUkhtKmFY0g,6371
4
4
  codeer_cli/chats.py,sha256=Vg1DIu8fT6mPyPpfqLpExKvXhdZEWCyJGPvalQy3BXg,7989
5
- codeer_cli/cli.py,sha256=ghZMn7LUxDDvb3f82mDYW5hIFLk7dtlrCrGGP40Gygw,4637
5
+ codeer_cli/cli.py,sha256=PDi0WLC4prkFSbY0Y7619MAdP3uoDzgTWekHrhHprHc,4756
6
6
  codeer_cli/client.py,sha256=apUycjK-OvHEquAj58FqOy8FHmHjA6K9dScaYQ4M-VU,13175
7
7
  codeer_cli/constants.py,sha256=D1pV3wCoqYybrKGKeoupYjjFWLfaFviKp1yL7oh6Qso,2323
8
- codeer_cli/eval_.py,sha256=2Tz4qokctocZ0fqLYL8Sk9UV8yJ7p-9zYiUFljsRRCs,17478
9
- codeer_cli/histories.py,sha256=WvpqvuprPc1nJJCTElyM_eoUabyooACfmRCwYlcvPnI,6637
10
- codeer_cli/kb.py,sha256=Ad4h65NByq5Rq5BTeMghLTKlWRhmOC2jxL0BaTGX3EM,10631
8
+ codeer_cli/eval_.py,sha256=TjDFGCGF19JaUmSaP3PiKIRrBIvYaU-EJS4dC-N7eBI,17868
9
+ codeer_cli/histories.py,sha256=Tom4tM20mls-Jo-q7NXish3zeOyMM5Nkd7MZjHFfy88,9979
10
+ codeer_cli/kb.py,sha256=-HodcGwTvIKgvuRVc5sPFu51si-GlzLEsPSHmnfrVQU,10615
11
11
  codeer_cli/models.py,sha256=Ama9fx8hyRMdysiUNA3Ty0sYIYwzFDkOcAZg-id3-vQ,362
12
12
  codeer_cli/parse.py,sha256=-txMtIxPfeg_i4McEskVKxo8p4438gKS-sFTI38RoBs,21772
13
13
  codeer_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  codeer_cli/commands/_util.py,sha256=X9-9cYgnBYz94hVG4Pe9SsI97DHNv3HE5_7GlPMn93I,1708
15
15
  codeer_cli/commands/agent.py,sha256=BjctOF0tflt8yVGzdc6320sWXVUAYrF39tNIiTxtQx4,16319
16
16
  codeer_cli/commands/check.py,sha256=lTxolx1mIJ8jldPhJ5FXqie9nbCLVOO-sDPOHTSy1-w,3817
17
- codeer_cli/commands/eval_cmd.py,sha256=Zlt_YIA3D5-Y8WL1HZ6PxZgL-TcwFTDoXvO2gv7yfOM,71989
18
- codeer_cli/commands/history.py,sha256=2p9VuPwyexr7Q2FFIBSuv9hNzn7VdIMa-AtvtP-8LvI,17253
19
- codeer_cli/commands/kb.py,sha256=kVEinBVM6NN8_0djOqIQErh46dLmArwFngXMNzvGeAI,28345
17
+ codeer_cli/commands/eval_cmd.py,sha256=S-ydzWZppPLMCu2i_HOK0OW5ZaoebxcLO--WDYOIuAk,73548
18
+ codeer_cli/commands/history.py,sha256=jUm0ZnIaJc0FV7pwMNdCBuuKPoauhDY2swFqB6_byWU,18800
19
+ codeer_cli/commands/kb.py,sha256=1lwrKTC0NpnAvXZ0u5DPsfx7ZXcHLURyBrqRViv03Yk,42349
20
20
  codeer_cli/commands/model.py,sha256=91LDo_YeHi3POZV7KTCkoCcgtK5p87LwrHX1lZgfCps,1698
21
21
  codeer_cli/commands/profile.py,sha256=IdlXC_6cqobsfN3JRrAnt-1OgBUsIFneS9QtR4Un6Kc,6521
22
- codeer_cli-0.1.13.dist-info/METADATA,sha256=zslhdIB39lnbLxfGOM9sv8zrZdzDNzi6xjZWCDLrPQU,7885
23
- codeer_cli-0.1.13.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
24
- codeer_cli-0.1.13.dist-info/entry_points.txt,sha256=-nXIrlm5SR5r7gg3y8AS0tN66MwmvNHsrlwLNQNGD50,47
25
- codeer_cli-0.1.13.dist-info/RECORD,,
22
+ codeer_cli-0.1.15.dist-info/METADATA,sha256=Cwz2jFmIb0GSVClvWt3WqfKm-F_SRvO7W6n67d6t7AY,10673
23
+ codeer_cli-0.1.15.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
24
+ codeer_cli-0.1.15.dist-info/entry_points.txt,sha256=-nXIrlm5SR5r7gg3y8AS0tN66MwmvNHsrlwLNQNGD50,47
25
+ codeer_cli-0.1.15.dist-info/RECORD,,