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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/extensions/fieldbrain-tools.ts +495 -0
- package/extensions/index.ts +12 -0
- package/extensions/python-runner.ts +56 -0
- package/package.json +44 -0
- package/scripts/__pycache__/_db_lib.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_json_out.cpython-312.pyc +0 -0
- package/scripts/__pycache__/models.cpython-312.pyc +0 -0
- package/scripts/_db_lib.py +228 -0
- package/scripts/_json_out.py +19 -0
- package/scripts/alembic/env.py +53 -0
- package/scripts/alembic/versions/001_baseline.py +38 -0
- package/scripts/alembic/versions/002_core_tables.py +429 -0
- package/scripts/alembic/versions/003_durability_content_in_db.py +106 -0
- package/scripts/alembic/versions/004_pgvector_embedding.py +42 -0
- package/scripts/alembic/versions/005_project_job_number.py +37 -0
- package/scripts/alembic/versions/006_project_parent.py +62 -0
- package/scripts/alembic/versions/__init__.py +1 -0
- package/scripts/alembic.ini +38 -0
- package/scripts/db_auto_migrate.py +103 -0
- package/scripts/db_bootstrap.py +207 -0
- package/scripts/db_check.py +105 -0
- package/scripts/db_migrate.py +85 -0
- package/scripts/fieldbrain_brain_delete.py +49 -0
- package/scripts/fieldbrain_brain_read.py +61 -0
- package/scripts/fieldbrain_brain_restore.py +49 -0
- package/scripts/fieldbrain_brain_revisions.py +54 -0
- package/scripts/fieldbrain_brain_write.py +67 -0
- package/scripts/fieldbrain_document_attach.py +81 -0
- package/scripts/fieldbrain_document_catalog.py +128 -0
- package/scripts/fieldbrain_document_ingest.py +62 -0
- package/scripts/fieldbrain_document_list.py +67 -0
- package/scripts/fieldbrain_document_promote.py +43 -0
- package/scripts/fieldbrain_log_create.py +55 -0
- package/scripts/fieldbrain_log_delete.py +39 -0
- package/scripts/fieldbrain_log_restore.py +39 -0
- package/scripts/fieldbrain_log_revisions.py +39 -0
- package/scripts/fieldbrain_log_search.py +58 -0
- package/scripts/fieldbrain_log_update.py +52 -0
- package/scripts/fieldbrain_project_create.py +72 -0
- package/scripts/fieldbrain_project_list.py +53 -0
- package/scripts/fieldbrain_search.py +74 -0
- package/scripts/fieldbrain_ui_brain_list.py +60 -0
- package/scripts/fieldbrain_ui_project_create.py +95 -0
- package/scripts/fieldbrain_ui_project_list.py +49 -0
- package/scripts/models.py +355 -0
- package/scripts/pgvector_enable.py +165 -0
- package/scripts/pgvector_guide.py +44 -0
- package/scripts/pgvector_install_files.py +66 -0
- package/scripts/pgvector_install_from_folder.py +148 -0
- package/scripts/postbuild-ui.mjs +13 -0
- package/scripts/requirements.txt +7 -0
- package/scripts/services/__init__.py +1 -0
- package/scripts/services/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/brain_paths.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/document_formats.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/document_service.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/pgvector_windows.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/project_naming.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/project_service.cpython-312.pyc +0 -0
- package/scripts/services/brain_paths.py +81 -0
- package/scripts/services/brain_service.py +280 -0
- package/scripts/services/document_catalog.py +186 -0
- package/scripts/services/document_formats.py +116 -0
- package/scripts/services/document_ingest.py +254 -0
- package/scripts/services/document_service.py +316 -0
- package/scripts/services/embedding_service.py +72 -0
- package/scripts/services/global_brain_support.py +36 -0
- package/scripts/services/maintenance_log_service.py +258 -0
- package/scripts/services/migrate_lock.py +24 -0
- package/scripts/services/pgvector_windows.py +190 -0
- package/scripts/services/project_naming.py +72 -0
- package/scripts/services/project_service.py +368 -0
- package/scripts/services/search_service.py +250 -0
- package/scripts/status.py +39 -0
- package/shared/README.md +7 -0
- package/skills/fieldbrain/SKILL.md +197 -0
- package/skills/fieldbrain/SKILL.md.bak +83 -0
- package/skills/fieldbrain/routes/fieldbrain/assets/index-B56utPpP.css +1 -0
- package/skills/fieldbrain/routes/fieldbrain/assets/index-DO0AD0df.js +55 -0
- package/skills/fieldbrain/routes/fieldbrain/fallback.md +7 -0
- 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 @@
|
|
|
1
|
+
"""LogicScout service modules."""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
]
|