vinc-client 0.1.0__tar.gz

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.
@@ -0,0 +1,52 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ # 예외: 디자인 허브 산출물은 **계약**이라 추적한다. 소비 리포의 사본
7
+ # (app/src/tokens.*.css·theme.ts)이 커밋돼 있으므로 그 비교 대상도 리포에 있어야
8
+ # `sync_out.py --check`(CI 드리프트 게이트)가 성립한다. 없으면 게이트가
9
+ # "[MISSING·허브]" 로 죽는다 — 2026-08-08 CI 실패의 원인.
10
+ !design-hub/dist/
11
+ # 단, Claude Design 푸시 번들은 제외 — 업로드 직전에 만드는 임시 산출물이고
12
+ # 대조할 커밋된 소비 사본이 없다(그쪽은 claude.ai 프로젝트에 산다).
13
+ design-hub/dist/claude-design/
14
+ build/
15
+ *.egg-info/
16
+ models/
17
+ vinc_store
18
+ *.lbug
19
+ vinc_e2e/
20
+ node_modules/
21
+ app/src-tauri/target/
22
+ app/src-tauri/binaries/
23
+ app/src-tauri/gen/
24
+ # .claude/ 는 기기별 설정(launch.json·ui-events.jsonl·worktrees)이라 **어느 깊이에서도**
25
+ # 추적하지 않는다. 예외는 이제 없다. 역할 껍데기는 `agents/shells/` 에서 추적되고
26
+ # (`agents_sync.py --check` 가 CI 에서 대조), 하네스가 읽는 사본은 리포 밖 워크스페이스
27
+ # 루트에 `--install` 이 쓴다. 리포 안 `.claude/agents` 는 체크아웃의 브랜치를 따라
28
+ # 수가 달라지는 두 번째 로스터였다.
29
+ # ⚠ 전에는 루트의 `.claude/agents/` 만 되살리려고 네 줄을 겹쳐 썼다(한 줄로는 예외가
30
+ # 사문이 되거나 `_migration/**/.claude/` 중첩본이 새어 나갔다, 2026-08-23 PR #69).
31
+ # 예외가 사라졌으니 모든 깊이를 잡는 이 한 줄이면 된다.
32
+ .claude/
33
+ # 서명·접속 키 — 커밋되면 되돌릴 수 없다. 지금 키들은 리포 **바깥**(워크스페이스
34
+ # 루트)에 있어 커밋된 적이 없지만, 누가 안으로 옮겨도 막히도록 패턴을 박아 둔다.
35
+ # 업데이터 개인키를 잃으면 배포된 전 사용자가 자동 업데이트에서 영구 이탈한다
36
+ # (복구 경로 없음 — DEPLOYMENT.md §1.6 백업 절차 참조).
37
+ *.key
38
+ *.pem
39
+ *.p12
40
+ *.pfx
41
+
42
+ # uv 캐시 락파일 — 로컬 테스트 실행(`uv run`)이 만든다. 정본 의존성은
43
+ # pyproject.toml 이고, 이 락은 기기별 파생물이라 추적하지 않는다.
44
+ uv.lock
45
+
46
+ # 평가·리허설 부산물 — 모델 가중치 수백 MB 가 여기 앉는다.
47
+ # 이 워킹트리는 세션 공유라 남의 `git add -A` 가 이것을 집는다.
48
+ .cache/
49
+ work/
50
+
51
+ # design-hub capture PNGs are build products of scripts/capture_*.mjs; the manifests are committed
52
+ design-hub/reports/captures/**/*.png
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.5
2
+ Name: vinc-client
3
+ Version: 0.1.0
4
+ Summary: Python client for the Vinc REST v1 API: read a knowledge graph you share with AI, and write episodes to it on purpose.
5
+ Project-URL: Homepage, https://vincs.io
6
+ Project-URL: Documentation, https://vincs.io/docs/
7
+ Author: Vinculums
8
+ License-Expression: MIT
9
+ Keywords: agents,knowledge-graph,memory,rest,vinc
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx<1,>=0.27
16
+ Provides-Extra: test
17
+ Requires-Dist: anyio>=4; extra == 'test'
18
+ Requires-Dist: pytest>=8; extra == 'test'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # vinc-client
22
+
23
+ Python client for the [Vinc](https://vincs.io) REST v1 API. Vinc is a knowledge graph you share with AI: decisions, records and documents your team wrote, with the reasons attached.
24
+
25
+ This package reads that graph for an agent and writes to it only when your code asks. It is the base of `vinc-langgraph` and `vinc-agent-framework`.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install vinc-client
31
+ ```
32
+
33
+ ## Keys and spaces
34
+
35
+ Create a member key in your Vinc account. A `vinc_ro_` key reads; a `vinc_sk_` key can also write. Use a read-only key wherever the agent only needs context.
36
+
37
+ ```python
38
+ from vinc_client import VincClient
39
+
40
+ vinc = VincClient() # reads VINC_API_KEY
41
+ team = VincClient(space="<team id>") # a team's shared graph instead of your personal one
42
+ ```
43
+
44
+ ## Context for a model turn
45
+
46
+ ```python
47
+ block = vinc.get_context("Why are our colour tokens stored as OKLCH?")
48
+ if block:
49
+ system_prompt += "\n\n" + block
50
+ ```
51
+
52
+ `get_context` makes one call per turn. It returns `None` when nothing matched, and also when Vinc is unreachable or the daily limit is spent, so the model call goes on without it. A key or space problem raises, because it will not fix itself.
53
+
54
+ The block is fenced and introduced as data, not instructions: text in the graph can be written by anyone with access to it, so the model is told to treat it as reference material.
55
+
56
+ ## Reading
57
+
58
+ ```python
59
+ vinc.brief("release checklist") # one node, its excerpts and relations
60
+ vinc.search("spacing scale", limit=10) # text and meaning search
61
+ vinc.recall(domain="vinc/design") # episode timeline, newest first
62
+ vinc.node("decision:tokens-are-oklch") # one node by id
63
+ ```
64
+
65
+ ## Writing, on purpose
66
+
67
+ Nothing in this package writes because a conversation happened. Record an episode when a person approved what the agent did:
68
+
69
+ ```python
70
+ vinc = VincClient(api_key=WRITE_KEY) # vinc_sk_
71
+ vinc.record_episode(
72
+ "Fixed contrast on the dark secondary button",
73
+ summary="Token text-secondary moved to pass 4.5:1 on the dark surface.",
74
+ about=["decision:tokens-are-oklch"],
75
+ )
76
+ ```
77
+
78
+ Every `about` id is read before the write, so a typo raises `VincNotFound` instead of creating an empty node.
79
+
80
+ ## Errors
81
+
82
+ | Class | Meaning |
83
+ |---|---|
84
+ | `VincAuthError` | missing, malformed, refused or read-only key; configuration is wrong |
85
+ | `VincNotFound` | the node, document or space does not exist for this key |
86
+ | `VincInvalidArgument` | the request was malformed or refused (for example a topic limit) |
87
+ | `VincQuotaExceeded` | the daily limit is spent; `retry_after` is in seconds |
88
+ | `VincUnavailable` | network or server failure; for a write, check `details["write_may_have_applied"]` |
89
+ | `VincPartialWrite` | part of a write was skipped; `result` holds what the server said |
90
+
91
+ ## Async
92
+
93
+ `AsyncVincClient` has the same methods, awaited.
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,77 @@
1
+ # vinc-client
2
+
3
+ Python client for the [Vinc](https://vincs.io) REST v1 API. Vinc is a knowledge graph you share with AI: decisions, records and documents your team wrote, with the reasons attached.
4
+
5
+ This package reads that graph for an agent and writes to it only when your code asks. It is the base of `vinc-langgraph` and `vinc-agent-framework`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install vinc-client
11
+ ```
12
+
13
+ ## Keys and spaces
14
+
15
+ Create a member key in your Vinc account. A `vinc_ro_` key reads; a `vinc_sk_` key can also write. Use a read-only key wherever the agent only needs context.
16
+
17
+ ```python
18
+ from vinc_client import VincClient
19
+
20
+ vinc = VincClient() # reads VINC_API_KEY
21
+ team = VincClient(space="<team id>") # a team's shared graph instead of your personal one
22
+ ```
23
+
24
+ ## Context for a model turn
25
+
26
+ ```python
27
+ block = vinc.get_context("Why are our colour tokens stored as OKLCH?")
28
+ if block:
29
+ system_prompt += "\n\n" + block
30
+ ```
31
+
32
+ `get_context` makes one call per turn. It returns `None` when nothing matched, and also when Vinc is unreachable or the daily limit is spent, so the model call goes on without it. A key or space problem raises, because it will not fix itself.
33
+
34
+ The block is fenced and introduced as data, not instructions: text in the graph can be written by anyone with access to it, so the model is told to treat it as reference material.
35
+
36
+ ## Reading
37
+
38
+ ```python
39
+ vinc.brief("release checklist") # one node, its excerpts and relations
40
+ vinc.search("spacing scale", limit=10) # text and meaning search
41
+ vinc.recall(domain="vinc/design") # episode timeline, newest first
42
+ vinc.node("decision:tokens-are-oklch") # one node by id
43
+ ```
44
+
45
+ ## Writing, on purpose
46
+
47
+ Nothing in this package writes because a conversation happened. Record an episode when a person approved what the agent did:
48
+
49
+ ```python
50
+ vinc = VincClient(api_key=WRITE_KEY) # vinc_sk_
51
+ vinc.record_episode(
52
+ "Fixed contrast on the dark secondary button",
53
+ summary="Token text-secondary moved to pass 4.5:1 on the dark surface.",
54
+ about=["decision:tokens-are-oklch"],
55
+ )
56
+ ```
57
+
58
+ Every `about` id is read before the write, so a typo raises `VincNotFound` instead of creating an empty node.
59
+
60
+ ## Errors
61
+
62
+ | Class | Meaning |
63
+ |---|---|
64
+ | `VincAuthError` | missing, malformed, refused or read-only key; configuration is wrong |
65
+ | `VincNotFound` | the node, document or space does not exist for this key |
66
+ | `VincInvalidArgument` | the request was malformed or refused (for example a topic limit) |
67
+ | `VincQuotaExceeded` | the daily limit is spent; `retry_after` is in seconds |
68
+ | `VincUnavailable` | network or server failure; for a write, check `details["write_may_have_applied"]` |
69
+ | `VincPartialWrite` | part of a write was skipped; `result` holds what the server said |
70
+
71
+ ## Async
72
+
73
+ `AsyncVincClient` has the same methods, awaited.
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vinc-client"
7
+ version = "0.1.0"
8
+ description = "Python client for the Vinc REST v1 API: read a knowledge graph you share with AI, and write episodes to it on purpose."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Vinculums" }]
13
+ keywords = ["vinc", "knowledge-graph", "agents", "memory", "rest"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = ["httpx>=0.27,<1"]
21
+
22
+ [project.optional-dependencies]
23
+ test = ["pytest>=8", "anyio>=4"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://vincs.io"
27
+ Documentation = "https://vincs.io/docs/"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/vinc_client"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,18 @@
1
+ """Python client for the Vinc REST v1 API.
2
+
3
+ Design and scope: [[vinc.plan.framework-integration-packages-2026-09-28]].
4
+ """
5
+ __version__ = "0.1.0"
6
+
7
+ from .client import AsyncVincClient, VincClient # noqa: E402
8
+ from .context import BOUNDARY, fence, format_answer, format_brief, format_search # noqa: E402
9
+ from .episode import build_episode_fragment # noqa: E402
10
+ from .errors import (VincAuthError, VincError, VincInvalidArgument, VincNotFound, # noqa: E402
11
+ VincPartialWrite, VincQuotaExceeded, VincUnavailable)
12
+
13
+ __all__ = [
14
+ "__version__", "VincClient", "AsyncVincClient",
15
+ "BOUNDARY", "fence", "format_answer", "format_brief", "format_search", "build_episode_fragment",
16
+ "VincError", "VincAuthError", "VincNotFound", "VincInvalidArgument",
17
+ "VincQuotaExceeded", "VincUnavailable", "VincPartialWrite",
18
+ ]
@@ -0,0 +1,384 @@
1
+ """Synchronous and asynchronous clients for the Vinc REST v1 API.
2
+
3
+ Both read a member key (``vinc_sk_`` full, ``vinc_ro_`` read only) and act on
4
+ one space: the personal graph, or a team by its id. Nothing here writes on its
5
+ own; the only write paths are ``ingest`` and ``record_episode``, and both are
6
+ explicit calls.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Iterable
15
+ from urllib.parse import quote, urlparse
16
+
17
+ import httpx
18
+
19
+ from . import __version__
20
+ from .context import DEFAULT_MAX_CHARS, format_answer
21
+ from .episode import build_episode_fragment, check_about
22
+ from .errors import (VincAuthError, VincError, VincInvalidArgument, VincPartialWrite,
23
+ VincQuotaExceeded, VincUnavailable, error_class)
24
+
25
+ log = logging.getLogger("vinc_client")
26
+
27
+ DEFAULT_BASE_URL = "https://mcp.vincs.io"
28
+ KEY_PREFIXES = ("vinc_sk_", "vinc_ro_")
29
+ _LOOPBACK = frozenset({"localhost", "127.0.0.1", "::1"})
30
+ #: 브리프가 한 노드로 못 좁힌 두 경우. 자연어 질문은 동점(choose)이 흔하다(라이브 실측).
31
+ _WIDEN = ("none", "choose")
32
+ _UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
33
+
34
+
35
+ def resolve_key(api_key: str | None) -> str:
36
+ key = (api_key or os.environ.get("VINC_API_KEY") or "").strip()
37
+ if not key:
38
+ raise VincAuthError("not_authenticated", "Pass api_key or set VINC_API_KEY.")
39
+ if not key.startswith(KEY_PREFIXES):
40
+ raise VincAuthError("not_authenticated",
41
+ "A Vinc member key starts with vinc_sk_ or vinc_ro_.")
42
+ return key
43
+
44
+
45
+ def resolve_base_url(base_url: str | None) -> str:
46
+ base = (base_url or os.environ.get("VINC_API_URL") or DEFAULT_BASE_URL).rstrip("/")
47
+ parsed = urlparse(base)
48
+ # http 는 루프백만 — 키가 평문으로 네트워크를 지나지 않게. CLI 와 같은 규칙.
49
+ if parsed.scheme == "https" or (parsed.scheme == "http" and parsed.hostname in _LOOPBACK):
50
+ return base
51
+ raise VincInvalidArgument("invalid_argument",
52
+ "The API URL must use https (http is allowed only for localhost).")
53
+
54
+
55
+ def normalize_space(space: str | None) -> str:
56
+ value = (space or "personal").strip().lower()
57
+ if value != "personal" and not _UUID.fullmatch(value):
58
+ raise ValueError("space must be 'personal' or a team id (a UUID)")
59
+ return value
60
+
61
+
62
+ def _segment(value: str) -> str:
63
+ # `.`·`..` 는 경로 정규화가 되감는 조각이라 그대로 보내면 다른 라우트로 간다.
64
+ text = quote(str(value), safe="")
65
+ return text.replace(".", "%2E") if text in (".", "..") else text
66
+
67
+
68
+ def _drop_none(values: dict[str, Any]) -> dict[str, Any]:
69
+ return {k: v for k, v in values.items() if v is not None}
70
+
71
+
72
+ @dataclass
73
+ class _Call:
74
+ method: str
75
+ path: str
76
+ params: dict[str, Any] = field(default_factory=dict)
77
+ body: dict[str, Any] | None = None
78
+ space: str | None = None
79
+
80
+
81
+ def _retry_after(response: httpx.Response, details: dict[str, Any]) -> float | None:
82
+ raw = response.headers.get("Retry-After") or details.get("retry_after_s")
83
+ try:
84
+ return float(raw) if raw not in (None, "") else None
85
+ except (TypeError, ValueError):
86
+ return None
87
+
88
+
89
+ def _parse(response: httpx.Response) -> dict[str, Any]:
90
+ status = response.status_code
91
+ if 300 <= status < 400:
92
+ # 리다이렉트를 따라가면 Authorization 이 그 주소로 가고, POST 쓰기가 본문 없는
93
+ # GET 으로 바뀌어 성공처럼 보인다. 따라가지 않고 실패로 말한다.
94
+ raise VincUnavailable("upstream_unavailable",
95
+ f"The API answered a redirect (HTTP {status}); it was not followed.",
96
+ status=status)
97
+ try:
98
+ payload = response.json()
99
+ except ValueError:
100
+ payload = None
101
+ if 200 <= status < 300:
102
+ if not isinstance(payload, dict):
103
+ raise VincUnavailable("upstream_unavailable", "The API returned invalid JSON.",
104
+ status=status)
105
+ return payload
106
+ raw = payload.get("error") if isinstance(payload, dict) else None
107
+ error: dict[str, Any] = raw if isinstance(raw, dict) else {
108
+ "code": "upstream_unavailable" if status >= 500 else "http_error",
109
+ "message": f"HTTP {status}"}
110
+ raw_details = error.get("details")
111
+ details: dict[str, Any] = raw_details if isinstance(raw_details, dict) else {}
112
+ raise error_class(status)(
113
+ str(error.get("code") or "http_error"), str(error.get("message") or ""),
114
+ status=status, request_id=str(error.get("request_id") or
115
+ response.headers.get("Vinc-Request-Id") or ""),
116
+ details=details, retry_after=_retry_after(response, details))
117
+
118
+
119
+ def _raise_if_partial(result: dict[str, Any]) -> dict[str, Any]:
120
+ if result.get("partial"):
121
+ raise VincPartialWrite("The write was accepted only in part; read result['warnings'].",
122
+ result)
123
+ return result
124
+
125
+
126
+ class _Base:
127
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None,
128
+ space: str | None = None, timeout: float = 30.0) -> None:
129
+ self._key = resolve_key(api_key)
130
+ self.base_url = resolve_base_url(base_url)
131
+ self.space = normalize_space(space)
132
+ self.timeout = timeout
133
+
134
+ @property
135
+ def read_only(self) -> bool:
136
+ """True for a ``vinc_ro_`` key: every write is refused before it is sent."""
137
+ return self._key.startswith("vinc_ro_")
138
+
139
+ def _headers(self) -> dict[str, str]:
140
+ return {"Authorization": f"Bearer {self._key}", "Accept": "application/json",
141
+ "User-Agent": f"vinc-client/{__version__}"}
142
+
143
+ def _request_args(self, call: _Call) -> dict[str, Any]:
144
+ space = normalize_space(call.space) if call.space is not None else self.space
145
+ params = {k: (",".join(v) if isinstance(v, (list, tuple)) else
146
+ ("true" if v else "false") if isinstance(v, bool) else v)
147
+ for k, v in call.params.items() if v is not None}
148
+ if space != "personal":
149
+ params["space"] = space
150
+ args: dict[str, Any] = {"method": call.method, "url": call.path, "params": params}
151
+ if call.body is not None:
152
+ args["json"] = call.body
153
+ return args
154
+
155
+ def _require_write_key(self) -> None:
156
+ if self.read_only:
157
+ raise VincAuthError("read_only_key",
158
+ "This is a read-only key (vinc_ro_); writing needs a vinc_sk_ key.")
159
+
160
+ # ── 라우트 한 줄씩. 경로·필드 이름은 vinc_mcp/openapi_v1.json 과 대조된다 ──
161
+ @staticmethod
162
+ def _whoami(space) -> _Call:
163
+ return _Call("GET", "/v1/me", space=space)
164
+
165
+ @staticmethod
166
+ def _brief(query, max_excerpts, historical, space) -> _Call:
167
+ return _Call("POST", "/v1/brief", body=_drop_none(
168
+ {"query": query, "max_excerpts": max_excerpts, "historical": historical}), space=space)
169
+
170
+ @staticmethod
171
+ def _node(node_id, space) -> _Call:
172
+ return _Call("GET", f"/v1/nodes/{_segment(node_id)}", space=space)
173
+
174
+ @staticmethod
175
+ def _search(query, limit, cursor, detail, historical, space) -> _Call:
176
+ return _Call("POST", "/v1/search", body=_drop_none(
177
+ {"query": query, "limit": limit, "cursor": cursor, "detail": detail,
178
+ "historical": historical}), space=space)
179
+
180
+ @staticmethod
181
+ def _recall(domain, tags, limit, cursor, space) -> _Call:
182
+ return _Call("GET", "/v1/episodes", params={
183
+ "domain": domain, "tags": list(tags) if tags else None,
184
+ "limit": limit, "cursor": cursor}, space=space)
185
+
186
+ @staticmethod
187
+ def _ingest(nodes, edges, space) -> _Call:
188
+ return _Call("POST", "/v1/fragments",
189
+ body={"nodes": list(nodes), "edges": list(edges or [])}, space=space)
190
+
191
+
192
+ class VincClient(_Base):
193
+ """Blocking client. Use as a context manager or call ``close()``."""
194
+
195
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None,
196
+ space: str | None = None, timeout: float = 30.0,
197
+ transport: httpx.BaseTransport | None = None) -> None:
198
+ super().__init__(api_key, base_url=base_url, space=space, timeout=timeout)
199
+ self._http = httpx.Client(base_url=self.base_url, headers=self._headers(),
200
+ timeout=timeout, follow_redirects=False, transport=transport)
201
+
202
+ def __enter__(self) -> "VincClient":
203
+ return self
204
+
205
+ def __exit__(self, *exc: object) -> None:
206
+ self.close()
207
+
208
+ def close(self) -> None:
209
+ self._http.close()
210
+
211
+ def _send(self, call: _Call) -> dict[str, Any]:
212
+ try:
213
+ response = self._http.request(**self._request_args(call))
214
+ except httpx.TimeoutException as exc:
215
+ raise VincUnavailable("upstream_unavailable", f"Timed out: {exc}") from None
216
+ except httpx.TransportError as exc:
217
+ raise VincUnavailable("upstream_unavailable",
218
+ f"The API is unreachable: {type(exc).__name__}") from None
219
+ return _parse(response)
220
+
221
+ def whoami(self, *, space: str | None = None) -> dict[str, Any]:
222
+ """``GET /v1/me``: the key's owner, plan and the teams it can reach."""
223
+ return self._send(self._whoami(space))
224
+
225
+ def brief(self, query: str, *, max_excerpts: int | None = None,
226
+ historical: bool | None = None, space: str | None = None) -> dict[str, Any]:
227
+ """``POST /v1/brief``: resolve a question (or a node id) to one node with its material."""
228
+ return self._send(self._brief(query, max_excerpts, historical, space))
229
+
230
+ def node(self, node_id: str, *, space: str | None = None) -> dict[str, Any]:
231
+ """``GET /v1/nodes/{id}``: read one node by id. Raises ``VincNotFound`` if absent."""
232
+ return self._send(self._node(node_id, space))
233
+
234
+ def search(self, query: str, *, limit: int | None = None, cursor: str | None = None,
235
+ detail: str | None = None, historical: bool | None = None,
236
+ space: str | None = None) -> dict[str, Any]:
237
+ """``POST /v1/search``: hybrid text and meaning search, paged by ``cursor``."""
238
+ return self._send(self._search(query, limit, cursor, detail, historical, space))
239
+
240
+ def recall(self, *, domain: str | None = None, tags: Iterable[str] | None = None,
241
+ limit: int | None = None, cursor: str | None = None,
242
+ space: str | None = None) -> dict[str, Any]:
243
+ """``GET /v1/episodes``: the episode timeline, newest first."""
244
+ return self._send(self._recall(domain, tags, limit, cursor, space))
245
+
246
+ def ingest(self, nodes: Iterable[dict[str, Any]], edges: Iterable[dict[str, Any]] | None = None,
247
+ *, space: str | None = None) -> dict[str, Any]:
248
+ """``POST /v1/fragments``: merge nodes and edges by id.
249
+
250
+ ``props`` sent for a node replace its stored props whole; read them first
251
+ and send the merged dict. Raises ``VincPartialWrite`` when part was skipped.
252
+ """
253
+ self._require_write_key()
254
+ return _raise_if_partial(self._send(self._ingest(nodes, edges, space)))
255
+
256
+ def get_context(self, query: str, *, search_fallback: bool = False,
257
+ max_chars: int = DEFAULT_MAX_CHARS, max_excerpts: int = 3,
258
+ min_query_chars: int = 3, space: str | None = None) -> str | None:
259
+ """A fenced context block for ``query``, or ``None``.
260
+
261
+ One call per turn, two with ``search_fallback`` when the brief found
262
+ nothing or several nodes tied (a natural question often ties). When Vinc is unavailable or the quota is spent this logs a
263
+ warning and returns ``None``: the caller's model call goes on without it.
264
+ A key or space problem still raises, because it will not fix itself.
265
+ """
266
+ text = (query or "").strip()
267
+ if len(text) < min_query_chars:
268
+ return None
269
+ try:
270
+ brief = self.brief(text, max_excerpts=max_excerpts, space=space)
271
+ search = (self.search(text, limit=5, space=space)
272
+ if search_fallback and brief.get("status") in _WIDEN else None)
273
+ return format_answer(brief, search, max_chars=max_chars)
274
+ except (VincUnavailable, VincQuotaExceeded) as exc:
275
+ log.warning("vinc context skipped for this turn: %s (retry_after=%s)",
276
+ exc, exc.retry_after)
277
+ return None
278
+
279
+ def record_episode(self, title: str, *, summary: str = "", about: Iterable[str] = (),
280
+ date: str | None = None, domain: str | None = None,
281
+ tags: Iterable[str] | None = None, episode_id: str | None = None,
282
+ space: str | None = None) -> dict[str, Any]:
283
+ """Write one Episode, ``about`` existing concepts or decisions.
284
+
285
+ Call it on purpose, after a person approved what happened; nothing in
286
+ this package calls it for you. Every ``about`` id is read first, so a
287
+ typo raises ``VincNotFound`` instead of creating an empty node.
288
+ """
289
+ self._require_write_key()
290
+ targets = check_about(about)
291
+ for target in targets:
292
+ self.node(target, space=space)
293
+ fragment = build_episode_fragment(title, summary=summary, about=targets, date=date,
294
+ domain=domain, tags=tags, episode_id=episode_id)
295
+ return self.ingest(fragment["nodes"], fragment["edges"], space=space)
296
+
297
+
298
+ class AsyncVincClient(_Base):
299
+ """Async twin of :class:`VincClient`; same methods, awaited."""
300
+
301
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None,
302
+ space: str | None = None, timeout: float = 30.0,
303
+ transport: httpx.AsyncBaseTransport | None = None) -> None:
304
+ super().__init__(api_key, base_url=base_url, space=space, timeout=timeout)
305
+ self._http = httpx.AsyncClient(base_url=self.base_url, headers=self._headers(),
306
+ timeout=timeout, follow_redirects=False,
307
+ transport=transport)
308
+
309
+ async def __aenter__(self) -> "AsyncVincClient":
310
+ return self
311
+
312
+ async def __aexit__(self, *exc: object) -> None:
313
+ await self.aclose()
314
+
315
+ async def aclose(self) -> None:
316
+ await self._http.aclose()
317
+
318
+ async def _send(self, call: _Call) -> dict[str, Any]:
319
+ try:
320
+ response = await self._http.request(**self._request_args(call))
321
+ except httpx.TimeoutException as exc:
322
+ raise VincUnavailable("upstream_unavailable", f"Timed out: {exc}") from None
323
+ except httpx.TransportError as exc:
324
+ raise VincUnavailable("upstream_unavailable",
325
+ f"The API is unreachable: {type(exc).__name__}") from None
326
+ return _parse(response)
327
+
328
+ async def whoami(self, *, space: str | None = None) -> dict[str, Any]:
329
+ return await self._send(self._whoami(space))
330
+
331
+ async def brief(self, query: str, *, max_excerpts: int | None = None,
332
+ historical: bool | None = None, space: str | None = None) -> dict[str, Any]:
333
+ return await self._send(self._brief(query, max_excerpts, historical, space))
334
+
335
+ async def node(self, node_id: str, *, space: str | None = None) -> dict[str, Any]:
336
+ return await self._send(self._node(node_id, space))
337
+
338
+ async def search(self, query: str, *, limit: int | None = None, cursor: str | None = None,
339
+ detail: str | None = None, historical: bool | None = None,
340
+ space: str | None = None) -> dict[str, Any]:
341
+ return await self._send(self._search(query, limit, cursor, detail, historical, space))
342
+
343
+ async def recall(self, *, domain: str | None = None, tags: Iterable[str] | None = None,
344
+ limit: int | None = None, cursor: str | None = None,
345
+ space: str | None = None) -> dict[str, Any]:
346
+ return await self._send(self._recall(domain, tags, limit, cursor, space))
347
+
348
+ async def ingest(self, nodes: Iterable[dict[str, Any]],
349
+ edges: Iterable[dict[str, Any]] | None = None, *,
350
+ space: str | None = None) -> dict[str, Any]:
351
+ self._require_write_key()
352
+ return _raise_if_partial(await self._send(self._ingest(nodes, edges, space)))
353
+
354
+ async def get_context(self, query: str, *, search_fallback: bool = False,
355
+ max_chars: int = DEFAULT_MAX_CHARS, max_excerpts: int = 3,
356
+ min_query_chars: int = 3, space: str | None = None) -> str | None:
357
+ text = (query or "").strip()
358
+ if len(text) < min_query_chars:
359
+ return None
360
+ try:
361
+ brief = await self.brief(text, max_excerpts=max_excerpts, space=space)
362
+ search = (await self.search(text, limit=5, space=space)
363
+ if search_fallback and brief.get("status") in _WIDEN else None)
364
+ return format_answer(brief, search, max_chars=max_chars)
365
+ except (VincUnavailable, VincQuotaExceeded) as exc:
366
+ log.warning("vinc context skipped for this turn: %s (retry_after=%s)",
367
+ exc, exc.retry_after)
368
+ return None
369
+
370
+ async def record_episode(self, title: str, *, summary: str = "", about: Iterable[str] = (),
371
+ date: str | None = None, domain: str | None = None,
372
+ tags: Iterable[str] | None = None, episode_id: str | None = None,
373
+ space: str | None = None) -> dict[str, Any]:
374
+ self._require_write_key()
375
+ targets = check_about(about)
376
+ for target in targets:
377
+ await self.node(target, space=space)
378
+ fragment = build_episode_fragment(title, summary=summary, about=targets, date=date,
379
+ domain=domain, tags=tags, episode_id=episode_id)
380
+ return await self.ingest(fragment["nodes"], fragment["edges"], space=space)
381
+
382
+
383
+ __all__ = ["VincClient", "AsyncVincClient", "VincError", "resolve_key", "resolve_base_url",
384
+ "normalize_space", "DEFAULT_BASE_URL"]