knowledge-os-mcp-server 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. knowledge_os/__init__.py +3 -0
  2. knowledge_os/api/__init__.py +0 -0
  3. knowledge_os/api/deps.py +13 -0
  4. knowledge_os/api/main.py +112 -0
  5. knowledge_os/api/routes/__init__.py +0 -0
  6. knowledge_os/api/routes/_common.py +41 -0
  7. knowledge_os/api/routes/connection.py +115 -0
  8. knowledge_os/api/routes/item.py +236 -0
  9. knowledge_os/api/routes/project.py +128 -0
  10. knowledge_os/api/routes/relation.py +24 -0
  11. knowledge_os/api/routes/tag.py +35 -0
  12. knowledge_os/api/routes/workspace.py +118 -0
  13. knowledge_os/api/schemas/__init__.py +0 -0
  14. knowledge_os/api/schemas/requests.py +42 -0
  15. knowledge_os/api/schemas/responses.py +128 -0
  16. knowledge_os/api/static/css/app.css +532 -0
  17. knowledge_os/api/static/css/tokens.css +134 -0
  18. knowledge_os/api/static/index.html +1357 -0
  19. knowledge_os/api/static/js/api.js +66 -0
  20. knowledge_os/api/static/js/main.js +47 -0
  21. knowledge_os/api/static/js/markdown.js +70 -0
  22. knowledge_os/api/static/js/router.js +68 -0
  23. knowledge_os/api/static/js/shortcuts.js +60 -0
  24. knowledge_os/api/static/js/store.js +417 -0
  25. knowledge_os/api/static/js/theme.js +9 -0
  26. knowledge_os/api/static/js/util.js +181 -0
  27. knowledge_os/api/static/js/views/connections.js +210 -0
  28. knowledge_os/api/static/js/views/editor.js +223 -0
  29. knowledge_os/api/static/js/views/graph.js +406 -0
  30. knowledge_os/api/static/js/views/item.js +270 -0
  31. knowledge_os/api/static/js/views/listing.js +233 -0
  32. knowledge_os/api/static/js/views/palette.js +167 -0
  33. knowledge_os/api/static/js/views/project.js +34 -0
  34. knowledge_os/api/static/js/views/sidebar.js +13 -0
  35. knowledge_os/api/static/js/views/subject.js +36 -0
  36. knowledge_os/api/static/js/views/tags.js +93 -0
  37. knowledge_os/api/static/js/views/workspace.js +29 -0
  38. knowledge_os/api/static/vendor/alpine.esm.js +11 -0
  39. knowledge_os/api/static/vendor/d3-drag.esm.js +7 -0
  40. knowledge_os/api/static/vendor/d3-force.esm.js +7 -0
  41. knowledge_os/api/static/vendor/d3-selection.esm.js +7 -0
  42. knowledge_os/api/static/vendor/d3-zoom.esm.js +7 -0
  43. knowledge_os/api/static/vendor/highlight.esm.js +1242 -0
  44. knowledge_os/api/static/vendor/marked.esm.js +2188 -0
  45. knowledge_os/api/static/vendor/purify.esm.js +1345 -0
  46. knowledge_os/cli.py +543 -0
  47. knowledge_os/config.py +224 -0
  48. knowledge_os/exceptions.py +25 -0
  49. knowledge_os/main.py +230 -0
  50. knowledge_os/mcp/INSTRUCTIONS.md +68 -0
  51. knowledge_os/mcp/__init__.py +1 -0
  52. knowledge_os/mcp/instructions.py +80 -0
  53. knowledge_os/mcp/tools.py +756 -0
  54. knowledge_os/model.py +345 -0
  55. knowledge_os/schemas/__init__.py +6 -0
  56. knowledge_os/schemas/item_schemas.py +134 -0
  57. knowledge_os/schemas/project_schemas.py +50 -0
  58. knowledge_os/schemas/relation_schemas.py +27 -0
  59. knowledge_os/schemas/tag_schemas.py +22 -0
  60. knowledge_os/schemas/workspace_schemas.py +26 -0
  61. knowledge_os/services/__init__.py +1 -0
  62. knowledge_os/services/_common.py +18 -0
  63. knowledge_os/services/brain.py +869 -0
  64. knowledge_os/services/connection_service.py +316 -0
  65. knowledge_os/services/context_service.py +272 -0
  66. knowledge_os/services/gh_cli.py +135 -0
  67. knowledge_os/services/git_repo_service.py +482 -0
  68. knowledge_os/services/graph.py +200 -0
  69. knowledge_os/services/item_file.py +414 -0
  70. knowledge_os/services/item_service.py +763 -0
  71. knowledge_os/services/migrate.py +167 -0
  72. knowledge_os/services/project_service.py +198 -0
  73. knowledge_os/services/relation_service.py +183 -0
  74. knowledge_os/services/repo_service.py +233 -0
  75. knowledge_os/services/secret_guard.py +56 -0
  76. knowledge_os/services/secret_run.py +185 -0
  77. knowledge_os/services/secret_service.py +149 -0
  78. knowledge_os/services/subject_service.py +183 -0
  79. knowledge_os/services/tag_service.py +180 -0
  80. knowledge_os/services/vault.py +125 -0
  81. knowledge_os/services/workspace_service.py +209 -0
  82. knowledge_os/storage/__init__.py +1 -0
  83. knowledge_os/storage/access.py +179 -0
  84. knowledge_os/storage/files.py +302 -0
  85. knowledge_os/storage/local_state.py +275 -0
  86. knowledge_os/storage/search.py +486 -0
  87. knowledge_os_mcp_server-0.3.1.dist-info/METADATA +292 -0
  88. knowledge_os_mcp_server-0.3.1.dist-info/RECORD +92 -0
  89. knowledge_os_mcp_server-0.3.1.dist-info/WHEEL +5 -0
  90. knowledge_os_mcp_server-0.3.1.dist-info/entry_points.txt +2 -0
  91. knowledge_os_mcp_server-0.3.1.dist-info/licenses/LICENSE +21 -0
  92. knowledge_os_mcp_server-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """MCP Knowledge OS."""
