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.
- vinc_client-0.1.0/.gitignore +52 -0
- vinc_client-0.1.0/PKG-INFO +97 -0
- vinc_client-0.1.0/README.md +77 -0
- vinc_client-0.1.0/pyproject.toml +33 -0
- vinc_client-0.1.0/src/vinc_client/__init__.py +18 -0
- vinc_client-0.1.0/src/vinc_client/client.py +384 -0
- vinc_client-0.1.0/src/vinc_client/context.py +150 -0
- vinc_client-0.1.0/src/vinc_client/episode.py +65 -0
- vinc_client-0.1.0/src/vinc_client/errors.py +77 -0
- vinc_client-0.1.0/src/vinc_client/py.typed +0 -0
- vinc_client-0.1.0/tests/conftest.py +62 -0
- vinc_client-0.1.0/tests/test_client.py +275 -0
- vinc_client-0.1.0/tests/test_context_and_episode.py +102 -0
- vinc_client-0.1.0/tests/test_contract.py +55 -0
|
@@ -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"]
|