vincs 0.1.0__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.
- vinc/__init__.py +8 -0
- vinc/__main__.py +3 -0
- vinc/_runtime/api/__init__.py +16 -0
- vinc/_runtime/api/activate.py +226 -0
- vinc/_runtime/api/auth.py +119 -0
- vinc/_runtime/api/backup.py +50 -0
- vinc/_runtime/api/changes.py +127 -0
- vinc/_runtime/api/cli.py +1035 -0
- vinc/_runtime/api/cli_account.py +30 -0
- vinc/_runtime/api/cli_output.py +177 -0
- vinc/_runtime/api/cli_target.py +48 -0
- vinc/_runtime/api/cli_tools.py +230 -0
- vinc/_runtime/api/cli_transport.py +122 -0
- vinc/_runtime/api/docstore.py +2350 -0
- vinc/_runtime/api/docsync.py +163 -0
- vinc/_runtime/api/export.py +217 -0
- vinc/_runtime/api/fragment.py +1299 -0
- vinc/_runtime/api/graph_algo.py +417 -0
- vinc/_runtime/api/graphmd.py +620 -0
- vinc/_runtime/api/jobs.py +238 -0
- vinc/_runtime/api/localmode.py +168 -0
- vinc/_runtime/api/mirror_provenance.py +147 -0
- vinc/_runtime/api/mirror_status.py +46 -0
- vinc/_runtime/api/node_mirror.py +187 -0
- vinc/_runtime/api/owner_fold.py +160 -0
- vinc/_runtime/api/package_contract.py +50 -0
- vinc/_runtime/api/packfile.py +127 -0
- vinc/_runtime/api/preference.py +137 -0
- vinc/_runtime/api/procutil.py +18 -0
- vinc/_runtime/api/query.py +7016 -0
- vinc/_runtime/api/refs.py +249 -0
- vinc/_runtime/api/remote_client.py +169 -0
- vinc/_runtime/api/revstamp.py +141 -0
- vinc/_runtime/api/role_contract.py +132 -0
- vinc/_runtime/api/role_gate_contract.py +57 -0
- vinc/_runtime/api/roles.py +64 -0
- vinc/_runtime/api/server.py +2627 -0
- vinc/_runtime/api/share.py +566 -0
- vinc/_runtime/api/spaceproxy.py +159 -0
- vinc/_runtime/api/sync.py +1324 -0
- vinc/_runtime/api/syncmerge.py +110 -0
- vinc/_runtime/api/textextract.py +167 -0
- vinc/_runtime/engines/__init__.py +0 -0
- vinc/_runtime/engines/body_candidates.py +94 -0
- vinc/_runtime/engines/domain_hierarchy.py +297 -0
- vinc/_runtime/engines/embed_pipeline.py +419 -0
- vinc/_runtime/engines/fragment_ingest.py +246 -0
- vinc/_runtime/engines/mentions_link.py +401 -0
- vinc/_runtime/engines/recall_ingest.py +429 -0
- vinc/_runtime/store/__init__.py +14 -0
- vinc/_runtime/store/backend.py +58 -0
- vinc/_runtime/store/chunking.py +162 -0
- vinc/_runtime/store/config.py +228 -0
- vinc/_runtime/store/diagnostics.py +36 -0
- vinc/_runtime/store/durable_generation.py +326 -0
- vinc/_runtime/store/embedded_backend.py +2435 -0
- vinc/_runtime/store/embedded_schema.py +685 -0
- vinc/_runtime/store/encoder_runtime.py +20 -0
- vinc/_runtime/store/lexical.py +102 -0
- vinc/_runtime/store/migrate.py +522 -0
- vinc/_runtime/store/migrate_themes.py +136 -0
- vinc/_runtime/store/open_diagnostics.py +5 -0
- vinc/_runtime/store/persistence.py +637 -0
- vinc/_runtime/store/pkns.py +192 -0
- vinc/_runtime/store/procutil.py +68 -0
- vinc/_runtime/store/provision.py +83 -0
- vinc/_runtime/store/querybase.py +495 -0
- vinc/_runtime/store/request_timing.py +130 -0
- vinc/_runtime/store/theme_store.py +288 -0
- vinc/_runtime/store/wal_guard.py +115 -0
- vinc/_runtime/vinc_mcp/__init__.py +22 -0
- vinc/_runtime/vinc_mcp/bridge.py +290 -0
- vinc/_runtime/vinc_mcp/edverify.py +222 -0
- vinc/_runtime/vinc_mcp/entitlement.py +359 -0
- vinc/_runtime/vinc_mcp/http_server.py +1655 -0
- vinc/_runtime/vinc_mcp/install.py +292 -0
- vinc/_runtime/vinc_mcp/keychain.py +88 -0
- vinc/_runtime/vinc_mcp/oauth.py +323 -0
- vinc/_runtime/vinc_mcp/openapi_v1.json +2227 -0
- vinc/_runtime/vinc_mcp/openapi_v1.py +77 -0
- vinc/_runtime/vinc_mcp/proxy.py +205 -0
- vinc/_runtime/vinc_mcp/remote.py +70 -0
- vinc/_runtime/vinc_mcp/rest_api.py +330 -0
- vinc/_runtime/vinc_mcp/rest_routes.py +114 -0
- vinc/_runtime/vinc_mcp/role_output_gate.py +357 -0
- vinc/_runtime/vinc_mcp/roles.py +547 -0
- vinc/_runtime/vinc_mcp/rpc.py +291 -0
- vinc/_runtime/vinc_mcp/stdio.py +36 -0
- vinc/_runtime/vinc_mcp/store.py +579 -0
- vinc/_runtime/vinc_mcp/syncclient.py +108 -0
- vinc/_runtime/vinc_mcp/toolcall.py +79 -0
- vinc/_runtime/vinc_mcp/tools.py +2306 -0
- vinc/_runtime/vinc_mcp/viewurl.py +139 -0
- vinc/_runtime/vinc_rag/__init__.py +6 -0
- vinc/_runtime/vinc_rag/embed/__init__.py +5 -0
- vinc/_runtime/vinc_rag/embed/encoder.py +254 -0
- vinc/_runtime/vinc_rag/embed/worker.py +987 -0
- vinc/_runtime/vinc_rag/rerank.py +397 -0
- vinc/_runtime/vinc_rag/retrieval.py +1429 -0
- vinc/_runtime/vinc_rag/search_relevance.py +83 -0
- vinc/cli.py +22 -0
- vinc/integrations/__init__.py +1 -0
- vinc/integrations/agent_framework/__init__.py +16 -0
- vinc/integrations/agent_framework/approval.py +39 -0
- vinc/integrations/agent_framework/output_gate.py +39 -0
- vinc/integrations/agent_framework/provider.py +196 -0
- vinc/integrations/agent_framework/py.typed +0 -0
- vinc/integrations/agent_framework/review.py +42 -0
- vinc/integrations/langgraph/__init__.py +41 -0
- vinc/integrations/langgraph/approval.py +29 -0
- vinc/integrations/langgraph/helpers.py +199 -0
- vinc/integrations/langgraph/middleware.py +202 -0
- vinc/integrations/langgraph/output_gate.py +71 -0
- vinc/integrations/langgraph/py.typed +0 -0
- vinc/integrations/langgraph/review.py +64 -0
- vinc/sdk/__init__.py +31 -0
- vinc/sdk/approval.py +118 -0
- vinc/sdk/client.py +629 -0
- vinc/sdk/context.py +211 -0
- vinc/sdk/episode.py +65 -0
- vinc/sdk/errors.py +77 -0
- vinc/sdk/handoff.py +34 -0
- vinc/sdk/output_gate.py +357 -0
- vinc/sdk/package.py +120 -0
- vinc/sdk/prefetch.py +91 -0
- vinc/sdk/py.typed +0 -0
- vinc/sdk/review.py +115 -0
- vinc/sdk/tools.py +74 -0
- vincs-0.1.0.dist-info/METADATA +79 -0
- vincs-0.1.0.dist-info/RECORD +132 -0
- vincs-0.1.0.dist-info/WHEEL +4 -0
- vincs-0.1.0.dist-info/entry_points.txt +2 -0
vinc/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Vinc SDK. Framework dependencies are loaded only by their integration."""
|
|
2
|
+
from .sdk import * # noqa: F403
|
|
3
|
+
from .sdk import AsyncVincClient, VincClient, __all__ as _sdk_exports
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
Vinc = VincClient
|
|
7
|
+
AsyncVinc = AsyncVincClient
|
|
8
|
+
__all__ = [*_sdk_exports, "Vinc", "AsyncVinc"]
|
vinc/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Query API — read-only entry points over the memory store.
|
|
3
|
+
|
|
4
|
+
Three entry points sit on top of the ingestion engines:
|
|
5
|
+
recall : episodic memory retrieval (Episode nodes, time-ordered)
|
|
6
|
+
search : hybrid vector + BM25 retrieval over Chunk nodes
|
|
7
|
+
decide : decision support — graph traversal over policy/concept relations
|
|
8
|
+
|
|
9
|
+
Each entry point builds a deterministic *query plan* (testable without a
|
|
10
|
+
database) and, when a Neo4j driver is available, executes it.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .query import QueryPlan, decide, recall, search
|
|
15
|
+
|
|
16
|
+
__all__ = ["QueryPlan", "recall", "search", "decide"]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Activate — make a slice of owned memory *portable* to an external AI.
|
|
3
|
+
|
|
4
|
+
P3 of the memory IA ("내 AI로 이식"). Given recall rows (owner-scoped, optionally
|
|
5
|
+
preference-ranked), build a **context pack** in two portable shapes:
|
|
6
|
+
|
|
7
|
+
- a Markdown brief (``pack.md``) a human or any LLM can read directly;
|
|
8
|
+
- a structured JSON (``pack.json``) for programmatic ingestion;
|
|
9
|
+
|
|
10
|
+
plus an **MCP resource manifest** (``mcp.json``) — the connector contract an MCP
|
|
11
|
+
server (mounted by weg-9000-orchestration) serves so a foreign AI can pull these
|
|
12
|
+
episodes as ``memory://{owner}/{id}`` resources.
|
|
13
|
+
|
|
14
|
+
Builders are pure (no I/O, no wall clock — ``generated_at`` is injected) so the
|
|
15
|
+
pack is deterministic and testable. ``activate`` orchestrates recall → pack and
|
|
16
|
+
optionally writes the three files.
|
|
17
|
+
|
|
18
|
+
python -m api.cli activate --owner user:weg1456 --rank preference --out ./pack
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import query as q
|
|
27
|
+
|
|
28
|
+
MCP_SCHEMA_VERSION = "2024-11-05"
|
|
29
|
+
_URI_SCHEME = "memory"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _uri(owner: str, episode_id: str) -> str:
|
|
33
|
+
return f"{_URI_SCHEME}://{owner}/{episode_id}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ContextPack:
|
|
38
|
+
owner: str
|
|
39
|
+
query: str
|
|
40
|
+
generated_at: str | None
|
|
41
|
+
episodes: list[dict]
|
|
42
|
+
learner_model: list[dict] = field(default_factory=list) # how this person learns (U-patterns)
|
|
43
|
+
theme_id: str | None = None
|
|
44
|
+
# 주제 어휘(선택) — **authored 만** 실린다. proposed(재가 전 잠정)는 팩·내보내기에
|
|
45
|
+
# 하중을 지지 않는다(THESIS §6 잠정상태 · D11-R2). activate() 가 필터해 넣어준다.
|
|
46
|
+
vocabulary: list[dict] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
# ── JSON shape (programmatic ingestion) ──────────────────────────────────
|
|
49
|
+
def to_json(self) -> dict:
|
|
50
|
+
out = {
|
|
51
|
+
"kind": "weg-9000-memory/context-pack",
|
|
52
|
+
"owner": self.owner,
|
|
53
|
+
"query": self.query,
|
|
54
|
+
"generated_at": self.generated_at,
|
|
55
|
+
"episode_count": len(self.episodes),
|
|
56
|
+
"episodes": self.episodes,
|
|
57
|
+
"learner_model": self.learner_model,
|
|
58
|
+
}
|
|
59
|
+
if self.theme_id is not None:
|
|
60
|
+
out["theme"] = {"theme_id": self.theme_id, "vocabulary": self.vocabulary}
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
def _learner_section(self) -> list[str]:
|
|
64
|
+
if not self.learner_model:
|
|
65
|
+
return []
|
|
66
|
+
out = [
|
|
67
|
+
"",
|
|
68
|
+
f"## 학습법 — how {self.owner} learns",
|
|
69
|
+
"",
|
|
70
|
+
"> 이 개인의 학습 방식(인지 지문). 외부 AI는 이 방식에 맞춰 나를 도와야 한다.",
|
|
71
|
+
"",
|
|
72
|
+
]
|
|
73
|
+
for p in self.learner_model:
|
|
74
|
+
meta = []
|
|
75
|
+
if p.get("domain"):
|
|
76
|
+
meta.append(f"domain: {p['domain']}")
|
|
77
|
+
if p.get("observations") is not None:
|
|
78
|
+
meta.append(f"n={p['observations']}")
|
|
79
|
+
head = f"- **{p.get('code', '')} {p.get('name', '')}**".rstrip()
|
|
80
|
+
if meta:
|
|
81
|
+
head += f" ({' · '.join(meta)})"
|
|
82
|
+
out.append(head)
|
|
83
|
+
desc = (p.get("description") or "").strip()
|
|
84
|
+
if desc:
|
|
85
|
+
out.append(f" - {desc}")
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
# ── Markdown brief (human / LLM-readable) ────────────────────────────────
|
|
89
|
+
def to_markdown(self) -> str:
|
|
90
|
+
parts = [
|
|
91
|
+
f"# Memory Context Pack — {self.owner}",
|
|
92
|
+
"",
|
|
93
|
+
f"> query: \"{self.query}\" · generated: {self.generated_at or 'n/a'} "
|
|
94
|
+
f"· {len(self.episodes)} episode(s) · {len(self.learner_model)} learning-pattern(s)",
|
|
95
|
+
"> Portable memory brief for ingestion into an external AI. "
|
|
96
|
+
"Source: weg-9000-memory.",
|
|
97
|
+
"",
|
|
98
|
+
]
|
|
99
|
+
if not self.episodes:
|
|
100
|
+
parts.append("_(no episodes in scope)_")
|
|
101
|
+
for ep in self.episodes:
|
|
102
|
+
pin = " 📌" if ep.get("pinned") else ""
|
|
103
|
+
tags = ep.get("tags") or []
|
|
104
|
+
meta = [f"date: {ep.get('date', 'n/a')}"]
|
|
105
|
+
if ep.get("domain"):
|
|
106
|
+
meta.append(f"domain: {ep['domain']}")
|
|
107
|
+
if tags:
|
|
108
|
+
meta.append("tags: " + ", ".join(tags))
|
|
109
|
+
if ep.get("score") is not None:
|
|
110
|
+
meta.append(f"score: {ep['score']}")
|
|
111
|
+
parts.append(f"## {ep.get('title') or ep.get('id', 'untitled')}{pin}")
|
|
112
|
+
parts.append("- " + " · ".join(meta))
|
|
113
|
+
summary = (ep.get("summary") or "").strip()
|
|
114
|
+
if summary:
|
|
115
|
+
parts += ["", summary]
|
|
116
|
+
parts += ["", "---", ""]
|
|
117
|
+
parts += self._learner_section()
|
|
118
|
+
return "\n".join(parts) + "\n"
|
|
119
|
+
|
|
120
|
+
# ── MCP resource manifest (connector contract) ───────────────────────────
|
|
121
|
+
def to_mcp_manifest(self) -> dict:
|
|
122
|
+
resources = [
|
|
123
|
+
{
|
|
124
|
+
"uri": _uri(self.owner, ep.get("id", "")),
|
|
125
|
+
"name": ep.get("title") or ep.get("id", ""),
|
|
126
|
+
"mimeType": "text/markdown",
|
|
127
|
+
"description": (ep.get("summary") or "")[:120],
|
|
128
|
+
}
|
|
129
|
+
for ep in self.episodes
|
|
130
|
+
]
|
|
131
|
+
if self.learner_model:
|
|
132
|
+
resources.append({
|
|
133
|
+
"uri": _uri(self.owner, "_learner"),
|
|
134
|
+
"name": f"learning method ({self.owner})",
|
|
135
|
+
"mimeType": "text/markdown",
|
|
136
|
+
"description": f"{len(self.learner_model)} learning pattern(s) — how this person learns",
|
|
137
|
+
})
|
|
138
|
+
return {
|
|
139
|
+
"schemaVersion": MCP_SCHEMA_VERSION,
|
|
140
|
+
"owner": self.owner,
|
|
141
|
+
"generated_at": self.generated_at,
|
|
142
|
+
"resources": resources,
|
|
143
|
+
"pack": {
|
|
144
|
+
"uri": _uri(self.owner, "_pack"),
|
|
145
|
+
"name": f"context pack ({self.owner})",
|
|
146
|
+
"mimeType": "application/json",
|
|
147
|
+
"description": f"{len(self.episodes)} episode(s), query=\"{self.query}\"",
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_context_pack(rows: list[dict], *, owner: str, query: str = "",
|
|
153
|
+
generated_at: str | None = None,
|
|
154
|
+
learner_model: list[dict] | None = None,
|
|
155
|
+
theme_id: str | None = None,
|
|
156
|
+
vocabulary: list[dict] | None = None) -> ContextPack:
|
|
157
|
+
"""Assemble a ContextPack from recall rows (pure). ``learner_model`` is the
|
|
158
|
+
person's LearnerPattern rows (how they learn) — carried so an export is "my
|
|
159
|
+
learning *and how I learn it*". ``vocabulary`` (authored 만) 은 주제 타입 어휘를
|
|
160
|
+
함께 이전한다 — 어휘는 소유·이전 단위(THESIS T4·D11-R2 §8)."""
|
|
161
|
+
episodes = [dict(r) for r in rows]
|
|
162
|
+
return ContextPack(owner=owner, query=query, generated_at=generated_at,
|
|
163
|
+
episodes=episodes, learner_model=[dict(p) for p in (learner_model or [])],
|
|
164
|
+
theme_id=theme_id, vocabulary=[dict(t) for t in (vocabulary or [])])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def write_pack(pack: ContextPack, out_dir: str | Path) -> dict:
|
|
168
|
+
"""Write pack.md / pack.json / mcp.json into ``out_dir``. Returns the paths."""
|
|
169
|
+
out = Path(out_dir)
|
|
170
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
md_path = out / "pack.md"
|
|
172
|
+
json_path = out / "pack.json"
|
|
173
|
+
mcp_path = out / "mcp.json"
|
|
174
|
+
md_path.write_text(pack.to_markdown(), encoding="utf-8")
|
|
175
|
+
json_path.write_text(json.dumps(pack.to_json(), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
176
|
+
mcp_path.write_text(json.dumps(pack.to_mcp_manifest(), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
177
|
+
return {"markdown": str(md_path), "json": str(json_path), "mcp": str(mcp_path)}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── Public entry point ───────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def activate(query: str = "", *, owner: str, tenant: str | None = None,
|
|
183
|
+
domain: str | None = None, workspace: str | None = None,
|
|
184
|
+
tags: list[str] | None = None, rank: str = "preference", limit: int = 20,
|
|
185
|
+
include_model: bool = False,
|
|
186
|
+
generated_at: str | None = None, out_dir: str | Path | None = None,
|
|
187
|
+
theme_id: str | None = None, themes=None,
|
|
188
|
+
cfg=None, store=None) -> dict:
|
|
189
|
+
"""Recall owner-scoped records and build a portable context pack.
|
|
190
|
+
|
|
191
|
+
``domain`` (free, user-defined) optionally scopes the pack to one dataset
|
|
192
|
+
partition; omit to export across all of the owner's domains. Defaults to
|
|
193
|
+
``rank="preference"`` — an export leads with the owner's preference signals.
|
|
194
|
+
|
|
195
|
+
``include_model`` (**default False** — Vinc 기본 export 은 learner 섹션 부재:
|
|
196
|
+
설계 §13 D6 · WO §3 F6, 학습 프레이밍 미승계 §1). 마이그레이션(A+2, §10)의
|
|
197
|
+
무손실 이관 시에만 ``True`` 로 learner 신호(LearnerPattern)를 포함한다 — 이때만
|
|
198
|
+
pack 에 학습법 섹션이 실린다(D4 는 pack 이 신호를 담을 수 **있게** 하는 것이지
|
|
199
|
+
기본 노출이 아니다). Preference 신호(pinned/tags/access_count/last_access)는
|
|
200
|
+
rank 무관 항상 수록된다(§10·D4). Writes three files when ``out_dir`` given."""
|
|
201
|
+
res = q.recall(query, owner=owner, tenant=tenant, domain=domain,
|
|
202
|
+
workspace=workspace, tags=tags, rank=rank, limit=limit, cfg=cfg, store=store)
|
|
203
|
+
model_rows = (q.learner_model(owner=owner, cfg=cfg, store=store).get("rows", [])
|
|
204
|
+
if include_model else [])
|
|
205
|
+
# 주제 어휘 동봉(선택) — **authored 만**. proposed(재가 전)는 팩에 실리지 않는다
|
|
206
|
+
# (THESIS §6 잠정상태 · D11-R2 수용기준). 단일 필터 라인.
|
|
207
|
+
vocabulary = ([t for t in themes.list_types(theme_id) if t.get("status") == "authored"]
|
|
208
|
+
if (theme_id and themes is not None) else None)
|
|
209
|
+
pack = build_context_pack(res.get("rows", []), owner=owner, query=query,
|
|
210
|
+
generated_at=generated_at, learner_model=model_rows,
|
|
211
|
+
theme_id=theme_id, vocabulary=vocabulary)
|
|
212
|
+
out: dict = {
|
|
213
|
+
"owner": owner,
|
|
214
|
+
"episode_count": len(pack.episodes),
|
|
215
|
+
"learner_pattern_count": len(pack.learner_model),
|
|
216
|
+
"executed": res.get("executed", False),
|
|
217
|
+
}
|
|
218
|
+
if out_dir is not None:
|
|
219
|
+
out["written"] = write_pack(pack, out_dir)
|
|
220
|
+
else:
|
|
221
|
+
out["pack"] = {
|
|
222
|
+
"markdown": pack.to_markdown(),
|
|
223
|
+
"json": pack.to_json(),
|
|
224
|
+
"mcp": pack.to_mcp_manifest(),
|
|
225
|
+
}
|
|
226
|
+
return out
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""로컬 로그인 코어 — 온보딩 프로필 + 세션 토큰(설계 [[vinc.spec.architecture §4]], F4).
|
|
3
|
+
|
|
4
|
+
Vinc 는 단일 사용자·오프라인 앱이다. "로그인"은 외부 인증 서버 왕복이 아니라
|
|
5
|
+
**로컬 자격 파일에 대한 패스프레이즈 검증**이다(F4 — 네트워크 왕복 없음). 검증에
|
|
6
|
+
성공하면 사이드카가 임의 **세션 토큰**을 발급하고, 데스크톱 셸(Tauri)이 이후 모든
|
|
7
|
+
HTTP 요청에 ``Authorization: Bearer <token>`` 로 실어 보낸다. 루프백 바인딩
|
|
8
|
+
(127.0.0.1) + 토큰이 이중 방어다(§3-1).
|
|
9
|
+
|
|
10
|
+
자격 해시는 **argon2id**(설계 §3-1 명시)로, ``argon2-cffi`` 옵션 extra(``vinc[server]``)
|
|
11
|
+
에서 lazy-import 한다 — 코어는 의존성 0(F5: 키 없이 기동). 미설치 환경에선 온보딩·
|
|
12
|
+
패스프레이즈 검증만 불가하고(명확한 오류) 앱 기동·조회는 그대로 동작한다.
|
|
13
|
+
|
|
14
|
+
토큰 발급·검증은 stdlib ``secrets`` 만 쓴다(argon2 불요) — 어떤 경로도 소켓을 열지
|
|
15
|
+
않는다(F4 실측: 소켓 차단 하에서도 동작).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import secrets
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# argon2id 파라미터(자격 파일 헤더에 함께 저장 — 재해시 없이 검증 가능).
|
|
24
|
+
# 대화형 로그인 1회 검증 기준의 보수적 기본값. (RFC 9106 2nd 권장 근방.)
|
|
25
|
+
_ARGON2_TIME_COST = 3
|
|
26
|
+
_ARGON2_MEMORY_COST = 64 * 1024 # 64 MiB
|
|
27
|
+
_ARGON2_PARALLELISM = 4
|
|
28
|
+
|
|
29
|
+
_CRED_KIND = "vinc/local-credential"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthUnavailable(RuntimeError):
|
|
33
|
+
"""argon2 extra 미설치 — 온보딩/패스프레이즈 검증 불가(``pip install vinc[server]``)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _argon2():
|
|
37
|
+
"""argon2 PasswordHasher 를 lazy-import(코어 의존성 0 유지 — F5). 미설치 시
|
|
38
|
+
:class:`AuthUnavailable`."""
|
|
39
|
+
try:
|
|
40
|
+
from argon2 import PasswordHasher, Type
|
|
41
|
+
except ImportError as exc: # pragma: no cover - 설치 여부에 따라
|
|
42
|
+
raise AuthUnavailable(
|
|
43
|
+
"argon2id 자격에는 'vinc[server]' extra 가 필요하다 "
|
|
44
|
+
"(pip install argon2-cffi). 코어·조회는 이것 없이 동작한다."
|
|
45
|
+
) from exc
|
|
46
|
+
return PasswordHasher(time_cost=_ARGON2_TIME_COST, memory_cost=_ARGON2_MEMORY_COST,
|
|
47
|
+
parallelism=_ARGON2_PARALLELISM, type=Type.ID)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cred_path(data_dir: str | Path) -> Path:
|
|
51
|
+
"""자격 파일 경로. 데이터 디렉토리 옆 ``auth.json``(§3-2 — 데이터·설정 동거)."""
|
|
52
|
+
return Path(data_dir).parent / "auth.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── 온보딩 / 검증 ────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def create_profile(passphrase: str, *, path: str | Path, force: bool = False) -> dict:
|
|
58
|
+
"""온보딩: 패스프레이즈를 argon2id 로 해시해 자격 파일을 만든다(1회). 이미
|
|
59
|
+
있으면 ``force`` 없이 덮어쓰지 않는다(계정 유실 방지)."""
|
|
60
|
+
p = Path(path)
|
|
61
|
+
if p.exists() and not force:
|
|
62
|
+
raise FileExistsError(f"자격 파일 이미 존재: {p} (재설정은 force=True)")
|
|
63
|
+
if not passphrase:
|
|
64
|
+
raise ValueError("빈 패스프레이즈 금지")
|
|
65
|
+
ph = _argon2()
|
|
66
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
payload = {"kind": _CRED_KIND, "algo": "argon2id", "hash": ph.hash(passphrase)}
|
|
68
|
+
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
69
|
+
# 자격 파일은 소유자만 읽도록(단일 사용자 데스크톱 — §3-3).
|
|
70
|
+
try:
|
|
71
|
+
p.chmod(0o600)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
return {"created": str(p), "algo": "argon2id"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def verify_passphrase(passphrase: str, *, path: str | Path) -> bool:
|
|
78
|
+
"""패스프레이즈를 자격 파일과 대조(로컬만 — 네트워크 왕복 없음, F4). 파일
|
|
79
|
+
부재·불일치 시 False. argon2 미설치 시 :class:`AuthUnavailable`."""
|
|
80
|
+
p = Path(path)
|
|
81
|
+
if not p.exists():
|
|
82
|
+
return False
|
|
83
|
+
from argon2.exceptions import VerifyMismatchError # lazy — extra 없으면 아래 _argon2 가 먼저 raise
|
|
84
|
+
ph = _argon2()
|
|
85
|
+
try:
|
|
86
|
+
stored = json.loads(p.read_text(encoding="utf-8")).get("hash", "")
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return False
|
|
89
|
+
try:
|
|
90
|
+
return bool(ph.verify(stored, passphrase))
|
|
91
|
+
except VerifyMismatchError:
|
|
92
|
+
return False
|
|
93
|
+
except Exception: # noqa: BLE001 - 손상 해시 등은 인증 실패로 취급
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── 세션 토큰 ────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def issue_token(nbytes: int = 32) -> str:
|
|
100
|
+
"""새 세션 토큰 발급(stdlib secrets — 소켓 없음, F4). 프로세스 수명 동안만 유효."""
|
|
101
|
+
return secrets.token_urlsafe(nbytes)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SessionAuth:
|
|
105
|
+
"""사이드카 세션 토큰 보관·검증. 기동 시 1개 발급, 셸에 전달. 모든 요청의
|
|
106
|
+
``Authorization: Bearer`` 헤더를 상수시간 비교한다."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, token: str | None = None):
|
|
109
|
+
self.token = token or issue_token()
|
|
110
|
+
|
|
111
|
+
def check_header(self, header_value: str | None) -> bool:
|
|
112
|
+
"""``Authorization`` 헤더 값이 세션 토큰과 일치하는지(Bearer 스킴). 상수시간
|
|
113
|
+
비교(secrets.compare_digest)로 타이밍 누출 방지."""
|
|
114
|
+
if not header_value:
|
|
115
|
+
return False
|
|
116
|
+
parts = header_value.split(None, 1)
|
|
117
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
118
|
+
return False
|
|
119
|
+
return secrets.compare_digest(parts[1], self.token)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""일관 스냅샷 백업(설계 [[vinc.spec.architecture §5]], R9).
|
|
3
|
+
|
|
4
|
+
``vinc backup`` 은 (1) 스토어 데이터 디렉토리의 일관 스냅샷 + (2) ``packs/``(내보낸
|
|
5
|
+
컨텍스트 팩)·``profile.yml``(도메인 프로필) 을 한 아카이브 디렉토리에 모은다 —
|
|
6
|
+
사용자가 자기 데이터를 통째로 이관·보존할 수 있는 단일 통로(R9).
|
|
7
|
+
|
|
8
|
+
**쓰기 정지의 책임**: ``EmbeddedBackend.snapshot`` 은 데이터 디렉토리를 그대로
|
|
9
|
+
복사할 뿐 쓰기를 멈추지 않는다. 일관성은 **잡 큐가 보장**한다 — backup 은 잡으로
|
|
10
|
+
실행되고, 워커는 하나뿐이라 backup 이 도는 동안 다른 쓰기 잡을 처리하지 않는다
|
|
11
|
+
(설계 §11 P2, `store.embedded_backend.snapshot` docstring). 그래서 이 함수는 반드시
|
|
12
|
+
워커 스레드에서(=JobQueue 경유) 호출되어야 한다.
|
|
13
|
+
|
|
14
|
+
stdlib 전용(``shutil``·``pathlib``).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_backup(store, out_dir: str | Path, *, packs_dir: str | Path | None = None,
|
|
23
|
+
profile_path: str | Path | None = None) -> dict:
|
|
24
|
+
"""스토어 스냅샷 + packs·profile 을 ``out_dir`` 아래로 모은다. 반환은 포함 항목
|
|
25
|
+
경로 맵. **워커 스레드에서 호출**(쓰기 정지 보장 — 위 모듈 docstring)."""
|
|
26
|
+
out = Path(out_dir)
|
|
27
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
# (1) 스토어 데이터 디렉토리 일관 스냅샷(§3-1-1). 쓰기 정지는 잡 큐가 보장.
|
|
30
|
+
store_dest = store.snapshot(str(out))
|
|
31
|
+
|
|
32
|
+
included: dict[str, str] = {"store": store_dest}
|
|
33
|
+
|
|
34
|
+
# (2) 내보낸 컨텍스트 팩 동봉(있으면).
|
|
35
|
+
if packs_dir is not None:
|
|
36
|
+
src = Path(packs_dir)
|
|
37
|
+
if src.is_dir():
|
|
38
|
+
dest = out / "packs"
|
|
39
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
40
|
+
included["packs"] = str(dest)
|
|
41
|
+
|
|
42
|
+
# (3) 도메인 프로필 동봉(있으면).
|
|
43
|
+
if profile_path is not None:
|
|
44
|
+
src = Path(profile_path)
|
|
45
|
+
if src.is_file():
|
|
46
|
+
dest = out / src.name
|
|
47
|
+
shutil.copy2(src, dest)
|
|
48
|
+
included["profile"] = str(dest)
|
|
49
|
+
|
|
50
|
+
return {"archive": str(out), "included": included}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""변경 표식 — 열린 화면이 "그 뒤로 이 그래프에 쓰기가 있었나"를 싸게 묻는 값(VIN-129).
|
|
3
|
+
|
|
4
|
+
화면은 이 값을 몇 초마다 읽고, 지난번과 다르면 지금 보던 것을 다시 부른다. 그래서
|
|
5
|
+
MCP 가 쓴 것이 갱신 버튼 없이 열린 View 에 나타난다(웹과 데스크톱 계정 모드 둘 다).
|
|
6
|
+
|
|
7
|
+
**세는 곳은 사이드카의 쓰기 관문이다**(`api.server` 의 POST 처리). 이 값은 데이터가
|
|
8
|
+
아니라 "쓰기 요청이 성공했다"를 센다:
|
|
9
|
+
|
|
10
|
+
- 소유자별이다. 허브는 모든 사람의 그래프를 한 프로세스에서 돌리므로, 전역 번호면
|
|
11
|
+
누가 쓰든 모든 열린 화면이 다시 그려진다.
|
|
12
|
+
- 메모리에만 있다. 읽기가 스토어를 전혀 안 건드리므로 폴링이 허브의 단일 writer 에
|
|
13
|
+
짐을 얹지 않는다. 값 앞에 프로세스마다 새로 뽑는 세대(epoch)를 붙여, 재시작하면
|
|
14
|
+
표식이 바뀐다. 재시작은 쓰기를 되감을 수 있으므로(스냅샷 창) 그때 다시 읽는 것이 맞다.
|
|
15
|
+
- 비교만 한다. 값의 크기나 차이에는 뜻이 없다. 같으면 그대로, 다르면 다시 읽는다.
|
|
16
|
+
|
|
17
|
+
⚠ 여기서 멀티 레플리카를 가정하지 않는다. 임베디드 스토어는 단일 writer 라 쓰기를
|
|
18
|
+
받는 프로세스가 하나뿐이고, 표식도 그 프로세스 하나가 센다.
|
|
19
|
+
근거: [[record:the-change-marker-is-counted-at-the-sidecar-write-gate-2026-09-24]]
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import secrets
|
|
24
|
+
import threading
|
|
25
|
+
from typing import Callable
|
|
26
|
+
|
|
27
|
+
#: 소유자 없이 들어온 쓰기의 칸. 어느 소유자의 표식에도 더해진다: 누구의 것인지
|
|
28
|
+
#: 모르는 쓰기를 한 사람에게만 알리면 나머지가 놓친다(더 자주 다시 읽는 편이 안전측).
|
|
29
|
+
ANY = "*"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ChangeMarker:
|
|
33
|
+
"""소유자별 쓰기 세대. 스레드 안전(HTTP 핸들러 스레드와 잡 워커가 같이 쓴다)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._epoch = secrets.token_hex(4)
|
|
37
|
+
self._n: dict[str, int] = {}
|
|
38
|
+
self._pending: list = [] # (owner, Job, changed) — 끝나면 센다
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
def bump(self, owner: str | None) -> None:
|
|
42
|
+
key = owner or ANY
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._n[key] = self._n.get(key, 0) + 1
|
|
45
|
+
|
|
46
|
+
def bump_when_done(self, owner: str | None, job,
|
|
47
|
+
changed: Callable[[dict], bool] | None = None) -> None:
|
|
48
|
+
"""비동기 잡(202)은 **끝난 뒤에** 센다. 접수 때 세면 화면이 데이터가 들어오기
|
|
49
|
+
전에 다시 읽고, 그 뒤로 표식이 안 바뀌어 결과가 영영 안 나타난다.
|
|
50
|
+
|
|
51
|
+
``changed`` 는 성공한 결과를 보고 셀지 정한다. 주기적으로 도는 잡(동기화)이
|
|
52
|
+
아무것도 안 받았을 때까지 세면, 열린 화면이 10분마다 까닭 없이 다시 그려진다.
|
|
53
|
+
|
|
54
|
+
스레드를 따로 두지 않는다. 표식을 읽을 때 끝난 잡을 걷어 센다(`read`)."""
|
|
55
|
+
if job is None:
|
|
56
|
+
return
|
|
57
|
+
with self._lock:
|
|
58
|
+
self._pending.append((owner or ANY, job, changed))
|
|
59
|
+
|
|
60
|
+
def _settle(self) -> None:
|
|
61
|
+
"""끝난 잡을 센다. 호출부가 락을 쥔 상태여야 한다."""
|
|
62
|
+
if not self._pending:
|
|
63
|
+
return
|
|
64
|
+
keep = []
|
|
65
|
+
for owner, job, changed in self._pending:
|
|
66
|
+
done = getattr(job, "_done", None)
|
|
67
|
+
if done is None or not done.is_set():
|
|
68
|
+
keep.append((owner, job, changed))
|
|
69
|
+
continue
|
|
70
|
+
# 실패로 끝난 잡은 판정 없이 센다. 중간까지 쓰고 실패했을 수 있고, 다시
|
|
71
|
+
# 읽어서 손해 볼 것은 왕복 한 번이다. 판정이 터져도 같은 쪽으로 기운다.
|
|
72
|
+
count = True
|
|
73
|
+
if changed is not None and getattr(job, "status", "") != "error":
|
|
74
|
+
try:
|
|
75
|
+
count = bool(changed(getattr(job, "result", None) or {}))
|
|
76
|
+
except Exception: # noqa: BLE001
|
|
77
|
+
count = True
|
|
78
|
+
if count:
|
|
79
|
+
self._n[owner] = self._n.get(owner, 0) + 1
|
|
80
|
+
self._pending = keep
|
|
81
|
+
|
|
82
|
+
def read(self, owner: str | None) -> str:
|
|
83
|
+
"""이 소유자의 표식. 소유자 없는 쓰기(`ANY`)도 함께 센다."""
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._settle()
|
|
86
|
+
n = self._n.get(owner or ANY, 0)
|
|
87
|
+
if owner and owner != ANY:
|
|
88
|
+
n += self._n.get(ANY, 0)
|
|
89
|
+
return f"{self._epoch}.{n}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
#: 성공해도 **화면에 보이는 것을 바꾸지 않는** 쓰기. 여기 없는 쓰기는 전부 센다
|
|
93
|
+
#: (새 라우트를 더하고 잊으면 한 번 더 다시 읽을 뿐이고, 반대로 빠뜨리면 화면이 낡는다).
|
|
94
|
+
#:
|
|
95
|
+
#: /touch 조회 빈도 신호. 노드를 열 때마다 앱이 부른다. 세면 노드를 누를
|
|
96
|
+
#: 때마다 지도가 다시 그려진다
|
|
97
|
+
#: /doc/reembed 재임베딩 대기 표식만 세운다
|
|
98
|
+
#: /embed/apply 임베딩 워커의 결과. 허브에서 쉬지 않고 돈다
|
|
99
|
+
#: /node-mirror/rebuild 배포 경계의 색인 재구축(그때는 세대가 이미 바뀌었다)
|
|
100
|
+
#: /backup 파일을 만들 뿐이다
|
|
101
|
+
#: /sync/domains 동기화할 주제 선택. 그래프는 그대로다
|
|
102
|
+
#: /connect/start 기기 연결 논스
|
|
103
|
+
#: /space/proxy 계정으로 나가는 통과로. 이 스토어에 쓰지 않는다(표식은 계정 쪽
|
|
104
|
+
#: 허브가 센다)
|
|
105
|
+
#: /conventions/build GRAPH.md 재생성. 그래프가 아니다
|
|
106
|
+
QUIET_WRITES = frozenset({
|
|
107
|
+
"/touch", "/doc/reembed", "/embed/apply", "/node-mirror/rebuild", "/backup",
|
|
108
|
+
"/sync/domains", "/connect/start", "/space/proxy", "/conventions/build",
|
|
109
|
+
# 저장 설정(그래프 밖 파일)과 전량 올리기(이 스토어를 안 바꾼다), VIN-130.
|
|
110
|
+
"/config", "/sync/push-all", "/shutdown",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def counts(path: str) -> bool:
|
|
115
|
+
"""이 POST 가 성공하면 표식을 올리는가."""
|
|
116
|
+
return path not in QUIET_WRITES
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def sync_brought_something(result: dict) -> bool:
|
|
120
|
+
"""동기화 세션(`api.sync.run`)이 이 스토어에 무엇이든 들였는가. 올려 보내기만 한
|
|
121
|
+
세션은 이 기기의 그래프를 바꾸지 않는다."""
|
|
122
|
+
pull = result.get("pull") or {}
|
|
123
|
+
docs = result.get("docs_pull") or {}
|
|
124
|
+
moved = sum(int(pull.get(k) or 0) for k in ("applied", "deleted"))
|
|
125
|
+
moved += sum(int(docs.get(k) or 0) for k in ("applied", "updated", "deleted"))
|
|
126
|
+
return moved > 0 or bool(result.get("birth_resync")) \
|
|
127
|
+
or bool(result.get("account_switched"))
|