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,49 @@
1
+ #!/usr/bin/env python3
2
+ """Restore a soft-deleted brain document (logicscout_brain_restore 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.brain_service import restore_brain
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--path", required=True)
17
+ parser.add_argument("--scope", choices=("global", "project"), default="project")
18
+ parser.add_argument("--project-id", type=int, default=None)
19
+ args = parser.parse_args()
20
+
21
+ ok, msg = verify_schema_version()
22
+ if not ok:
23
+ emit_error(msg)
24
+
25
+ if args.scope == "project" and args.project_id is None:
26
+ emit_error("--project-id is required when scope is project")
27
+
28
+ try:
29
+ with Session(get_engine()) as session:
30
+ result = restore_brain(
31
+ session,
32
+ scope=args.scope,
33
+ relative_path=args.path,
34
+ project_id=args.project_id,
35
+ )
36
+ except ValueError as exc:
37
+ emit_error(str(exc))
38
+
39
+ emit(
40
+ {
41
+ "ok": True,
42
+ "document": result,
43
+ "operator_chat": f"Restored brain {args.scope}:{args.path}.",
44
+ }
45
+ )
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python3
2
+ """List revision history for a brain document (logicscout_brain_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.brain_service import list_brain_revisions
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--path", required=True)
17
+ parser.add_argument("--scope", choices=("global", "project"), default="project")
18
+ parser.add_argument("--project-id", type=int, default=None)
19
+ parser.add_argument("--limit", type=int, default=20)
20
+ args = parser.parse_args()
21
+
22
+ ok, msg = verify_schema_version()
23
+ if not ok:
24
+ emit_error(msg)
25
+
26
+ if args.scope == "project" and args.project_id is None:
27
+ emit_error("--project-id is required when scope is project")
28
+
29
+ try:
30
+ with Session(get_engine()) as session:
31
+ revisions = list_brain_revisions(
32
+ session,
33
+ scope=args.scope,
34
+ relative_path=args.path,
35
+ project_id=args.project_id,
36
+ limit=args.limit,
37
+ )
38
+ except ValueError as exc:
39
+ emit_error(str(exc))
40
+
41
+ emit(
42
+ {
43
+ "ok": True,
44
+ "path": args.path,
45
+ "scope": args.scope,
46
+ "revisions": revisions,
47
+ "count": len(revisions),
48
+ "operator_chat": f"{len(revisions)} revision(s) for brain {args.scope}:{args.path}.",
49
+ }
50
+ )
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """Write/update brain markdown (logicscout_brain_write 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.brain_service import write_brain_markdown
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--path", required=True, help="Relative brain path, e.g. gotchas/conveyor_jam.md")
17
+ parser.add_argument("--content", required=True, help="Markdown body (full file when mode=replace)")
18
+ parser.add_argument(
19
+ "--scope",
20
+ choices=("global", "project"),
21
+ default="project",
22
+ help="global org brain or project brain",
23
+ )
24
+ parser.add_argument("--project-id", type=int, default=None, help="Required when scope=project")
25
+ parser.add_argument(
26
+ "--mode",
27
+ choices=("replace", "append"),
28
+ default="replace",
29
+ help="replace entire file or append to existing",
30
+ )
31
+ args = parser.parse_args()
32
+
33
+ ok, msg = verify_schema_version()
34
+ if not ok:
35
+ emit_error(msg)
36
+
37
+ if args.scope == "project" and args.project_id is None:
38
+ emit_error("--project-id is required when scope is project")
39
+
40
+ try:
41
+ with Session(get_engine()) as session:
42
+ result = write_brain_markdown(
43
+ session,
44
+ scope=args.scope,
45
+ relative_path=args.path,
46
+ content=args.content,
47
+ project_id=args.project_id,
48
+ mode=args.mode,
49
+ )
50
+ except ValueError as exc:
51
+ emit_error(str(exc))
52
+
53
+ action = "Created" if result.get("created") else "Updated"
54
+ emit(
55
+ {
56
+ "ok": True,
57
+ **result,
58
+ "operator_chat": (
59
+ f"{action} brain {result['scope']}:{result['relative_path']} "
60
+ f"({result['size_bytes']} bytes, mode={result['mode']})."
61
+ ),
62
+ }
63
+ )
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env python3
2
+ """Attach existing doc or register new file path (logicscout_document_attach tool)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ from _db_lib import get_engine, verify_schema_version
10
+ from _json_out import emit, emit_error
11
+ from services.document_service import attach_document_to_project, register_document_from_path
12
+ from sqlalchemy.orm import Session
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("--document-id", type=int, default=None, help="Attach existing document by id")
18
+ parser.add_argument("--file-path", default="", help="Register a new file (copied into shared storage)")
19
+ parser.add_argument("--project-id", type=int, default=None, help="Target project id")
20
+ parser.add_argument(
21
+ "--scope",
22
+ choices=("global", "project"),
23
+ default="project",
24
+ help="Scope when registering a new file (default project)",
25
+ )
26
+ parser.add_argument("--title", default="", help="Optional title when registering a file")
27
+ parser.add_argument("--role", default="reference", help="Link role: reference or schematic")
28
+ args = parser.parse_args()
29
+
30
+ ok, msg = verify_schema_version()
31
+ if not ok:
32
+ emit_error(msg)
33
+
34
+ has_doc = args.document_id is not None
35
+ has_file = bool(args.file_path.strip())
36
+ if has_doc == has_file:
37
+ emit_error("Provide exactly one of --document-id or --file-path")
38
+
39
+ try:
40
+ with Session(get_engine()) as session:
41
+ if has_file:
42
+ result = register_document_from_path(
43
+ session,
44
+ Path(args.file_path.strip()),
45
+ scope=args.scope,
46
+ project_id=args.project_id,
47
+ title=args.title or None,
48
+ attach=args.project_id is not None,
49
+ )
50
+ doc = result["document"]
51
+ chat = (
52
+ f"Registered document #{doc['id']}: {doc['title']} "
53
+ f"(scope={doc['scope']}, created={result['created']})."
54
+ )
55
+ if result.get("linked"):
56
+ chat += f" Linked to project {args.project_id}."
57
+ emit({"ok": True, **result, "operator_chat": chat})
58
+ else:
59
+ if args.project_id is None:
60
+ emit_error("--project-id is required when attaching an existing document")
61
+ result = attach_document_to_project(
62
+ session,
63
+ args.project_id,
64
+ args.document_id,
65
+ role=args.role,
66
+ )
67
+ doc = result["document"]
68
+ verb = "Attached" if not result.get("already_linked") else "Already linked"
69
+ emit(
70
+ {
71
+ "ok": True,
72
+ **result,
73
+ "operator_chat": f"{verb} document #{doc['id']} ({doc['title']}) to project {args.project_id}.",
74
+ }
75
+ )
76
+ except ValueError as exc:
77
+ emit_error(str(exc))
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Catalog a library document after the agent reads it with Sylo reader skills."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from _db_lib import get_engine, verify_schema_version
11
+ from _json_out import emit, emit_error
12
+ from services.document_catalog import catalog_document
13
+ from services.document_formats import READER_HINTS, reader_hint_for_ext
14
+ from services.document_service import register_document_from_path
15
+ from sqlalchemy.orm import Session
16
+
17
+
18
+ def main() -> None:
19
+ parser = argparse.ArgumentParser()
20
+ parser.add_argument("--document-id", type=int, default=None, help="Existing document id")
21
+ parser.add_argument(
22
+ "--file-path",
23
+ default="",
24
+ help="Register this file first (global or project scope), then catalog",
25
+ )
26
+ parser.add_argument(
27
+ "--description",
28
+ required=True,
29
+ help="Agent-written catalog summary (what this file is and when to use it)",
30
+ )
31
+ parser.add_argument("--title", default="", help="Optional display title")
32
+ parser.add_argument("--category", default="", help="manual, datasheet, requirements, email, howto, image, …")
33
+ parser.add_argument("--tags", default="", help="Comma-separated or JSON array")
34
+ parser.add_argument("--manufacturer", default="")
35
+ parser.add_argument("--model", default="")
36
+ parser.add_argument("--version", default="")
37
+ parser.add_argument(
38
+ "--outline-json",
39
+ default="",
40
+ help='Optional JSON array: [{"level":1,"title":"Section","page":0}]',
41
+ )
42
+ parser.add_argument(
43
+ "--scope",
44
+ choices=("global", "project"),
45
+ default="global",
46
+ help="When using --file-path (default global library)",
47
+ )
48
+ parser.add_argument("--project-id", type=int, default=None)
49
+ args = parser.parse_args()
50
+
51
+ ok, msg = verify_schema_version()
52
+ if not ok:
53
+ emit_error(msg)
54
+
55
+ has_doc = args.document_id is not None
56
+ has_file = bool(args.file_path.strip())
57
+ if has_doc == has_file:
58
+ emit_error("Provide exactly one of --document-id or --file-path")
59
+
60
+ outline: list[dict] | None = None
61
+ if args.outline_json.strip():
62
+ try:
63
+ parsed = json.loads(args.outline_json)
64
+ if not isinstance(parsed, list):
65
+ emit_error("--outline-json must be a JSON array")
66
+ outline = parsed
67
+ except json.JSONDecodeError as exc:
68
+ emit_error(f"Invalid --outline-json: {exc}")
69
+
70
+ try:
71
+ with Session(get_engine()) as session:
72
+ if has_file:
73
+ path = Path(args.file_path.strip()).expanduser().resolve()
74
+ ext = path.suffix.lower()
75
+ reg = register_document_from_path(
76
+ session,
77
+ path,
78
+ scope=args.scope,
79
+ project_id=args.project_id,
80
+ title=args.title or None,
81
+ category=args.category or "other",
82
+ attach=args.project_id is not None,
83
+ )
84
+ doc_id = int(reg["document"]["id"])
85
+ reader = reader_hint_for_ext(ext)
86
+ else:
87
+ doc_id = int(args.document_id)
88
+ reader = None
89
+
90
+ result = catalog_document(
91
+ session,
92
+ doc_id,
93
+ description=args.description,
94
+ tags=args.tags or None,
95
+ category=args.category or None,
96
+ title=args.title or None,
97
+ manufacturer=args.manufacturer or None,
98
+ model=args.model or None,
99
+ version=args.version or None,
100
+ outline=outline,
101
+ project_id=args.project_id,
102
+ )
103
+ except ValueError as exc:
104
+ emit_error(str(exc))
105
+ except Exception as exc:
106
+ emit_error(f"Catalog failed: {exc}")
107
+
108
+ doc = result["document"]
109
+ lines = [
110
+ f"Cataloged document #{doc['id']}: {doc['title']} (category={doc['category']}).",
111
+ f"Indexed {result.get('chunks_indexed', 0)} search chunk(s); "
112
+ f"{result.get('embeddings_written', 0)} embedding(s).",
113
+ "Deep read later with the matching Sylo reader — not stored as full-text chunks.",
114
+ ]
115
+ if reader:
116
+ lines.insert(1, f"Reader for this type: {reader}")
117
+ emit(
118
+ {
119
+ "ok": True,
120
+ **result,
121
+ "reader_hints": READER_HINTS,
122
+ "operator_chat": "\n".join(lines),
123
+ }
124
+ )
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """Ingest PDF/text: extract, chunk, index, embed (logicscout_document_ingest 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.document_ingest import ingest_document_file
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--file-path", required=True, help="Path to PDF or .txt on disk")
17
+ parser.add_argument(
18
+ "--scope",
19
+ choices=("global", "project"),
20
+ default="project",
21
+ help="global library or project-local document",
22
+ )
23
+ parser.add_argument("--project-id", type=int, default=None, help="Required when scope=project")
24
+ parser.add_argument("--title", default="", help="Optional document title")
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
+ try:
35
+ with Session(get_engine()) as session:
36
+ result = ingest_document_file(
37
+ session,
38
+ args.file_path,
39
+ scope=args.scope,
40
+ project_id=args.project_id,
41
+ title=args.title or None,
42
+ )
43
+ except ValueError as exc:
44
+ emit_error(str(exc))
45
+ except Exception as exc:
46
+ emit_error(f"Ingest failed: {exc}")
47
+
48
+ emit(
49
+ {
50
+ "ok": True,
51
+ **result,
52
+ "operator_chat": (
53
+ f"Ingested document #{result['document_id']}: {result['title']}. "
54
+ f"{result['chunks_indexed']} chunk(s) indexed, "
55
+ f"{result['embeddings_written']} embedding(s) written."
56
+ ),
57
+ }
58
+ )
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """List global or project documents (logicscout_document_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.document_service import list_documents_for_project, list_global_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="global",
20
+ help="global library or project-attached documents",
21
+ )
22
+ parser.add_argument("--project-id", type=int, default=None, help="Required when scope=project")
23
+ parser.add_argument("--category", default="", help="Filter global docs by category")
24
+ parser.add_argument("--search", default="", help="Filter global docs by title/filename")
25
+ parser.add_argument("--limit", type=int, default=200, help="Max global results (default 200)")
26
+ args = parser.parse_args()
27
+
28
+ ok, msg = verify_schema_version()
29
+ if not ok:
30
+ emit_error(msg)
31
+
32
+ if args.scope == "project" and args.project_id is None:
33
+ emit_error("--project-id is required when scope is project")
34
+
35
+ with Session(get_engine()) as session:
36
+ if args.scope == "global":
37
+ documents = list_global_documents(
38
+ session,
39
+ category=args.category or None,
40
+ search=args.search or None,
41
+ limit=args.limit,
42
+ )
43
+ else:
44
+ documents = list_documents_for_project(session, args.project_id)
45
+
46
+ lines = []
47
+ for d in documents:
48
+ role = f" role={d['link_role']}" if d.get("link_role") else ""
49
+ lines.append(f"#{d['id']} {d['title']} ({d['scope']}){role}")
50
+
51
+ emit(
52
+ {
53
+ "ok": True,
54
+ "scope": args.scope,
55
+ "project_id": args.project_id,
56
+ "count": len(documents),
57
+ "documents": documents,
58
+ "operator_chat": (
59
+ f"Listed {len(documents)} document(s) ({args.scope}).\n"
60
+ + ("\n".join(lines) if lines else "(none)")
61
+ ),
62
+ }
63
+ )
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env python3
2
+ """Promote a project-local document to the global library (fieldbrain_document_promote 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.document_service import promote_document_to_global
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--document-id", type=int, required=True, help="Document id to promote")
17
+ args = parser.parse_args()
18
+
19
+ ok, msg = verify_schema_version()
20
+ if not ok:
21
+ emit_error(msg)
22
+
23
+ try:
24
+ with Session(get_engine()) as session:
25
+ result = promote_document_to_global(session, args.document_id)
26
+ except ValueError as exc:
27
+ emit_error(str(exc))
28
+
29
+ doc = result["document"]
30
+ if result.get("promoted"):
31
+ chat = (
32
+ f"Promoted document #{doc['id']} ({doc['title']}) to the global library. "
33
+ f"It now appears in global search; the link to project "
34
+ f"{result.get('previous_project_id')} is kept."
35
+ )
36
+ else:
37
+ chat = f"Document #{doc['id']} ({doc['title']}) is already in the global library."
38
+
39
+ emit({"ok": True, **result, "operator_chat": chat})
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env python3
2
+ """Create a maintenance log entry (logicscout_log_create 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 create_log_entry
11
+ from sqlalchemy.orm import Session
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--title", required=True, help="Short summary title")
17
+ parser.add_argument("--body", required=True, help="Full log body / notes")
18
+ parser.add_argument("--project-id", type=int, default=None, help="Optional project id")
19
+ parser.add_argument("--fault-code", default="", help="Optional fault/alarm code")
20
+ parser.add_argument("--equipment-tag", default="", help="Optional PLC/HMI tag or asset id")
21
+ parser.add_argument("--logged-by", default="", help="Optional attribution string")
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
+ entry = create_log_entry(
31
+ session,
32
+ title=args.title,
33
+ body=args.body,
34
+ project_id=args.project_id,
35
+ fault_code=args.fault_code or None,
36
+ equipment_tag=args.equipment_tag or None,
37
+ logged_by=args.logged_by or None,
38
+ )
39
+ except ValueError as exc:
40
+ emit_error(str(exc))
41
+
42
+ emit(
43
+ {
44
+ "ok": True,
45
+ "entry": entry,
46
+ "operator_chat": (
47
+ f"Logged maintenance entry #{entry['id']}: {entry['title']}"
48
+ + (f" (project {entry['project_id']})" if entry.get("project_id") else "")
49
+ ),
50
+ }
51
+ )
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+ """Soft-delete a maintenance log entry (logicscout_log_delete 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 soft_delete_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
+ args = parser.parse_args()
18
+
19
+ ok, msg = verify_schema_version()
20
+ if not ok:
21
+ emit_error(msg)
22
+
23
+ try:
24
+ with Session(get_engine()) as session:
25
+ result = soft_delete_log_entry(session, args.entry_id)
26
+ except ValueError as exc:
27
+ emit_error(str(exc))
28
+
29
+ emit(
30
+ {
31
+ "ok": True,
32
+ **result,
33
+ "operator_chat": f"Soft-deleted maintenance log entry {args.entry_id} (revision saved).",
34
+ }
35
+ )
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+ """Restore a soft-deleted maintenance log entry (logicscout_log_restore 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 restore_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
+ args = parser.parse_args()
18
+
19
+ ok, msg = verify_schema_version()
20
+ if not ok:
21
+ emit_error(msg)
22
+
23
+ try:
24
+ with Session(get_engine()) as session:
25
+ result = restore_log_entry(session, args.entry_id)
26
+ except ValueError as exc:
27
+ emit_error(str(exc))
28
+
29
+ emit(
30
+ {
31
+ "ok": True,
32
+ "entry": result,
33
+ "operator_chat": f"Restored maintenance log entry {args.entry_id}: {result['title']}",
34
+ }
35
+ )
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()