sylo-fieldbrain 0.1.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/extensions/fieldbrain-tools.ts +495 -0
  4. package/extensions/index.ts +12 -0
  5. package/extensions/python-runner.ts +56 -0
  6. package/package.json +44 -0
  7. package/scripts/__pycache__/_db_lib.cpython-312.pyc +0 -0
  8. package/scripts/__pycache__/_json_out.cpython-312.pyc +0 -0
  9. package/scripts/__pycache__/models.cpython-312.pyc +0 -0
  10. package/scripts/_db_lib.py +228 -0
  11. package/scripts/_json_out.py +19 -0
  12. package/scripts/alembic/env.py +53 -0
  13. package/scripts/alembic/versions/001_baseline.py +38 -0
  14. package/scripts/alembic/versions/002_core_tables.py +429 -0
  15. package/scripts/alembic/versions/003_durability_content_in_db.py +106 -0
  16. package/scripts/alembic/versions/004_pgvector_embedding.py +42 -0
  17. package/scripts/alembic/versions/005_project_job_number.py +37 -0
  18. package/scripts/alembic/versions/006_project_parent.py +62 -0
  19. package/scripts/alembic/versions/__init__.py +1 -0
  20. package/scripts/alembic.ini +38 -0
  21. package/scripts/db_auto_migrate.py +103 -0
  22. package/scripts/db_bootstrap.py +207 -0
  23. package/scripts/db_check.py +105 -0
  24. package/scripts/db_migrate.py +85 -0
  25. package/scripts/fieldbrain_brain_delete.py +49 -0
  26. package/scripts/fieldbrain_brain_read.py +61 -0
  27. package/scripts/fieldbrain_brain_restore.py +49 -0
  28. package/scripts/fieldbrain_brain_revisions.py +54 -0
  29. package/scripts/fieldbrain_brain_write.py +67 -0
  30. package/scripts/fieldbrain_document_attach.py +81 -0
  31. package/scripts/fieldbrain_document_catalog.py +128 -0
  32. package/scripts/fieldbrain_document_ingest.py +62 -0
  33. package/scripts/fieldbrain_document_list.py +67 -0
  34. package/scripts/fieldbrain_document_promote.py +43 -0
  35. package/scripts/fieldbrain_log_create.py +55 -0
  36. package/scripts/fieldbrain_log_delete.py +39 -0
  37. package/scripts/fieldbrain_log_restore.py +39 -0
  38. package/scripts/fieldbrain_log_revisions.py +39 -0
  39. package/scripts/fieldbrain_log_search.py +58 -0
  40. package/scripts/fieldbrain_log_update.py +52 -0
  41. package/scripts/fieldbrain_project_create.py +72 -0
  42. package/scripts/fieldbrain_project_list.py +53 -0
  43. package/scripts/fieldbrain_search.py +74 -0
  44. package/scripts/fieldbrain_ui_brain_list.py +60 -0
  45. package/scripts/fieldbrain_ui_project_create.py +95 -0
  46. package/scripts/fieldbrain_ui_project_list.py +49 -0
  47. package/scripts/models.py +355 -0
  48. package/scripts/pgvector_enable.py +165 -0
  49. package/scripts/pgvector_guide.py +44 -0
  50. package/scripts/pgvector_install_files.py +66 -0
  51. package/scripts/pgvector_install_from_folder.py +148 -0
  52. package/scripts/postbuild-ui.mjs +13 -0
  53. package/scripts/requirements.txt +7 -0
  54. package/scripts/services/__init__.py +1 -0
  55. package/scripts/services/__pycache__/__init__.cpython-312.pyc +0 -0
  56. package/scripts/services/__pycache__/brain_paths.cpython-312.pyc +0 -0
  57. package/scripts/services/__pycache__/document_formats.cpython-312.pyc +0 -0
  58. package/scripts/services/__pycache__/document_service.cpython-312.pyc +0 -0
  59. package/scripts/services/__pycache__/pgvector_windows.cpython-312.pyc +0 -0
  60. package/scripts/services/__pycache__/project_naming.cpython-312.pyc +0 -0
  61. package/scripts/services/__pycache__/project_service.cpython-312.pyc +0 -0
  62. package/scripts/services/brain_paths.py +81 -0
  63. package/scripts/services/brain_service.py +280 -0
  64. package/scripts/services/document_catalog.py +186 -0
  65. package/scripts/services/document_formats.py +116 -0
  66. package/scripts/services/document_ingest.py +254 -0
  67. package/scripts/services/document_service.py +316 -0
  68. package/scripts/services/embedding_service.py +72 -0
  69. package/scripts/services/global_brain_support.py +36 -0
  70. package/scripts/services/maintenance_log_service.py +258 -0
  71. package/scripts/services/migrate_lock.py +24 -0
  72. package/scripts/services/pgvector_windows.py +190 -0
  73. package/scripts/services/project_naming.py +72 -0
  74. package/scripts/services/project_service.py +368 -0
  75. package/scripts/services/search_service.py +250 -0
  76. package/scripts/status.py +39 -0
  77. package/shared/README.md +7 -0
  78. package/skills/fieldbrain/SKILL.md +197 -0
  79. package/skills/fieldbrain/SKILL.md.bak +83 -0
  80. package/skills/fieldbrain/routes/fieldbrain/assets/index-B56utPpP.css +1 -0
  81. package/skills/fieldbrain/routes/fieldbrain/assets/index-DO0AD0df.js +55 -0
  82. package/skills/fieldbrain/routes/fieldbrain/fallback.md +7 -0
  83. package/skills/fieldbrain/routes/fieldbrain/index.html +13 -0
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+ """List revision history for a maintenance log entry (logicscout_log_revisions tool)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from _db_lib import get_engine, verify_schema_version
9
+ from _json_out import emit, emit_error
10
+ from services.maintenance_log_service import list_log_revisions
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--entry-id", type=int, required=True)
17
+ parser.add_argument("--limit", type=int, default=20)
18
+ args = parser.parse_args()
19
+
20
+ ok, msg = verify_schema_version()
21
+ if not ok:
22
+ emit_error(msg)
23
+
24
+ with Session(get_engine()) as session:
25
+ revisions = list_log_revisions(session, args.entry_id, limit=args.limit)
26
+
27
+ emit(
28
+ {
29
+ "ok": True,
30
+ "entry_id": args.entry_id,
31
+ "revisions": revisions,
32
+ "count": len(revisions),
33
+ "operator_chat": f"{len(revisions)} revision(s) for log entry {args.entry_id}.",
34
+ }
35
+ )
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python3
2
+ """Search maintenance log entries (logicscout_log_search tool)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from _db_lib import get_engine, verify_schema_version
9
+ from _json_out import emit, emit_error
10
+ from services.maintenance_log_service import search_log_entries
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--query", default="", help="Full-text search (title, body, fault, tag)")
17
+ parser.add_argument("--project-id", type=int, default=None, help="Filter by project id")
18
+ parser.add_argument("--fault-code", default="", help="Filter by fault code (substring)")
19
+ parser.add_argument("--equipment-tag", default="", help="Filter by equipment tag (substring)")
20
+ parser.add_argument("--limit", type=int, default=25, help="Max results (default 25)")
21
+ args = parser.parse_args()
22
+
23
+ ok, msg = verify_schema_version()
24
+ if not ok:
25
+ emit_error(msg)
26
+
27
+ with Session(get_engine()) as session:
28
+ entries = search_log_entries(
29
+ session,
30
+ query=args.query,
31
+ project_id=args.project_id,
32
+ fault_code=args.fault_code or None,
33
+ equipment_tag=args.equipment_tag or None,
34
+ limit=args.limit,
35
+ )
36
+
37
+ lines = []
38
+ for e in entries:
39
+ proj = f" [{e.get('project_name')}]" if e.get("project_name") else ""
40
+ fault = f" fault={e['fault_code']}" if e.get("fault_code") else ""
41
+ tag = f" tag={e['equipment_tag']}" if e.get("equipment_tag") else ""
42
+ lines.append(f"#{e['id']} {e['title']}{proj}{fault}{tag}")
43
+
44
+ emit(
45
+ {
46
+ "ok": True,
47
+ "count": len(entries),
48
+ "entries": entries,
49
+ "operator_chat": (
50
+ f"Found {len(entries)} log entr{'y' if len(entries) == 1 else 'ies'}.\n"
51
+ + ("\n".join(lines) if lines else "(none)")
52
+ ),
53
+ }
54
+ )
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """Update a maintenance log entry with revision history (logicscout_log_update tool)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from _db_lib import get_engine, verify_schema_version
9
+ from _json_out import emit, emit_error
10
+ from services.maintenance_log_service import update_log_entry
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--entry-id", type=int, required=True)
17
+ parser.add_argument("--title", default=None)
18
+ parser.add_argument("--body", default=None)
19
+ parser.add_argument("--fault-code", default=None)
20
+ parser.add_argument("--equipment-tag", default=None)
21
+ parser.add_argument("--logged-by", default=None)
22
+ args = parser.parse_args()
23
+
24
+ ok, msg = verify_schema_version()
25
+ if not ok:
26
+ emit_error(msg)
27
+
28
+ try:
29
+ with Session(get_engine()) as session:
30
+ result = update_log_entry(
31
+ session,
32
+ args.entry_id,
33
+ title=args.title,
34
+ body=args.body,
35
+ fault_code=args.fault_code,
36
+ equipment_tag=args.equipment_tag,
37
+ logged_by=args.logged_by,
38
+ )
39
+ except ValueError as exc:
40
+ emit_error(str(exc))
41
+
42
+ emit(
43
+ {
44
+ "ok": True,
45
+ "entry": result,
46
+ "operator_chat": f"Updated maintenance log entry {args.entry_id} (previous version saved to revisions).",
47
+ }
48
+ )
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+ """Create a LogicScout project (logicscout_project_create tool)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+
9
+ from _db_lib import get_engine, verify_schema_version
10
+ from _json_out import emit, emit_error
11
+ from services.brain_paths import ensure_project_brain_layout
12
+ from services.project_service import create_project
13
+ from sqlalchemy.orm import Session
14
+
15
+
16
+ def main() -> None:
17
+ parser = argparse.ArgumentParser()
18
+ parser.add_argument("--name", required=True, help="12345 (job) or 12345-001 (sub-project)")
19
+ parser.add_argument("--org-id", default="default", help="Org id (default: default)")
20
+ parser.add_argument(
21
+ "--tags-json",
22
+ default="",
23
+ help='JSON array of tag strings, e.g. ["line3","packaging"]',
24
+ )
25
+ args = parser.parse_args()
26
+
27
+ ok, msg = verify_schema_version()
28
+ if not ok:
29
+ emit_error(msg)
30
+
31
+ tags: list[str] | None = None
32
+ if args.tags_json.strip():
33
+ try:
34
+ parsed = json.loads(args.tags_json)
35
+ if isinstance(parsed, list):
36
+ tags = [str(x) for x in parsed]
37
+ else:
38
+ emit_error("--tags-json must be a JSON array")
39
+ except json.JSONDecodeError as exc:
40
+ emit_error(f"Invalid --tags-json: {exc}")
41
+
42
+ try:
43
+ with Session(get_engine()) as session:
44
+ project = create_project(
45
+ session,
46
+ name=args.name,
47
+ project_tags=tags,
48
+ org_id=args.org_id,
49
+ )
50
+ ensure_project_brain_layout(project["id"])
51
+ if project.get("parent_project_id"):
52
+ ensure_project_brain_layout(int(project["parent_project_id"]))
53
+ except ValueError as exc:
54
+ emit_error(str(exc))
55
+
56
+ level = project.get("level", "other")
57
+ if level == "sub_project":
58
+ chat = f"Created sub-project {project['id']}: {project['name']} (under project {project.get('job_number')})."
59
+ else:
60
+ chat = f"Created project {project['id']}: {project['name']}."
61
+
62
+ emit(
63
+ {
64
+ "ok": True,
65
+ "project": project,
66
+ "operator_chat": chat,
67
+ }
68
+ )
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """List LogicScout projects (logicscout_project_list tool)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from _db_lib import get_engine, verify_schema_version
9
+ from _json_out import emit, emit_error
10
+ from services.project_service import list_projects, list_projects_with_stats
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--include-hidden", action="store_true", help="Include system-hidden projects")
17
+ parser.add_argument("--with-stats", action="store_true", help="Include brain/doc/log counts per project")
18
+ args = parser.parse_args()
19
+
20
+ ok, msg = verify_schema_version()
21
+ if not ok:
22
+ emit_error(msg)
23
+
24
+ with Session(get_engine()) as session:
25
+ if args.with_stats:
26
+ projects = list_projects_with_stats(session, include_hidden=args.include_hidden)
27
+ else:
28
+ projects = list_projects(session, include_hidden=args.include_hidden)
29
+
30
+ lines = []
31
+ for p in projects:
32
+ if args.with_stats and p.get("stats"):
33
+ s = p["stats"]
34
+ lines.append(
35
+ f"{p['id']}: {p['name']} — {s.get('brain_entries', 0)} brain, "
36
+ f"{s.get('library_docs', 0)} docs, {s.get('maintenance_logs', 0)} logs"
37
+ )
38
+ else:
39
+ lines.append(f"{p['id']}: {p['name']}")
40
+ emit(
41
+ {
42
+ "ok": True,
43
+ "count": len(projects),
44
+ "projects": projects,
45
+ "operator_chat": (
46
+ f"Found {len(projects)} project(s).\n" + ("\n".join(lines) if lines else "(none)")
47
+ ),
48
+ }
49
+ )
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """Hybrid search across project knowledge (logicscout_search tool)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from _db_lib import get_engine, verify_schema_version
9
+ from _json_out import emit, emit_error
10
+ from services.search_service import hybrid_search
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument(
17
+ "--project-id",
18
+ type=int,
19
+ default=None,
20
+ help="Project to search (project + global). Omit for global-only search.",
21
+ )
22
+ parser.add_argument("--query", required=True, help="Search query text")
23
+ parser.add_argument("--limit", type=int, default=25, help="Max results (default 25)")
24
+ parser.add_argument(
25
+ "--source-type",
26
+ default="",
27
+ help="Optional filter: document, brain, tag, rung, etc.",
28
+ )
29
+ args = parser.parse_args()
30
+
31
+ ok, msg = verify_schema_version()
32
+ if not ok:
33
+ emit_error(msg)
34
+
35
+ with Session(get_engine()) as session:
36
+ results = hybrid_search(
37
+ session,
38
+ args.project_id,
39
+ args.query,
40
+ limit=args.limit,
41
+ source_type=args.source_type or None,
42
+ )
43
+
44
+ lines = []
45
+ for r in results:
46
+ scope_tag = "global" if r.get("scope") == "global" else f"project {r.get('project_id')}"
47
+ lines.append(
48
+ f"[{r['score']}] ({scope_tag}) #{r['id']} {r['type']} {r['label']}: {r['snippet'][:160]}"
49
+ )
50
+
51
+ where = (
52
+ f"project {args.project_id} + global library/brain"
53
+ if args.project_id is not None
54
+ else "global library/brain only"
55
+ )
56
+ emit(
57
+ {
58
+ "ok": True,
59
+ "project_id": args.project_id,
60
+ "search_scope": "project_plus_global" if args.project_id is not None else "global_only",
61
+ "query": args.query,
62
+ "count": len(results),
63
+ "results": results,
64
+ "operator_chat": (
65
+ f"Search ({where}) for {args.query!r}: "
66
+ f"{len(results)} hit(s).\n"
67
+ + ("\n".join(lines) if lines else "(none)")
68
+ ),
69
+ }
70
+ )
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ """List brain markdown files for FieldBrain dashboard."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from _db_lib import get_engine, verify_schema_version
9
+ from _json_out import emit, emit_error
10
+ from services.brain_service import list_brain_documents
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument(
17
+ "--scope",
18
+ choices=("global", "project"),
19
+ default="project",
20
+ help="global org brain or project brain",
21
+ )
22
+ parser.add_argument("--project-id", type=int, default=None, help="Required when scope=project")
23
+ parser.add_argument("--search", default="", help="Filter by path substring")
24
+ parser.add_argument("--limit", type=int, default=200)
25
+ args = parser.parse_args()
26
+
27
+ ok, msg = verify_schema_version()
28
+ if not ok:
29
+ emit_error(msg)
30
+
31
+ if args.scope == "project" and args.project_id is None:
32
+ emit_error("--project-id is required when scope is project")
33
+
34
+ with Session(get_engine()) as session:
35
+ brains = list_brain_documents(
36
+ session,
37
+ scope=args.scope,
38
+ project_id=args.project_id,
39
+ search=args.search or None,
40
+ limit=args.limit,
41
+ )
42
+
43
+ lines = [f"#{b['id']} {b['relative_path']}" for b in brains]
44
+ emit(
45
+ {
46
+ "ok": True,
47
+ "scope": args.scope,
48
+ "project_id": args.project_id,
49
+ "count": len(brains),
50
+ "brains": brains,
51
+ "operator_chat": (
52
+ f"Listed {len(brains)} brain file(s) ({args.scope}).\n"
53
+ + ("\n".join(lines) if lines else "(none)")
54
+ ),
55
+ }
56
+ )
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """Create a project from FieldBrain dashboard (fieldbrain:projectCreate IPC)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+
9
+ from _db_lib import get_engine, verify_schema_version
10
+ from _json_out import emit, emit_error
11
+ from services.brain_paths import ensure_project_brain_layout
12
+ from services.project_service import create_project_structured
13
+ from sqlalchemy.orm import Session
14
+
15
+
16
+ def main() -> None:
17
+ parser = argparse.ArgumentParser()
18
+ parser.add_argument("--job-number", default="", help="Five-digit project number (e.g. 12345)")
19
+ parser.add_argument(
20
+ "--sub-project-number",
21
+ default="",
22
+ help="Optional three-digit sub-project (e.g. 001) → creates 12345-001",
23
+ )
24
+ parser.add_argument("--name", default="", help="Legacy: full name 12345 or 12345-001")
25
+ parser.add_argument(
26
+ "--tags-json",
27
+ default="",
28
+ help='JSON array of tag strings, e.g. ["line3"]',
29
+ )
30
+ args = parser.parse_args()
31
+
32
+ ok, msg = verify_schema_version()
33
+ if not ok:
34
+ emit_error(msg)
35
+
36
+ tags: list[str] | None = None
37
+ if args.tags_json.strip():
38
+ try:
39
+ parsed = json.loads(args.tags_json)
40
+ if isinstance(parsed, list):
41
+ tags = [str(x) for x in parsed]
42
+ else:
43
+ emit_error("--tags-json must be a JSON array")
44
+ except json.JSONDecodeError as exc:
45
+ emit_error(f"Invalid --tags-json: {exc}")
46
+
47
+ job_number = args.job_number.strip()
48
+ sub_number = args.sub_project_number.strip()
49
+ legacy_name = args.name.strip()
50
+
51
+ if legacy_name and not job_number:
52
+ from services.project_naming import parse_project_name
53
+
54
+ job, suffix = parse_project_name(legacy_name)
55
+ if not job:
56
+ emit_error("Provide --job-number (12345) or legacy --name in 12345 / 12345-001 format.")
57
+ job_number = job
58
+ sub_number = suffix or ""
59
+
60
+ if not job_number:
61
+ emit_error("Project number is required (five digits, e.g. 12345).")
62
+
63
+ try:
64
+ with Session(get_engine()) as session:
65
+ project = create_project_structured(
66
+ session,
67
+ job_number=job_number,
68
+ sub_project_number=sub_number or None,
69
+ project_tags=tags,
70
+ )
71
+ ensure_project_brain_layout(project["id"])
72
+ if project.get("parent_job_id"):
73
+ ensure_project_brain_layout(int(project["parent_job_id"]))
74
+ except ValueError as exc:
75
+ emit_error(str(exc))
76
+
77
+ if project["level"] == "sub_project":
78
+ chat = (
79
+ f"Created sub-project {project['id']}: {project['name']} "
80
+ f"(under project {project.get('job_number')})."
81
+ )
82
+ else:
83
+ chat = f"Created project {project['id']}: {project['name']}."
84
+
85
+ emit(
86
+ {
87
+ "ok": True,
88
+ "project": project,
89
+ "operator_chat": chat,
90
+ }
91
+ )
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ """List projects with stats for FieldBrain dashboard (fieldbrain:projectList IPC)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from _db_lib import get_engine, verify_schema_version
9
+ from _json_out import emit, emit_error
10
+ from services.project_service import list_projects_with_stats
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--include-hidden", action="store_true", help="Include system-hidden projects")
17
+ args = parser.parse_args()
18
+
19
+ ok, msg = verify_schema_version()
20
+ if not ok:
21
+ emit_error(msg)
22
+
23
+ with Session(get_engine()) as session:
24
+ projects = list_projects_with_stats(session, include_hidden=args.include_hidden)
25
+
26
+ lines = []
27
+ for p in projects:
28
+ stats = p.get("stats") or {}
29
+ lines.append(
30
+ f"{p['id']}: {p['name']} — "
31
+ f"{stats.get('brain_entries', 0)} brain, "
32
+ f"{stats.get('library_docs', 0)} docs, "
33
+ f"{stats.get('maintenance_logs', 0)} logs"
34
+ )
35
+
36
+ emit(
37
+ {
38
+ "ok": True,
39
+ "count": len(projects),
40
+ "projects": projects,
41
+ "operator_chat": (
42
+ f"Found {len(projects)} project(s).\n" + ("\n".join(lines) if lines else "(none)")
43
+ ),
44
+ }
45
+ )
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()