2
+
3
+ __version__ = "0.3.1"
File without changes
@@ -0,0 +1,13 @@
1
+ """Dependências FastAPI: a conexão do request."""
2
+
3
+ from fastapi import Header
4
+
5
+ from knowledge_os.storage.access import resolve_connection
6
+
7
+
8
+ def get_connection_id(x_connection_id: str | None = Header(default=None)) -> str:
9
+ """Conexão dos dados: o header X-Connection-Id ou, sem ele, a padrão do connections.json.
10
+
11
+ 422 se não há conexão configurada ou se ela está desabilitada; 404 se não existe.
12
+ """
13
+ return resolve_connection(x_connection_id).id
@@ -0,0 +1,112 @@
1
+ """App FastAPI do Knowledge OS: camada HTTP sobre os services."""
2
+
3
+ from pathlib import Path
4
+ from urllib.parse import urlsplit
5
+
6
+ from fastapi import FastAPI, Request
7
+ from fastapi.middleware.trustedhost import TrustedHostMiddleware
8
+ from fastapi.responses import JSONResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from knowledge_os import __version__
12
+ from knowledge_os.api.routes import (
13
+ connection,
14
+ item,
15
+ project,
16
+ relation,
17
+ tag,
18
+ workspace,
19
+ )
20
+ from knowledge_os.exceptions import NotFoundError, StorageError, ValidationError
21
+
22
+ app = FastAPI(
23
+ title="Knowledge OS API",
24
+ version=__version__,
25
+ description="REST API para Knowledge OS MCP",
26
+ docs_url="/docs",
27
+ redoc_url="/redoc",
28
+ openapi_url="/api/openapi.json",
29
+ )
30
+
31
+ # Sem login: o servidor é local (127.0.0.1). Sem CORS: a UI é servida pela mesma origem.
32
+ # TrustedHost barra DNS rebinding (Host forjado); "testserver" é o host do TestClient.
33
+ app.add_middleware(
34
+ TrustedHostMiddleware, allowed_hosts=["127.0.0.1", "localhost", "testserver"]
35
+ )
36
+
37
+ _WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
38
+
39
+
40
+ @app.middleware("http")
41
+ async def _same_origin_writes(request: Request, call_next):
42
+ """Recusa escrita vinda de outra página: o navegador manda `Origin` e ele deve ser o do Host.
43
+
44
+ Sem `Origin` (curl, scripts, TestClient) a requisição passa: só o navegador é o vetor.
45
+ """
46
+ origin = request.headers.get("origin")
47
+ if request.method in _WRITE_METHODS and origin is not None:
48
+ if origin == "null" or urlsplit(origin).netloc != request.headers.get("host", ""):
49
+ return JSONResponse(status_code=403, content={"detail": "Origin não permitida"})
50
+ return await call_next(request)
51
+
52
+
53
+ _SECURITY_HEADERS = {
54
+ "X-Frame-Options": "DENY",
55
+ "X-Content-Type-Options": "nosniff",
56
+ # Tudo servido daqui (sem CDN). 'unsafe-eval': o Alpine avalia as expressões dos atributos;
57
+ # style 'unsafe-inline': atributos style do HTML.
58
+ "Content-Security-Policy": (
59
+ "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; "
60
+ "img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; "
61
+ "base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
62
+ ),
63
+ }
64
+
65
+
66
+ @app.middleware("http")
67
+ async def _security_headers(request: Request, call_next):
68
+ response = await call_next(request)
69
+ for name, value in _SECURITY_HEADERS.items():
70
+ response.headers[name] = value
71
+ if request.url.path.startswith("/ui"):
72
+ # Módulos ES sem build: sem isso o navegador mistura versões em cache depois de uma
73
+ # atualização (import de export inexistente = tela preta). Revalida por ETag (304).
74
+ response.headers["Cache-Control"] = "no-cache"
75
+ return response
76
+
77
+
78
+ @app.exception_handler(NotFoundError)
79
+ async def _not_found(_: Request, exc: NotFoundError) -> JSONResponse:
80
+ return JSONResponse(status_code=404, content={"detail": str(exc)})
81
+
82
+
83
+ @app.exception_handler(ValidationError)
84
+ async def _validation(_: Request, exc: ValidationError) -> JSONResponse:
85
+ return JSONResponse(status_code=422, content={"detail": str(exc)})
86
+
87
+
88
+ @app.exception_handler(StorageError)
89
+ async def _storage(_: Request, exc: StorageError) -> JSONResponse:
90
+ return JSONResponse(status_code=500, content={"detail": str(exc)})
91
+
92
+
93
+ @app.get("/", tags=["meta"])
94
+ def health() -> dict[str, str]:
95
+ return {"status": "ok", "version": __version__}
96
+
97
+
98
+ for _module, _tag in (
99
+ (workspace, "workspaces"),
100
+ (project, "projects & subjects"),
101
+ (item, "items"),
102
+ (relation, "relations"),
103
+ (tag, "tags"),
104
+ (connection, "connections"),
105
+ ):
106
+ app.include_router(_module.router, prefix="/api", tags=[_tag])
107
+
108
+ # UI estática (Alpine + ES modules, libs via CDN, sem build). Em /ui porque "/" já é o health check.
109
+ _STATIC_DIR = Path(__file__).parent / "static"
110
+ if _STATIC_DIR.is_dir():
111
+ app.mount("/ui", StaticFiles(directory=_STATIC_DIR, html=True), name="ui")
112
+
File without changes
@@ -0,0 +1,41 @@
1
+ """Peças comuns das rotas: o item de saída e a recusa de parâmetro de consulta desconhecido
2
+ (ex.: um filtro do modelo antigo, como `scope` ou `status`, que agora são tags)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Iterable
7
+
8
+ from fastapi import Request
9
+
10
+ from knowledge_os.exceptions import ValidationError
11
+ from knowledge_os.schemas.item_schemas import ItemResponse
12
+ from knowledge_os.services.brain import Brain
13
+ from knowledge_os.storage.files import ItemRecord
14
+
15
+
16
+ def split(value: str | None) -> list[str] | None:
17
+ """`"a,b"` → `["a", "b"]` (None se vazio)."""
18
+ items = [v.strip() for v in (value or "").split(",") if v.strip()]
19
+ return items or None
20
+
21
+
22
+ def check_query(request: Request, allowed: Iterable[str]) -> None:
23
+ """422 com os válidos se a consulta traz um parâmetro que a rota não conhece: sem isso um
24
+ filtro inexistente seria ignorado em silêncio e a lista pareceria filtrada."""
25
+ valid = sorted(set(allowed))
26
+ unknown = sorted(k for k in request.query_params if k not in valid)
27
+ if unknown:
28
+ raise ValidationError(
29
+ f"Parâmetro(s) desconhecido(s): {', '.join(unknown)}. Válidos: {', '.join(valid)}"
30
+ )
31
+
32
+
33
+ def item_out(brain: Brain, record: ItemRecord) -> ItemResponse:
34
+ """O item completo da API a partir do registro."""
35
+ return ItemResponse.from_item(brain.view(record))
36
+
37
+
38
+ def item_by_id(cid: str, item_id: str) -> ItemResponse:
39
+ """Lê de novo (snapshot atual) e devolve o item; NotFoundError se não existe."""
40
+ brain = Brain(cid)
41
+ return item_out(brain, brain.snapshot.require(item_id))
@@ -0,0 +1,115 @@
1
+ """Rotas de connections sobre o connections.json: listar, ver, editar, testar, definir a
2
+ padrão e apagar. Criar conexão é só pelo MCP (`connection_create`), nunca pela API."""
3
+
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, Request, Response, status
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import JSONResponse
11
+ from fastapi.routing import APIRoute
12
+
13
+ from knowledge_os.api.schemas.requests import ConnectionUpdate
14
+ from knowledge_os.api.schemas.responses import (
15
+ ConnectionHealth,
16
+ ConnectionResponse,
17
+ ConnectionTestResponse,
18
+ )
19
+ from knowledge_os.exceptions import NotFoundError
20
+ from knowledge_os.services.connection_service import Connection, ConnectionService
21
+
22
+
23
+ class _SafeRoute(APIRoute):
24
+ """422 de validação sem o `input` recebido: o corpo pode trazer senha ou URL com senha."""
25
+
26
+ def get_route_handler(self) -> Callable[[Request], Any]:
27
+ original = super().get_route_handler()
28
+
29
+ async def handler(request: Request) -> Response:
30
+ try:
31
+ return await original(request)
32
+ except RequestValidationError as exc:
33
+ detail = [
34
+ {"type": e["type"], "loc": list(e["loc"]), "msg": e["msg"]}
35
+ for e in exc.errors()
36
+ ]
37
+ return JSONResponse(status_code=422, content={"detail": detail})
38
+
39
+ return handler
40
+
41
+
42
+ router = APIRouter(route_class=_SafeRoute)
43
+
44
+
45
+ def _folder_state(path: str | None) -> tuple[bool, bool]:
46
+ """(a pasta existe, é repositório git) — só para a UI mostrar o estado da conexão."""
47
+ if not path:
48
+ return False, False
49
+ folder = Path(path)
50
+ exists = folder.is_dir()
51
+ return exists, exists and (folder / ".git").exists()
52
+
53
+
54
+ def _view(conn: Connection) -> dict[str, Any]:
55
+ """Serializa a conexão para a API: a pasta (repositório git), o estado dela e o remote."""
56
+ path = getattr(conn, "path", None)
57
+ path_exists, is_git_repo = _folder_state(path)
58
+ return {
59
+ "id": conn.id,
60
+ "name": conn.name,
61
+ "path": path,
62
+ "path_exists": path_exists,
63
+ "is_git_repo": is_git_repo,
64
+ "remote_url": getattr(conn, "remote_url", None),
65
+ "review_mode": getattr(conn, "review_mode", "direct"),
66
+ "enabled": bool(conn.is_active),
67
+ "is_default": bool(getattr(conn, "is_default", False)),
68
+ "last_test": getattr(conn, "last_test", None),
69
+ "created_at": conn.created_at,
70
+ }
71
+
72
+
73
+ @router.get("/connections", response_model=list[ConnectionResponse])
74
+ def list_connections():
75
+ return [_view(c) for c in ConnectionService().list()]
76
+
77
+
78
+ # Antes de /connections/{id}, para "health" não ser lido como id.
79
+ @router.get("/connections/health", response_model=list[ConnectionHealth])
80
+ def connections_health():
81
+ """Cada conexão com o caminho da pasta, se é repositório git (`ok`) e os `.md` que a
82
+ leitura ignorou (`parse_errors`)."""
83
+ return ConnectionService().health()
84
+
85
+
86
+ @router.get("/connections/{id}", response_model=ConnectionResponse)
87
+ def get_connection(id: str):
88
+ return _view(ConnectionService().get(id))
89
+
90
+
91
+ @router.patch("/connections/{id}", response_model=ConnectionResponse)
92
+ def update_connection(id: str, req: ConnectionUpdate):
93
+ fields = req.model_dump(exclude_unset=True)
94
+ if "enabled" in fields:
95
+ fields["is_active"] = fields.pop("enabled")
96
+ if "name" in fields and fields["name"] is None:
97
+ del fields["name"]
98
+ return _view(ConnectionService().update(id, **fields))
99
+
100
+
101
+ @router.delete("/connections/{id}", status_code=status.HTTP_204_NO_CONTENT)
102
+ def delete_connection(id: str) -> Response:
103
+ if not ConnectionService().delete(id):
104
+ raise NotFoundError(f"Conexão não encontrada: {id}")
105
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
106
+
107
+
108
+ @router.post("/connections/{id}/test", response_model=ConnectionTestResponse)
109
+ def test_connection(id: str):
110
+ return ConnectionService().test(id)
111
+
112
+
113
+ @router.put("/connections/{id}/default", response_model=ConnectionResponse)
114
+ def set_default_connection(id: str):
115
+ return _view(ConnectionService().set_default(id))
@@ -0,0 +1,236 @@
1
+ """Rotas de itens (v6): listar com filtros, ler, criar, atualizar, apagar, busca explicada,
2
+ grafo do item e o valor de segredo.
3
+
4
+ Toda validação de conteúdo é do `ItemService` (que usa `model.validate_entry`): campo
5
+ desconhecido e valor fora da taxonomia voltam 422 com a mensagem do serviço, que lista os
6
+ válidos. v6: sem subtype/scope/status/origin/ttl — a nuance e o ciclo vivem em `tags`, o
7
+ alcance vem de onde o item mora.
8
+ """
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
11
+ from fastapi.concurrency import run_in_threadpool
12
+
13
+ from knowledge_os.api.deps import get_connection_id
14
+ from knowledge_os.api.routes._common import (
15
+ check_query,
16
+ item_by_id,
17
+ item_out,
18
+ split,
19
+ )
20
+ from knowledge_os.api.schemas.requests import ItemCreate, ItemUpdate
21
+ from knowledge_os.api.schemas.responses import ItemListResponse, ItemResponse
22
+ from knowledge_os.exceptions import ValidationError
23
+ from knowledge_os.model import TYPES
24
+ from knowledge_os.services.brain import Brain
25
+ from knowledge_os.services.graph import GraphService
26
+ from knowledge_os.services.item_file import slugify
27
+ from knowledge_os.services.item_service import MAX_LIMIT, ItemService
28
+ from knowledge_os.services.secret_service import SecretService
29
+
30
+ router = APIRouter()
31
+
32
+ _LIST_PARAMS = ("workspace_id", "project_id", "subject_id", "type", "tag", "limit", "offset")
33
+ _SEARCH_PARAMS = ("query", "workspace_id", "project_id", "subject_id", "types", "tags", "limit")
34
+ _GRAPH_PARAMS = ("depth", "limit", "relation_types", "types", "direction")
35
+ SEARCH_LIMIT = 50
36
+ CSV = "um valor, ou vários separados por vírgula"
37
+
38
+
39
+ def _choices(name: str, value: str | None, valid: tuple[str, ...]) -> set[str] | None:
40
+ values = split(value)
41
+ if values is None:
42
+ return None
43
+ bad = [v for v in values if v not in valid]
44
+ if bad:
45
+ raise ValidationError(f"{name} inválido: {', '.join(bad)}. Válidos: {', '.join(valid)}")
46
+ return set(values)
47
+
48
+
49
+ def _require_place(cid: str, ws_id: str, pj_id: str | None, sj_id: str | None = None) -> None:
50
+ """A UI cria e move itens só para lugares que existem. Item pode morar no workspace (sem
51
+ project); nesse caso só o workspace é exigido."""
52
+ snap = Brain(cid).snapshot
53
+ ws = snap.workspace(ws_id)
54
+ if pj_id:
55
+ pj = snap.project(ws.id, pj_id)
56
+ if sj_id:
57
+ snap.subject(ws.id, pj.id, sj_id)
58
+
59
+
60
+ @router.get("/items", response_model=ItemListResponse)
61
+ def list_items(
62
+ request: Request,
63
+ workspace_id: str | None = None,
64
+ project_id: str | None = Query(default=None, description=CSV),
65
+ subject_id: str | None = Query(default=None, description=CSV),
66
+ type: str | None = Query(default=None, description=CSV),
67
+ tag: str | None = Query(default=None, description=f"{CSV}; o item precisa ter todas"),
68
+ limit: int = Query(default=100, ge=1, le=500),
69
+ offset: int = Query(default=0, ge=0),
70
+ cid: str = Depends(get_connection_id),
71
+ ):
72
+ """Itens da conexão (todos, inclusive os com a tag `archived`), mais antigos primeiro."""
73
+ check_query(request, _LIST_PARAMS)
74
+ types = _choices("type", type, tuple(TYPES))
75
+ tags = set(split(tag) or [])
76
+ projects, subjects = split(project_id), split(subject_id)
77
+ brain = Brain(cid)
78
+ snap = brain.snapshot
79
+
80
+ def wanted(r) -> bool:
81
+ return (
82
+ (not workspace_id or slugify(r.workspace or "") == workspace_id)
83
+ and (projects is None or slugify(r.project or "") in projects)
84
+ and (subjects is None or bool(r.subject and slugify(r.subject) in subjects))
85
+ and (types is None or r.type in types)
86
+ and tags <= set(r.tags or [])
87
+ )
88
+
89
+ records = sorted((r for r in snap.records.values() if wanted(r)),
90
+ key=lambda r: (r.created_at, r.path))
91
+ page = records[offset:offset + limit]
92
+ return ItemListResponse(items=[item_out(brain, r) for r in page], total=len(records))
93
+
94
+
95
+ @router.post("/items", status_code=status.HTTP_201_CREATED, response_model=ItemResponse)
96
+ def create_item(req: ItemCreate, cid: str = Depends(get_connection_id)):
97
+ if req.sent("id"):
98
+ raise ValidationError("POST /api/items cria um item: não envie id (ele é gerado); para "
99
+ "editar use PUT /api/items/{id}")
100
+ fields = req.service_fields()
101
+ ws = fields.pop("workspace")
102
+ pj = fields.pop("project", None)
103
+ sj = fields.pop("subject", None)
104
+ _require_place(cid, ws, pj, sj)
105
+ item = ItemService(cid).create(ws, pj, sj, **fields)
106
+ return item_by_id(cid, item.id)
107
+
108
+
109
+ # Declarada antes de /items/{id} para "search" não ser lido como id.
110
+ @router.get("/items/search")
111
+ def search_items(
112
+ request: Request,
113
+ query: str = "",
114
+ workspace_id: str | None = Query(default=None, description="sem ele, todos os workspaces"),
115
+ project_id: str | None = Query(default=None, description=CSV),
116
+ subject_id: str | None = Query(default=None, description=CSV),
117
+ types: str | None = Query(default=None, description=CSV),
118
+ tags: str | None = Query(default=None, description=f"{CSV}; o item precisa ter todas"),
119
+ limit: int = Query(default=10, ge=1, le=SEARCH_LIMIT),
120
+ cid: str = Depends(get_connection_id),
121
+ ):
122
+ """A resposta do `ItemService.search` (`{results: [...]}`, sem `content`), com
123
+ `workspace_id`/`project_id`/`subject_id` em cada resultado para o link da UI.
124
+
125
+ Com `workspace_id`: o workspace inteiro; sem: tudo (`everywhere`). `project_id`/`subject_id`
126
+ restringem os resultados a esses lugares.
127
+ """
128
+ check_query(request, _SEARCH_PARAMS)
129
+ projects, subjects = split(project_id), split(subject_id)
130
+ narrowed = projects is not None or subjects is not None
131
+ service = ItemService(cid)
132
+ out = service.search(
133
+ query=query, workspace=workspace_id or None, everywhere=not workspace_id,
134
+ types=split(types), tags=split(tags),
135
+ # O serviço não filtra por project/subject: pede o máximo e corta aqui.
136
+ limit=MAX_LIMIT if narrowed else limit,
137
+ )
138
+ snap = Brain(cid).snapshot
139
+ results = []
140
+ for row in out.get("results", []):
141
+ record = snap.get(row["id"])
142
+ if record is None:
143
+ continue
144
+ pj = slugify(record.project or "") if record.project else None
145
+ sj = slugify(record.subject) if record.subject else None
146
+ if projects is not None and pj not in projects:
147
+ continue
148
+ if subjects is not None and sj not in subjects:
149
+ continue
150
+ results.append({**row, "workspace_id": slugify(record.workspace or ""),
151
+ "project_id": pj, "subject_id": sj})
152
+ return {**out, "results": results[:limit]}
153
+
154
+
155
+ @router.get("/items/{id}", response_model=ItemResponse)
156
+ def get_item(id: str, cid: str = Depends(get_connection_id)):
157
+ # Sem somar `opened`: o sinal de uso mede o agente, não quem navega pela UI.
158
+ return item_by_id(cid, id)
159
+
160
+
161
+ def _update(id: str, req: ItemUpdate, cid: str) -> ItemResponse:
162
+ record = Brain(cid).snapshot.require(id)
163
+ fields = req.service_fields()
164
+ if fields.get("workspace") or fields.get("project"):
165
+ _require_place(cid, fields.get("workspace") or slugify(record.workspace or ""),
166
+ fields.get("project") or (slugify(record.project or "") or None),
167
+ fields.get("subject"))
168
+ elif fields.get("subject"):
169
+ _require_place(cid, slugify(record.workspace or ""),
170
+ slugify(record.project or "") or None, fields["subject"])
171
+ ItemService(cid).update(id, **fields)
172
+ return item_by_id(cid, id)
173
+
174
+
175
+ @router.put("/items/{id}", response_model=ItemResponse)
176
+ def update_item(id: str, req: ItemUpdate, cid: str = Depends(get_connection_id)):
177
+ """Atualização parcial (igual ao PATCH): só os campos enviados mudam."""
178
+ return _update(id, req, cid)
179
+
180
+
181
+ @router.patch("/items/{id}", response_model=ItemResponse)
182
+ def patch_item(id: str, req: ItemUpdate, cid: str = Depends(get_connection_id)):
183
+ return _update(id, req, cid)
184
+
185
+
186
+ @router.delete("/items/{id}", status_code=status.HTTP_204_NO_CONTENT)
187
+ def delete_item(id: str, cid: str = Depends(get_connection_id)) -> Response:
188
+ """Apaga o item e as relações que apontam para ele, num commit."""
189
+ ItemService(cid).remove(id)
190
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
191
+
192
+
193
+ @router.get("/items/{id}/graph")
194
+ def item_graph(
195
+ request: Request,
196
+ id: str,
197
+ depth: int = 1,
198
+ limit: int = 20,
199
+ relation_types: str | None = Query(default=None, description=CSV),
200
+ types: str | None = Query(default=None, description=CSV),
201
+ direction: str = "both",
202
+ cid: str = Depends(get_connection_id),
203
+ ):
204
+ """O `item_graph` a partir do item: `{nodes: [{key, id, type, title, summary, hop}],
205
+ edges: [{from, type, to}], truncated, total_by_hop}` (`from`/`to` = key, ou id quando o
206
+ item não tem key)."""
207
+ check_query(request, _GRAPH_PARAMS)
208
+ Brain(cid).snapshot.require(id)
209
+ return GraphService(cid).graph(
210
+ [id], depth=depth, limit=limit,
211
+ relation_types=split(relation_types), types=split(types), direction=direction,
212
+ )
213
+
214
+
215
+ # Valor de segredo: entra só por aqui (o formulário da UI) e nenhuma rota o devolve. O corpo é
216
+ # lido à mão para que um erro de validação nunca ecoe o valor (o 422 padrão traz o `input`).
217
+ @router.put("/items/{id}/secret", status_code=status.HTTP_204_NO_CONTENT)
218
+ async def set_secret_value(
219
+ id: str, request: Request, cid: str = Depends(get_connection_id)
220
+ ) -> Response:
221
+ try:
222
+ body = await request.json()
223
+ except ValueError:
224
+ raise HTTPException(422, 'Corpo deve ser JSON: {"value": "..."}') from None
225
+ value = body.get("value") if isinstance(body, dict) else None
226
+ if not isinstance(value, str) or not value:
227
+ raise HTTPException(422, "Informe value (texto não vazio)")
228
+ # keyring e escrita em disco bloqueiam: fora do event loop da UI
229
+ await run_in_threadpool(SecretService(cid).set_value, id, value)
230
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
231
+
232
+
233
+ @router.delete("/items/{id}/secret", status_code=status.HTTP_204_NO_CONTENT)
234
+ def clear_secret_value(id: str, cid: str = Depends(get_connection_id)) -> Response:
235
+ SecretService(cid).clear_value(id)
236
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,128 @@
1
+ """Rotas de projects e subjects (v2; id = slug do nome, único dentro do pai).
2
+
3
+ Nas rotas por id, `workspace_id` (query) desfaz a ambiguidade quando o mesmo project existe em
4
+ mais de um workspace (ex.: `geral`). As listas vêm de `ProjectService.rows()` /
5
+ `SubjectService.rows()` (com `scope` efetivo e explícito) e indicam de onde vem o herdado.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from fastapi import APIRouter, Depends, Response, status
11
+
12
+ from knowledge_os.api.deps import get_connection_id
13
+ from knowledge_os.api.schemas.requests import (
14
+ ProjectCreate,
15
+ ProjectUpdate,
16
+ SubjectCreate,
17
+ SubjectUpdate,
18
+ )
19
+ from knowledge_os.api.schemas.responses import ProjectRow, SubjectRow
20
+ from knowledge_os.exceptions import NotFoundError
21
+ from knowledge_os.services.brain import Brain
22
+ from knowledge_os.services.project_service import ProjectService
23
+ from knowledge_os.services.subject_service import SubjectService
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ def _project_rows(cid: str, ws_id: str) -> list[dict[str, Any]]:
29
+ ws = Brain(cid).snapshot.workspace(ws_id)
30
+ return list(ProjectService(cid).rows(ws.id))
31
+
32
+
33
+ def _project_row(cid: str, ws_id: str, pj_id: str) -> dict[str, Any]:
34
+ for row in _project_rows(cid, ws_id):
35
+ if row["id"] == pj_id:
36
+ return row
37
+ raise NotFoundError(f"Project não encontrado: {pj_id}")
38
+
39
+
40
+ def _subject_rows(cid: str, ws_id: str, pj_id: str) -> list[dict[str, Any]]:
41
+ snap = Brain(cid).snapshot
42
+ ws = snap.workspace(ws_id)
43
+ pj = snap.project(ws.id, pj_id)
44
+ return [{**row, "workspace_id": ws.id, "project_id": pj.id}
45
+ for row in SubjectService(cid).rows(ws.id, pj.id)]
46
+
47
+
48
+ def _subject_row(cid: str, ws_id: str, pj_id: str, sj_id: str) -> dict[str, Any]:
49
+ for row in _subject_rows(cid, ws_id, pj_id):
50
+ if row["id"] == sj_id:
51
+ return row
52
+ raise NotFoundError(f"Subject não encontrado: {sj_id}")
53
+
54
+
55
+ # ---------------------------------------------------------------------------- projects
56
+
57
+
58
+ @router.get("/projects", response_model=list[ProjectRow])
59
+ def list_projects(workspace_id: str | None = None, cid: str = Depends(get_connection_id)):
60
+ if workspace_id:
61
+ return _project_rows(cid, workspace_id)
62
+ return [row for ws in Brain(cid).snapshot.workspaces() for row in _project_rows(cid, ws.id)]
63
+
64
+
65
+ @router.post("/projects", status_code=status.HTTP_201_CREATED, response_model=ProjectRow)
66
+ def create_project(req: ProjectCreate, cid: str = Depends(get_connection_id)):
67
+ pj = ProjectService(cid).create(req.workspace_id, req.name, req.description)
68
+ return _project_row(cid, pj.workspace_id, pj.id)
69
+
70
+
71
+ @router.get("/projects/{id}", response_model=ProjectRow)
72
+ def get_project(id: str, workspace_id: str | None = None, cid: str = Depends(get_connection_id)):
73
+ pj = ProjectService(cid).find_by_id(id, workspace_id)
74
+ return _project_row(cid, pj.workspace_id, pj.id)
75
+
76
+
77
+ @router.put("/projects/{id}", response_model=ProjectRow)
78
+ def update_project(
79
+ id: str, req: ProjectUpdate, workspace_id: str | None = None,
80
+ cid: str = Depends(get_connection_id),
81
+ ):
82
+ """Só o que vier muda: `name` renomeia, `description` atualiza."""
83
+ svc = ProjectService(cid)
84
+ pj = svc.find_by_id(id, workspace_id)
85
+ updated = svc.update(pj.workspace_id, pj.id, req.name, req.description)
86
+ return _project_row(cid, updated.workspace_id, updated.id)
87
+
88
+
89
+ @router.delete("/projects/{id}", status_code=status.HTTP_204_NO_CONTENT)
90
+ def delete_project(
91
+ id: str, workspace_id: str | None = None, cid: str = Depends(get_connection_id)
92
+ ) -> Response:
93
+ svc = ProjectService(cid)
94
+ pj = svc.find_by_id(id, workspace_id)
95
+ svc.delete(pj.workspace_id, pj.id)
96
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
97
+
98
+
99
+ # ---------------------------------------------------------------------------- subjects
100
+
101
+
102
+ @router.get("/subjects", response_model=list[SubjectRow])
103
+ def list_subjects(workspace_id: str, project_id: str, cid: str = Depends(get_connection_id)):
104
+ return _subject_rows(cid, workspace_id, project_id)
105
+
106
+
107
+ @router.post("/subjects", status_code=status.HTTP_201_CREATED, response_model=SubjectRow)
108
+ def create_subject(req: SubjectCreate, cid: str = Depends(get_connection_id)):
109
+ sj = SubjectService(cid).create(req.workspace_id, req.project_id, req.name,
110
+ req.description)
111
+ return _subject_row(cid, sj.workspace_id, sj.project_id, sj.id)
112
+
113
+
114
+ @router.put("/subjects/{id}", response_model=SubjectRow)
115
+ def update_subject(id: str, req: SubjectUpdate, workspace_id: str, project_id: str,
116
+ cid: str = Depends(get_connection_id)):
117
+ """Só o que vier muda: `name` renomeia, `description` atualiza."""
118
+ sj = SubjectService(cid).update(workspace_id, project_id, id, req.name, req.description)
119
+ return _subject_row(cid, sj.workspace_id, sj.project_id, sj.id)
120
+
121
+
122
+ @router.delete("/subjects/{id}", status_code=status.HTTP_204_NO_CONTENT)
123
+ def delete_subject(id: str, workspace_id: str, project_id: str,
124
+ cid: str = Depends(get_connection_id)) -> Response:
125
+ """Os itens do subject ficam no project, sem subject."""
126
+ if not SubjectService(cid).delete(workspace_id, project_id, id):
127
+ raise NotFoundError(f"Subject não encontrado: {id}")
128
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,24 @@
1
+ """Rotas de relações (v2): criar e apagar em lote pelo `RelationService` (`{source, type,
2
+ target}`, key ou id, atômico). Ler é pelo grafo (`GET /items/{id}/graph`)."""
3
+
4
+ from fastapi import APIRouter, Depends, status
5
+
6
+ from knowledge_os.api.deps import get_connection_id
7
+ from knowledge_os.api.schemas.requests import RelationBatch
8
+ from knowledge_os.api.schemas.responses import RelationRow
9
+ from knowledge_os.services.relation_service import RelationService
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("/relations", status_code=status.HTTP_201_CREATED,
15
+ response_model=list[RelationRow], response_model_exclude_none=True)
16
+ def create_relations(req: RelationBatch, cid: str = Depends(get_connection_id)):
17
+ """`created` ou `unchanged` (já existia) por entrada; `supersedes` arquiva o alvo."""
18
+ return RelationService(cid).create(req.items)
19
+
20
+
21
+ @router.delete("/relations", response_model=list[RelationRow], response_model_exclude_none=True)
22
+ def delete_relations(req: RelationBatch, cid: str = Depends(get_connection_id)):
23
+ """`deleted` ou `missing` por entrada."""
24
+ return RelationService(cid).delete(req.items)