kiwime-export 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.
@@ -0,0 +1,17 @@
1
+ """kiwiMe export worker.
2
+
3
+ Serializes the compiled Expert Context Package (manifest, identity, memories,
4
+ graph, cases, assets, permissions) per docs/schema/core.md "Export layer".
5
+
6
+ Default behavior is **minimum public**: identity + confirmed cases +
7
+ topic-level capability summary. No raw document text leaves the package
8
+ unless the user explicitly opts in. See ``options.ExportOptions``.
9
+ """
10
+
11
+ from .manifest import build_manifest
12
+ from .options import ExportOptions
13
+ from .writer import write_package
14
+
15
+ __all__ = ["ExportOptions", "build_manifest", "write_package"]
16
+
17
+ __version__ = "0.0.0"
@@ -0,0 +1,144 @@
1
+ """Entry point: ``python -m kiwime_export``.
2
+
3
+ Reads JSON-RPC requests line-by-line from stdin, writes responses to stdout.
4
+ Logs go to stderr (per docs/architecture/ipc-protocol.md §1: stdout is the
5
+ protocol channel).
6
+
7
+ Production wiring: the host spawns this with ``--direct <sqlite_path>`` so
8
+ the worker reads the same store database the rest of the system writes to.
9
+ Without ``--direct`` we fall back to a no-op stub, which is only useful for
10
+ the standalone smoke tests in ``tests/`` — every export call would otherwise
11
+ return an empty package.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sqlite3
18
+ import sys
19
+ from typing import Any
20
+
21
+ from kiwime_store import repo
22
+
23
+ from .server import dispatch
24
+ from .store_client import StoreClient
25
+
26
+
27
+ class _UnwiredStore:
28
+ """Placeholder used only when ``--direct`` is omitted (tests).
29
+
30
+ Returns empty results for every query so the binary can boot in
31
+ isolation without a database.
32
+ """
33
+
34
+ def query_memories(self, profile_id: str) -> list[dict[str, Any]]:
35
+ return []
36
+
37
+ def query_graph(self, profile_id: str) -> dict[str, list[dict[str, Any]]]:
38
+ return {"nodes": [], "edges": []}
39
+
40
+ def query_capabilities(self, profile_id: str) -> list[dict[str, Any]]:
41
+ return []
42
+
43
+ def query_cases(self, profile_id: str) -> list[dict[str, Any]]:
44
+ return []
45
+
46
+ def query_assets(self, profile_id: str) -> list[dict[str, Any]]:
47
+ return []
48
+
49
+ def query_permissions(self, profile_id: str) -> dict[str, Any]:
50
+ return {}
51
+
52
+
53
+ class SqliteStoreClient:
54
+ """Direct SQLite reader. The same database file the store sidecar uses.
55
+
56
+ We deliberately open in read-only mode (``mode=ro``) — the export
57
+ worker must never mutate state. Also ``immutable=0`` so SQLite still
58
+ sees concurrent writes from the store sidecar (otherwise it would
59
+ cache the page set and miss new memories compiled mid-session).
60
+ """
61
+
62
+ def __init__(self, db_path: str) -> None:
63
+ uri = f"file:{db_path}?mode=ro"
64
+ self._conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
65
+ self._conn.row_factory = sqlite3.Row
66
+
67
+ def query_memories(self, profile_id: str) -> list[dict[str, Any]]:
68
+ return repo.list_memories(self._conn, profile_id)
69
+
70
+ def query_graph(self, profile_id: str) -> dict[str, list[dict[str, Any]]]:
71
+ return repo.get_graph(self._conn, profile_id)
72
+
73
+ def query_capabilities(self, profile_id: str) -> list[dict[str, Any]]:
74
+ return repo.list_capabilities(self._conn, profile_id)
75
+
76
+ def query_cases(self, profile_id: str) -> list[dict[str, Any]]:
77
+ # ``case_record`` rows aren't yet exposed via a repo helper. Read
78
+ # directly; columns track migrations/0001_init.sql.
79
+ try:
80
+ rows = self._conn.execute(
81
+ "SELECT * FROM case_record WHERE profile_id = ?",
82
+ (profile_id,),
83
+ ).fetchall()
84
+ except sqlite3.OperationalError:
85
+ return []
86
+ return [dict(r) for r in rows]
87
+
88
+ def query_assets(self, profile_id: str) -> list[dict[str, Any]]:
89
+ # Assets live under ``source`` — there's no profile_id column on
90
+ # ``asset`` itself; we join through ``source``.
91
+ try:
92
+ rows = self._conn.execute(
93
+ """
94
+ SELECT a.* FROM asset a
95
+ JOIN source s ON s.id = a.source_id
96
+ WHERE s.profile_id = ? AND a.deleted_at IS NULL
97
+ """,
98
+ (profile_id,),
99
+ ).fetchall()
100
+ except sqlite3.OperationalError:
101
+ return []
102
+ return [dict(r) for r in rows]
103
+
104
+ def query_permissions(self, profile_id: str) -> dict[str, Any]:
105
+ # No persisted permission rows yet — the manifest section is the
106
+ # canonical record of "what this export contains". Surface the
107
+ # known sources so downstream consumers see scope at a glance.
108
+ try:
109
+ sources = repo.list_sources(self._conn, profile_id)
110
+ except Exception:
111
+ sources = []
112
+ return {
113
+ "sources": [
114
+ {"id": s.get("id"), "connector": s.get("connector"), "kind": s.get("kind")}
115
+ for s in sources
116
+ ],
117
+ }
118
+
119
+
120
+ def main() -> int:
121
+ parser = argparse.ArgumentParser(prog="kiwime-export")
122
+ parser.add_argument(
123
+ "--direct",
124
+ metavar="SQLITE_PATH",
125
+ help="Open the store SQLite directly (read-only). Required for real exports.",
126
+ )
127
+ args, _unknown = parser.parse_known_args()
128
+
129
+ # Without --direct: stub mode with empty results. The binary still
130
+ # serves RPC so smoke tests pass, but every export will be empty.
131
+ store: StoreClient = SqliteStoreClient(args.direct) if args.direct else _UnwiredStore()
132
+
133
+ for line in sys.stdin:
134
+ line = line.strip()
135
+ if not line:
136
+ continue
137
+ response = dispatch(line, store)
138
+ sys.stdout.write(response + "\n")
139
+ sys.stdout.flush()
140
+ return 0
141
+
142
+
143
+ if __name__ == "__main__":
144
+ sys.exit(main())
@@ -0,0 +1,65 @@
1
+ """Manifest builder.
2
+
3
+ The manifest is the spine of the package: every file is checksummed, every
4
+ included section is listed, every redaction is named. It is what makes the
5
+ package *auditable* — a downstream importer can verify integrity and see
6
+ exactly what the user chose to share.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from datetime import UTC, datetime
13
+ from typing import Any
14
+
15
+ from .options import ExportOptions
16
+
17
+ # Bump on breaking changes. v0 = pre-1.0 draft.
18
+ MANIFEST_VERSION = 0
19
+ GENERATOR = "kiwiMe/0.0.0"
20
+
21
+
22
+ def _sha256_hex(data: str | bytes) -> str:
23
+ if isinstance(data, str):
24
+ data = data.encode("utf-8")
25
+ return hashlib.sha256(data).hexdigest()
26
+
27
+
28
+ def _utc_now_iso() -> str:
29
+ """ISO 8601 UTC, millisecond precision, ``Z`` suffix (per schema/core.md)."""
30
+ now = datetime.now(UTC)
31
+ return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
32
+
33
+
34
+ def build_manifest(
35
+ profile_id: str,
36
+ opts: ExportOptions,
37
+ files: dict[str, str | bytes],
38
+ *,
39
+ generated_at: str | None = None,
40
+ ) -> dict[str, Any]:
41
+ """Build the manifest object.
42
+
43
+ Args:
44
+ profile_id: The profile being exported.
45
+ opts: User's per-export toggles.
46
+ files: Mapping ``filename -> content`` of every file included in the
47
+ package *other than* ``manifest.json`` itself. Checksums are
48
+ computed over these.
49
+ generated_at: Optional override for the timestamp (tests inject a
50
+ fixed value).
51
+
52
+ Returns:
53
+ The manifest dict, ready to be JSON-serialized.
54
+ """
55
+ sections = sorted(files.keys())
56
+ checksums = {name: _sha256_hex(content) for name, content in files.items()}
57
+ return {
58
+ "version": MANIFEST_VERSION,
59
+ "profile_id": profile_id,
60
+ "generated_at": generated_at or _utc_now_iso(),
61
+ "generator": GENERATOR,
62
+ "sections": sections,
63
+ "redactions": opts.redactions(),
64
+ "checksums": checksums,
65
+ }
@@ -0,0 +1,70 @@
1
+ """Export options.
2
+
3
+ Defaults are deliberately conservative: **minimum public**. No raw document
4
+ text leaves the package, no episodic memories, no provenance ingestion-layer
5
+ refs, no assets. The user must explicitly opt in to expand the package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Literal
12
+
13
+ ExportFormat = Literal["directory", "tarball"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ExportOptions:
18
+ """User-controlled toggles for what goes into the Expert Context Package.
19
+
20
+ Every field is opt-in by default. The package contains the *compiled*
21
+ expert context, not the user's source material.
22
+ """
23
+
24
+ include_episodic: bool = False
25
+ """If False, episodic memories are filtered out (only identity + semantic)."""
26
+
27
+ include_provenance: bool = False
28
+ """If False, ingestion-layer refs are stripped from graph/cases/memories."""
29
+
30
+ include_asset_ids: list[str] = field(default_factory=list)
31
+ """Explicit allow-list of asset ids. Empty means: no assets exported."""
32
+
33
+ include_raw_text: bool = False
34
+ """If False, the ``text`` field of memories/documents is summarized only.
35
+
36
+ Note: this is the load-bearing privacy switch. With ``False`` (default),
37
+ no raw user document text is in the package.
38
+ """
39
+
40
+ format: ExportFormat = "directory"
41
+ """Either a directory of files or an additional ``package.tar.gz``."""
42
+
43
+ @classmethod
44
+ def from_dict(cls, d: dict[str, object] | None) -> ExportOptions:
45
+ if not d:
46
+ return cls()
47
+ return cls(
48
+ include_episodic=bool(d.get("include_episodic", False)),
49
+ include_provenance=bool(d.get("include_provenance", False)),
50
+ include_asset_ids=list(d.get("include_asset_ids", []) or []), # type: ignore[arg-type]
51
+ include_raw_text=bool(d.get("include_raw_text", False)),
52
+ format=d.get("format", "directory"), # type: ignore[arg-type]
53
+ )
54
+
55
+ def redactions(self) -> list[str]:
56
+ """List of section names that were redacted by these options.
57
+
58
+ Surfaced in ``manifest.json`` so a downstream importer can see what
59
+ the user chose to omit, without leaking what was actually in there.
60
+ """
61
+ out: list[str] = []
62
+ if not self.include_episodic:
63
+ out.append("episodic_memories")
64
+ if not self.include_raw_text:
65
+ out.append("raw_document_text")
66
+ if not self.include_provenance:
67
+ out.append("provenance_refs")
68
+ if not self.include_asset_ids:
69
+ out.append("assets")
70
+ return out
kiwime_export/py.typed ADDED
File without changes
@@ -0,0 +1,231 @@
1
+ """Static resume page — render the compiled expert context as one HTML file.
2
+
3
+ The "public resume" use case: a user exports a single self-contained
4
+ ``resume.html`` (inline CSS, zero external requests, no JS required) and
5
+ hosts it anywhere — GitHub Pages, their own domain — as a live, provenance-
6
+ aware alternative to a PDF resume.
7
+
8
+ Privacy stance mirrors the package exporter: this renders only *compiled*
9
+ context (identity summary, capabilities, confirmed cases), never raw
10
+ document text, and applies the same governance filters (dismissed/archived
11
+ excluded, superseded memories skipped, candidate cases skipped). The page
12
+ is meant to be published, so the bar is "what the user already chose to
13
+ stand behind", not "everything the store knows".
14
+
15
+ The optional ``narrative`` (an LLM-written professional summary) is
16
+ generated by the compiler's assistant role — the host orchestrates that
17
+ call and passes the text in, so this worker stays LLM-free.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import html
23
+ from datetime import UTC, datetime
24
+ from typing import Any
25
+
26
+ from .store_client import StoreClient
27
+
28
+ # Governance statuses that must never appear on a public page.
29
+ _EXCLUDED_STATUSES = frozenset({"dismissed", "archived"})
30
+
31
+ # recency column values → human phrasing, in display order.
32
+ _RECENCY_LABEL = {
33
+ "recent": "current focus",
34
+ "stable": "established",
35
+ "dormant": "earlier work",
36
+ }
37
+ _RECENCY_ORDER = {"recent": 0, "stable": 1, "dormant": 2}
38
+
39
+
40
+ def collect_resume_data(profile_id: str, store: StoreClient) -> dict[str, Any]:
41
+ """Gather everything the template needs, applying governance filters.
42
+
43
+ Returns a plain dict so it can double as the ``resume.json`` payload
44
+ (machine-readable twin of the page) and be unit-tested without HTML
45
+ parsing.
46
+ """
47
+ memories = store.query_memories(profile_id)
48
+ identities = [
49
+ m
50
+ for m in memories
51
+ if m.get("kind") == "identity"
52
+ and not m.get("superseded_by")
53
+ and m.get("status") not in _EXCLUDED_STATUSES
54
+ ]
55
+ identities.sort(key=lambda m: m.get("created_at", ""), reverse=True)
56
+ identity = identities[0] if identities else None
57
+
58
+ capabilities = [
59
+ c
60
+ for c in store.query_capabilities(profile_id)
61
+ if c.get("status") not in _EXCLUDED_STATUSES
62
+ ]
63
+ # Confidence within recency bucket: current focus first, strongest first.
64
+ capabilities.sort(
65
+ key=lambda c: (
66
+ _RECENCY_ORDER.get(str(c.get("recency")), 3),
67
+ -float(c.get("confidence") or 0.0),
68
+ )
69
+ )
70
+
71
+ cases = [c for c in store.query_cases(profile_id) if c.get("status") == "confirmed"]
72
+ cases.sort(key=lambda c: -float(c.get("confidence") or 0.0))
73
+
74
+ return {
75
+ "identity": {
76
+ "summary": (identity.get("summary") or "").strip() if identity else "",
77
+ "text": (identity.get("text") or "").strip() if identity else "",
78
+ },
79
+ "capabilities": [
80
+ {
81
+ "name": c.get("name") or "",
82
+ "description": c.get("description") or "",
83
+ "confidence": float(c.get("confidence") or 0.0),
84
+ "recency": str(c.get("recency") or "stable"),
85
+ }
86
+ for c in capabilities
87
+ ],
88
+ "cases": [
89
+ {
90
+ "title": c.get("title") or "",
91
+ "summary": c.get("summary") or "",
92
+ "outcome": c.get("outcome") or "",
93
+ }
94
+ for c in cases
95
+ ],
96
+ }
97
+
98
+
99
+ def _esc(s: str) -> str:
100
+ return html.escape(s, quote=True)
101
+
102
+
103
+ def _confidence_bar(confidence: float) -> str:
104
+ pct = max(0, min(100, round(confidence * 100)))
105
+ return (
106
+ f'<div class="bar" role="img" aria-label="confidence {pct}%">'
107
+ f'<div class="bar-fill" style="width:{pct}%"></div></div>'
108
+ )
109
+
110
+
111
+ def render_resume_html(
112
+ data: dict[str, Any],
113
+ *,
114
+ narrative: str = "",
115
+ display_name: str = "",
116
+ generated_at: str | None = None,
117
+ ) -> str:
118
+ """Render the resume page. Self-contained: inline CSS, no JS, no fetches.
119
+
120
+ ``narrative`` is the optional LLM-written summary (host passes it in);
121
+ when absent the page falls back to the compiled identity text, and when
122
+ that's absent too, the section is omitted — never filled with boilerplate.
123
+ """
124
+ ts = generated_at or datetime.now(UTC).strftime("%Y-%m-%d")
125
+ name = display_name.strip() or "Expert Context"
126
+ summary = narrative.strip() or data["identity"]["text"] or data["identity"]["summary"]
127
+
128
+ cap_items: list[str] = []
129
+ for c in data["capabilities"]:
130
+ cap_items.append(
131
+ '<li class="cap">'
132
+ f'<div class="cap-head"><span class="cap-name">{_esc(c["name"])}</span>'
133
+ f'<span class="tag">{_esc(_RECENCY_LABEL.get(c["recency"], c["recency"]))}</span></div>'
134
+ f"{_confidence_bar(c['confidence'])}"
135
+ f'<p class="cap-desc">{_esc(c["description"])}</p>'
136
+ "</li>"
137
+ )
138
+
139
+ case_items: list[str] = []
140
+ for c in data["cases"]:
141
+ outcome = (
142
+ f'<p class="case-outcome"><strong>Outcome:</strong> {_esc(c["outcome"])}</p>'
143
+ if c["outcome"]
144
+ else ""
145
+ )
146
+ case_items.append(
147
+ '<li class="case">'
148
+ f"<h3>{_esc(c['title'])}</h3>"
149
+ f"<p>{_esc(c['summary'])}</p>"
150
+ f"{outcome}"
151
+ "</li>"
152
+ )
153
+
154
+ summary_section = (
155
+ f'<section><h2>Profile</h2><p class="summary">{_esc(summary)}</p></section>'
156
+ if summary
157
+ else ""
158
+ )
159
+ caps_section = (
160
+ f'<section><h2>Capabilities</h2><ul class="caps">{"".join(cap_items)}</ul></section>'
161
+ if cap_items
162
+ else ""
163
+ )
164
+ cases_section = (
165
+ f'<section><h2>Selected cases</h2><ul class="cases">{"".join(case_items)}</ul></section>'
166
+ if case_items
167
+ else ""
168
+ )
169
+
170
+ return f"""<!doctype html>
171
+ <html lang="en">
172
+ <head>
173
+ <meta charset="utf-8">
174
+ <meta name="viewport" content="width=device-width, initial-scale=1">
175
+ <meta name="robots" content="index, follow">
176
+ <title>{_esc(name)}</title>
177
+ <style>
178
+ :root {{ color-scheme: light dark; }}
179
+ * {{ box-sizing: border-box; margin: 0; }}
180
+ body {{
181
+ font: 16px/1.65 system-ui, -apple-system, "Segoe UI", sans-serif;
182
+ max-width: 46rem; margin: 0 auto; padding: 3rem 1.5rem 4rem;
183
+ color: #1a2233; background: #fdfdfc;
184
+ }}
185
+ @media (prefers-color-scheme: dark) {{
186
+ body {{ color: #d7dce6; background: #0d1117; }}
187
+ .tag {{ background: #1f2937; color: #93a4bd; }}
188
+ .bar {{ background: #1f2937; }}
189
+ header, section {{ border-color: #232b3a; }}
190
+ }}
191
+ header {{ border-bottom: 2px solid #e5e4df; padding-bottom: 1.25rem; margin-bottom: 2rem; }}
192
+ h1 {{ font-size: 1.75rem; letter-spacing: -0.02em; }}
193
+ .meta {{ font-size: .8rem; opacity: .55; margin-top: .35rem; }}
194
+ section {{ margin-bottom: 2.25rem; }}
195
+ h2 {{
196
+ font-size: .8rem; text-transform: uppercase; letter-spacing: .12em;
197
+ opacity: .6; margin-bottom: 1rem;
198
+ }}
199
+ .summary {{ font-size: 1.05rem; }}
200
+ ul.caps, ul.cases {{ list-style: none; padding: 0; display: grid; gap: 1.1rem; }}
201
+ .cap-head {{ display: flex; align-items: baseline; gap: .6rem; }}
202
+ .cap-name {{ font-weight: 600; }}
203
+ .tag {{
204
+ font-size: .68rem; text-transform: uppercase; letter-spacing: .06em;
205
+ background: #eceae4; color: #5b6472; border-radius: 999px; padding: .1rem .55rem;
206
+ }}
207
+ .bar {{ height: 4px; background: #eceae4; border-radius: 2px; margin: .4rem 0; max-width: 16rem; }}
208
+ .bar-fill {{ height: 100%; background: #22a3a9; border-radius: 2px; }}
209
+ .cap-desc, .case p {{ font-size: .92rem; opacity: .85; }}
210
+ .case h3 {{ font-size: 1rem; margin-bottom: .25rem; }}
211
+ .case-outcome {{ font-size: .88rem; margin-top: .35rem; }}
212
+ footer {{ margin-top: 3rem; font-size: .75rem; opacity: .45; }}
213
+ footer a {{ color: inherit; }}
214
+ </style>
215
+ </head>
216
+ <body>
217
+ <header>
218
+ <h1>{_esc(name)}</h1>
219
+ <p class="meta">Compiled expert context · generated {_esc(ts)}</p>
220
+ </header>
221
+ {summary_section}
222
+ {caps_section}
223
+ {cases_section}
224
+ <footer>
225
+ <p>Generated from a continuously compiled expert context —
226
+ every capability and case above traces back to real work.
227
+ Built with <a href="https://github.com/kiwiberry-ai/kiwime">kiwiMe</a>.</p>
228
+ </footer>
229
+ </body>
230
+ </html>
231
+ """