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,148 @@
1
+ #!/usr/bin/env python3
2
+ """Copy pgvector from operator folder/zip, then enable extension in FieldBrain DB."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from _db_lib import load_database_config, run_health_check
14
+ from _json_out import emit, emit_error
15
+ from services.pgvector_windows import (
16
+ guess_pgroot_for_major,
17
+ install_pgvector_files,
18
+ layman_pgvector_steps,
19
+ resolve_source_dir,
20
+ windows_install_status,
21
+ )
22
+
23
+
24
+ def _parse_script_json(stdout: str) -> dict:
25
+ trimmed = stdout.strip()
26
+ if not trimmed:
27
+ return {"ok": False, "error": "empty output"}
28
+ for line in reversed(trimmed.splitlines()):
29
+ line = line.strip()
30
+ if not line.startswith("{"):
31
+ continue
32
+ try:
33
+ return json.loads(line)
34
+ except json.JSONDecodeError:
35
+ continue
36
+ return {"ok": False, "error": trimmed}
37
+
38
+
39
+ def _run_enable(args: argparse.Namespace, admin_password: str) -> dict:
40
+ script = Path(__file__).resolve().parent / "pgvector_enable.py"
41
+ cmd = [
42
+ sys.executable,
43
+ str(script),
44
+ "--admin-user",
45
+ args.admin_user,
46
+ "--admin-password",
47
+ admin_password,
48
+ "--host",
49
+ args.host,
50
+ "--port",
51
+ str(args.port),
52
+ "--database",
53
+ args.database,
54
+ ]
55
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
56
+ parsed = _parse_script_json(proc.stdout or "")
57
+ if parsed.get("ok") is False and proc.stderr:
58
+ parsed.setdefault("error", proc.stderr.strip())
59
+ return parsed
60
+
61
+
62
+ def main() -> None:
63
+ parser = argparse.ArgumentParser()
64
+ parser.add_argument("--source", default="", help="Folder or .zip with pgvector files (local Postgres only)")
65
+ parser.add_argument("--admin-user", default="postgres")
66
+ parser.add_argument("--admin-password", default="")
67
+ parser.add_argument("--host", default="")
68
+ parser.add_argument("--port", type=int, default=0)
69
+ parser.add_argument("--database", default="")
70
+ parser.add_argument("--skip-file-copy", action="store_true", help="Only CREATE EXTENSION (remote server)")
71
+ args = parser.parse_args()
72
+
73
+ admin_password = (args.admin_password or os.environ.get("SYLO_FIELDBRAIN_BOOTSTRAP_ADMIN_PASSWORD") or "").strip()
74
+ if not admin_password:
75
+ emit_error("Postgres superuser password is required (not saved).")
76
+
77
+ cfg = load_database_config() or {}
78
+ host = (args.host or str(cfg.get("host") or "localhost")).strip()
79
+ port = args.port or int(cfg.get("port") or 5432)
80
+ database = (args.database or str(cfg.get("database") or "fieldbrain")).strip()
81
+ args.host = host
82
+ args.port = port
83
+ args.database = database
84
+
85
+ health = run_health_check()
86
+ if health.get("postgres_connected") is not True:
87
+ emit_error(health.get("postgres_error", "Connect to Postgres first (Test connection)."))
88
+
89
+ major = health.get("postgres_major")
90
+ if isinstance(major, str) and major.isdigit():
91
+ major = int(major)
92
+ install = windows_install_status(major if isinstance(major, int) else None)
93
+ db_mode = "remote" if host not in ("localhost", "127.0.0.1") else str(
94
+ health.get("db_mode") or "local"
95
+ )
96
+
97
+ copy_steps: list[str] = []
98
+ if not args.skip_file_copy and db_mode == "local":
99
+ source = (args.source or "").strip()
100
+ if not source:
101
+ emit_error(
102
+ "Select the extracted pgvector folder (or .zip) first.",
103
+ pgvector_setup=layman_pgvector_steps(
104
+ major if isinstance(major, int) else None,
105
+ files_installed=bool(install.get("vector_files_installed")),
106
+ db_mode=db_mode,
107
+ ),
108
+ )
109
+ pgroot = guess_pgroot_for_major(major) if isinstance(major, int) else None
110
+ if pgroot is None:
111
+ emit_error("Could not find PostgreSQL install folder on this PC.")
112
+ source_dir, tmp, err = resolve_source_dir(source)
113
+ if err or source_dir is None:
114
+ emit_error(err or "Invalid pgvector source path")
115
+ try:
116
+ copy_result = install_pgvector_files(source_dir, pgroot)
117
+ finally:
118
+ if tmp is not None:
119
+ tmp.cleanup()
120
+ if not copy_result.get("ok"):
121
+ emit_error(
122
+ copy_result.get("error", "File copy failed"),
123
+ **{
124
+ **install,
125
+ "needs_elevation": copy_result.get("needs_elevation"),
126
+ "pgroot": copy_result.get("pgroot") or install.get("pgroot"),
127
+ "source_dir": str(source_dir),
128
+ },
129
+ )
130
+ copy_steps.append(f"Installed pgvector files into {copy_result.get('pgroot')}")
131
+ install = windows_install_status(major if isinstance(major, int) else None)
132
+
133
+ enable_result = _run_enable(args, admin_password)
134
+ if enable_result.get("ok") is False:
135
+ emit_error(
136
+ enable_result.get("error", "Enable failed"),
137
+ pgvector_setup=enable_result.get("pgvector_setup"),
138
+ **install,
139
+ )
140
+
141
+ lines = [*copy_steps]
142
+ if enable_result.get("operator_chat"):
143
+ lines.append(str(enable_result["operator_chat"]))
144
+ emit({**enable_result, "operator_chat": "\n".join(lines), **install})
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
@@ -0,0 +1,13 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
6
+ const dest = path.join(root, 'skills/fieldbrain/routes/fieldbrain/fallback.md')
7
+ const src = path.join(root, 'ui/fallback.md')
8
+
9
+ if (fs.existsSync(src)) {
10
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
11
+ fs.copyFileSync(src, dest)
12
+ console.log('[sylo-fieldbrain] copied ui/fallback.md →', dest)
13
+ }
@@ -0,0 +1,7 @@
1
+ sqlalchemy>=2.0.0
2
+ psycopg2-binary>=2.9.9
3
+ alembic>=1.13.0
4
+ python-dotenv>=1.0.0
5
+ httpx>=0.27.0
6
+ pymupdf>=1.24.0
7
+ pgvector>=0.3.0
@@ -0,0 +1 @@
1
+ """LogicScout service modules."""
@@ -0,0 +1,81 @@
1
+ """Brain filesystem path helpers (stub — full write/index in later phases)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from _db_lib import package_root
8
+
9
+ GLOBAL_BRAIN_PROJECT_NAME = "__fieldbrain_global_brain__"
10
+
11
+ _BRAIN_SUBDIRS = (
12
+ "inbox",
13
+ "issues",
14
+ "gotchas",
15
+ "runbooks",
16
+ "decisions",
17
+ "common_issues",
18
+ "attachments",
19
+ )
20
+
21
+
22
+ def storage_root() -> Path:
23
+ """Shared on-disk storage under the package (server-side or local Postgres host)."""
24
+ root = package_root() / "shared" / "storage"
25
+ root.mkdir(parents=True, exist_ok=True)
26
+ return root
27
+
28
+
29
+ def project_brain_root(project_id: int) -> Path:
30
+ return storage_root() / str(project_id) / "brain"
31
+
32
+
33
+ def global_brain_root() -> Path:
34
+ return storage_root() / "global" / "brain"
35
+
36
+
37
+ def ensure_project_brain_layout(project_id: int) -> Path:
38
+ """Create brain/ subdirs if missing. Does not seed INDEX.md yet (Phase 4+)."""
39
+ root = project_brain_root(project_id)
40
+ root.mkdir(parents=True, exist_ok=True)
41
+ for sub in _BRAIN_SUBDIRS:
42
+ (root / sub).mkdir(parents=True, exist_ok=True)
43
+ return root
44
+
45
+
46
+ def ensure_global_brain_layout() -> Path:
47
+ root = global_brain_root()
48
+ root.mkdir(parents=True, exist_ok=True)
49
+ for sub in (*_BRAIN_SUBDIRS, "references"):
50
+ (root / sub).mkdir(parents=True, exist_ok=True)
51
+ return root
52
+
53
+
54
+ def safe_relative_brain_path(spec: str) -> str | None:
55
+ if not spec or not spec.strip():
56
+ return None
57
+ raw = spec.strip().strip("`").replace("\\", "/")
58
+ parts = Path(raw).parts
59
+ if any(p == ".." or p.startswith("..") for p in parts):
60
+ return None
61
+ if parts and parts[0] == "brain":
62
+ parts = parts[1:]
63
+ if not parts:
64
+ return None
65
+ rel = "/".join(parts)
66
+ if ".." in rel.split("/"):
67
+ return None
68
+ if not rel.endswith(".md"):
69
+ return None
70
+ return rel
71
+
72
+
73
+ def resolve_brain_path(brain_root: Path, relative: str) -> Path | None:
74
+ rel = safe_relative_brain_path(relative)
75
+ if not rel:
76
+ return None
77
+ target = (brain_root / rel).resolve()
78
+ root_resolved = brain_root.resolve()
79
+ if root_resolved not in target.parents and target != root_resolved:
80
+ return None
81
+ return target
@@ -0,0 +1,280 @@
1
+ """Read/write brain markdown in Postgres with revisions and soft delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from sqlalchemy import func
9
+ from sqlalchemy.orm import Session
10
+
11
+ from models import BrainDocument, BrainDocumentRevision
12
+ from services.brain_paths import safe_relative_brain_path
13
+ from services.global_brain_support import ensure_global_brain_shell_project
14
+
15
+
16
+ def _scope_norm(scope: str) -> str:
17
+ s = (scope or "project").strip().lower()
18
+ if s not in ("global", "project"):
19
+ raise ValueError("scope must be global or project")
20
+ return s
21
+
22
+
23
+ def _resolve_scope_project(session: Session, scope: str, project_id: int | None) -> tuple[str, int | None]:
24
+ scope_n = _scope_norm(scope)
25
+ if scope_n == "global":
26
+ ensure_global_brain_shell_project(session)
27
+ return scope_n, None
28
+ if project_id is None:
29
+ raise ValueError("project_id is required when scope is project")
30
+ return scope_n, project_id
31
+
32
+
33
+ def _active_brain_query(session: Session, scope: str, project_id: int | None, relative_path: str):
34
+ scope_n, pid = _resolve_scope_project(session, scope, project_id)
35
+ q = session.query(BrainDocument).filter(
36
+ BrainDocument.scope == scope_n,
37
+ BrainDocument.relative_path == relative_path,
38
+ BrainDocument.deleted_at.is_(None),
39
+ )
40
+ if scope_n == "global":
41
+ q = q.filter(BrainDocument.project_id.is_(None))
42
+ else:
43
+ q = q.filter(BrainDocument.project_id == pid)
44
+ return q
45
+
46
+
47
+ def _next_brain_revision(session: Session, doc_id: int) -> int:
48
+ current = (
49
+ session.query(func.max(BrainDocumentRevision.revision_number))
50
+ .filter(BrainDocumentRevision.brain_document_id == doc_id)
51
+ .scalar()
52
+ )
53
+ return int(current or 0) + 1
54
+
55
+
56
+ def _save_brain_revision(session: Session, doc: BrainDocument) -> None:
57
+ rev = BrainDocumentRevision(
58
+ brain_document_id=doc.id,
59
+ relative_path=doc.relative_path,
60
+ content=doc.content,
61
+ revision_number=_next_brain_revision(session, doc.id),
62
+ )
63
+ session.add(rev)
64
+
65
+
66
+ def brain_doc_to_dict(doc: BrainDocument) -> dict[str, Any]:
67
+ return {
68
+ "id": doc.id,
69
+ "scope": doc.scope,
70
+ "project_id": doc.project_id,
71
+ "relative_path": doc.relative_path,
72
+ "content": doc.content,
73
+ "size_bytes": len((doc.content or "").encode("utf-8")),
74
+ "deleted_at": doc.deleted_at.isoformat() if doc.deleted_at else None,
75
+ "created_at": doc.created_at.isoformat() if doc.created_at else None,
76
+ "updated_at": doc.updated_at.isoformat() if doc.updated_at else None,
77
+ }
78
+
79
+
80
+ def brain_doc_list_item(doc: BrainDocument) -> dict[str, Any]:
81
+ return {
82
+ "id": doc.id,
83
+ "scope": doc.scope,
84
+ "project_id": doc.project_id,
85
+ "relative_path": doc.relative_path,
86
+ "size_bytes": len((doc.content or "").encode("utf-8")),
87
+ "updated_at": doc.updated_at.isoformat() if doc.updated_at else None,
88
+ }
89
+
90
+
91
+ def list_brain_documents(
92
+ session: Session,
93
+ *,
94
+ scope: str = "project",
95
+ project_id: int | None = None,
96
+ search: str | None = None,
97
+ limit: int = 200,
98
+ ) -> list[dict[str, Any]]:
99
+ scope_n = _scope_norm(scope)
100
+ query = session.query(BrainDocument).filter(BrainDocument.deleted_at.is_(None))
101
+ if scope_n == "global":
102
+ query = query.filter(BrainDocument.scope == "global", BrainDocument.project_id.is_(None))
103
+ else:
104
+ if project_id is None:
105
+ raise ValueError("project_id is required when scope is project")
106
+ query = query.filter(BrainDocument.scope == "project", BrainDocument.project_id == project_id)
107
+
108
+ term = (search or "").strip()
109
+ if term:
110
+ like = f"%{term}%"
111
+ query = query.filter(BrainDocument.relative_path.ilike(like))
112
+
113
+ cap = max(1, min(int(limit), 500))
114
+ rows = query.order_by(BrainDocument.relative_path.asc()).limit(cap).all()
115
+ return [brain_doc_list_item(d) for d in rows]
116
+
117
+
118
+ def read_brain_markdown(
119
+ session: Session,
120
+ *,
121
+ scope: str,
122
+ relative_path: str,
123
+ project_id: int | None = None,
124
+ ) -> dict[str, Any]:
125
+ rel = safe_relative_brain_path(relative_path)
126
+ if not rel:
127
+ raise ValueError("Invalid brain path (must be a .md file under brain/, no .. segments)")
128
+
129
+ doc = _active_brain_query(session, scope, project_id, rel).first()
130
+ if not doc:
131
+ raise ValueError(f"Brain file not found: {rel}")
132
+
133
+ result = brain_doc_to_dict(doc)
134
+ result["storage"] = "postgres"
135
+ return result
136
+
137
+
138
+ def write_brain_markdown(
139
+ session: Session,
140
+ *,
141
+ scope: str,
142
+ relative_path: str,
143
+ content: str,
144
+ project_id: int | None = None,
145
+ mode: str = "replace",
146
+ ) -> dict[str, Any]:
147
+ rel = safe_relative_brain_path(relative_path)
148
+ if not rel:
149
+ raise ValueError("Invalid brain path (must be a .md file under brain/, no .. segments)")
150
+
151
+ scope_n, pid = _resolve_scope_project(session, scope, project_id)
152
+ mode_norm = (mode or "replace").strip().lower()
153
+ if mode_norm not in ("replace", "append"):
154
+ raise ValueError("mode must be replace or append")
155
+
156
+ doc = _active_brain_query(session, scope, project_id, rel).first()
157
+ created = doc is None
158
+
159
+ if doc is None:
160
+ doc = BrainDocument(scope=scope_n, project_id=pid, relative_path=rel, content="")
161
+ session.add(doc)
162
+ session.flush()
163
+
164
+ if mode_norm == "append" and doc.content:
165
+ new_content = doc.content.rstrip() + "\n\n" + (content or "")
166
+ else:
167
+ new_content = content or ""
168
+
169
+ if not created and doc.content != new_content:
170
+ _save_brain_revision(session, doc)
171
+
172
+ doc.content = new_content
173
+ doc.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
174
+ session.commit()
175
+ session.refresh(doc)
176
+
177
+ result = brain_doc_to_dict(doc)
178
+ result["storage"] = "postgres"
179
+ result["mode"] = mode_norm
180
+ result["created"] = created
181
+ return result
182
+
183
+
184
+ def soft_delete_brain(
185
+ session: Session,
186
+ *,
187
+ scope: str,
188
+ relative_path: str,
189
+ project_id: int | None = None,
190
+ ) -> dict[str, Any]:
191
+ rel = safe_relative_brain_path(relative_path)
192
+ if not rel:
193
+ raise ValueError("Invalid brain path")
194
+
195
+ doc = _active_brain_query(session, scope, project_id, rel).first()
196
+ if not doc:
197
+ raise ValueError(f"Brain file not found: {rel}")
198
+
199
+ _save_brain_revision(session, doc)
200
+ doc.deleted_at = datetime.now(timezone.utc).replace(tzinfo=None)
201
+ session.commit()
202
+ return {"ok": True, "id": doc.id, "relative_path": rel, "soft_deleted": True}
203
+
204
+
205
+ def restore_brain(
206
+ session: Session,
207
+ *,
208
+ scope: str,
209
+ relative_path: str,
210
+ project_id: int | None = None,
211
+ ) -> dict[str, Any]:
212
+ rel = safe_relative_brain_path(relative_path)
213
+ if not rel:
214
+ raise ValueError("Invalid brain path")
215
+
216
+ scope_n, pid = _resolve_scope_project(session, scope, project_id)
217
+ q = session.query(BrainDocument).filter(
218
+ BrainDocument.scope == scope_n,
219
+ BrainDocument.relative_path == rel,
220
+ BrainDocument.deleted_at.isnot(None),
221
+ )
222
+ if scope_n == "global":
223
+ q = q.filter(BrainDocument.project_id.is_(None))
224
+ else:
225
+ q = q.filter(BrainDocument.project_id == pid)
226
+
227
+ doc = q.order_by(BrainDocument.deleted_at.desc()).first()
228
+ if not doc:
229
+ raise ValueError(f"No deleted brain file to restore: {rel}")
230
+
231
+ conflict = _active_brain_query(session, scope, project_id, rel).first()
232
+ if conflict:
233
+ raise ValueError(f"An active brain file already exists at {rel}; delete or rename it first")
234
+
235
+ doc.deleted_at = None
236
+ session.commit()
237
+ return brain_doc_to_dict(doc)
238
+
239
+
240
+ def list_brain_revisions(
241
+ session: Session,
242
+ *,
243
+ scope: str,
244
+ relative_path: str,
245
+ project_id: int | None = None,
246
+ limit: int = 20,
247
+ ) -> list[dict[str, Any]]:
248
+ rel = safe_relative_brain_path(relative_path)
249
+ if not rel:
250
+ raise ValueError("Invalid brain path")
251
+
252
+ doc = _active_brain_query(session, scope, project_id, rel).first()
253
+ if not doc:
254
+ doc = (
255
+ session.query(BrainDocument)
256
+ .filter(BrainDocument.relative_path == rel)
257
+ .order_by(BrainDocument.updated_at.desc())
258
+ .first()
259
+ )
260
+ if not doc:
261
+ return []
262
+
263
+ cap = max(1, min(int(limit), 100))
264
+ rows = (
265
+ session.query(BrainDocumentRevision)
266
+ .filter(BrainDocumentRevision.brain_document_id == doc.id)
267
+ .order_by(BrainDocumentRevision.revision_number.desc())
268
+ .limit(cap)
269
+ .all()
270
+ )
271
+ return [
272
+ {
273
+ "revision_number": r.revision_number,
274
+ "relative_path": r.relative_path,
275
+ "content_preview": (r.content[:400] + "…") if len(r.content) > 400 else r.content,
276
+ "content_length": len(r.content),
277
+ "created_at": r.created_at.isoformat() if r.created_at else None,
278
+ }
279
+ for r in rows
280
+ ]