worktrust 0.10.13__py3-none-win_amd64.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,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: worktrust
3
+ Version: 0.10.13
4
+ Summary: The WorkTrust CLI, using the same measurement engine as npm
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://worktrust.io
7
+ Project-URL: Source, https://github.com/fhomey/worktrust-cli
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # WorkTrust for Python tool runners
14
+
15
+ This package launches the same JavaScript measurement engine shipped in the WorkTrust npm
16
+ package. It does not implement another measurement engine or a Python instrumentation SDK.
17
+ Python 3.10+ is required. Supported platform wheels include **Node.js 22.23.3**; no separate
18
+ Node or npm installation is needed. The launcher uses that bundled runtime, never downloads a
19
+ runtime when invoked, and does not silently switch to another CLI version.
20
+
21
+ Wheels target macOS 11+ (Intel and Apple Silicon), Linux with glibc 2.28+ (x64 and arm64), and
22
+ Windows 10+ (x64 and arm64). Linux also requires the Node-supported system C++ runtime
23
+ (GLIBCXX_3.4.25 or newer). Alpine/musl, 32-bit systems and other platforms are not supported.
24
+ A source distribution cannot fetch Node automatically: a source build requires the explicit
25
+ maintainer preparation step below. Supported users should install the matching wheel.
26
+
27
+ After publication under a verified WorkTrust-owned PyPI project, the intended commands are:
28
+
29
+ ```sh
30
+ uvx worktrust preserve
31
+ pipx run worktrust preserve
32
+ ```
33
+
34
+ These public commands are not a claim that this package has already been published. Test a
35
+ reviewed local wheel first:
36
+
37
+ ```sh
38
+ uvx --from /absolute/path/worktrust-VERSION-py3-none-PLATFORM.whl worktrust --help
39
+ pipx run --spec /absolute/path/worktrust-VERSION-py3-none-PLATFORM.whl worktrust --help
40
+ ```
41
+
42
+ Use your package manager to choose and update the installed version. The package contains
43
+ the CLI modules and Node runtime, so `preserve` itself needs no network after installation. It reads supported
44
+ local session files and can change retention settings or create local metadata archives;
45
+ only the person runs it. Existing WorkTrust agent safeguards and consent prompts remain.
46
+ Other commands can contact WorkTrust. Package installation itself can contact registries.
47
+
48
+ After PyPI publication, explicitly request the latest published package when needed:
49
+
50
+ ```sh
51
+ uvx worktrust@latest preserve
52
+ pipx run --no-cache worktrust preserve
53
+ ```
54
+
55
+ For persistent installations, use `uv tool upgrade worktrust` or `pipx upgrade worktrust`.
56
+ An existing cache or version constraint can otherwise keep an older release. `worktrust update`
57
+ refreshes the computer coupling using the selected package; it does not upgrade the Python package.
58
+
59
+ Runtime bundle hashes detect missing or changed files; they are not hardware attestation or
60
+ independent proof of authenticity. Request and archive signatures retain the existing CLI's
61
+ software-key threat model. See the npm package SECURITY.md and README.md for the data contract.
62
+
63
+ ## Maintainers
64
+
65
+ Running `node tools/worktrust-cli/sync.mjs` from the monorepo also regenerates the Python JavaScript
66
+ bundle and version. `node scripts/build-python-cli.mjs --check` refuses drift. The native runtime
67
+ is a separate build artifact, excluded from Git and sdists. Explicitly prepare it before building:
68
+
69
+ ```sh
70
+ python tools/worktrust-python/build_runtime.py
71
+ python -m build --wheel tools/worktrust-python
72
+ python -m build --sdist tools/worktrust-python
73
+ ```
74
+
75
+ `build_runtime.py` downloads one official Node archive from nodejs.org at build time, verifies its
76
+ committed SHA256 from `runtime-lock.json`, and extracts only the executable and complete LICENSE.
77
+ It accepts `--target` for a reviewed target and `--cache` for a build cache. Platform wheel tags
78
+ follow the selected runtime. Never label these wheels `any`. Building a wheel from the sdist
79
+ fails closed until this explicit preparation step has been performed; no install hook downloads code.
80
+
81
+ Run `python tools/worktrust-python/tests/test_runtime_build.py`, then
82
+ `node scripts/test-python-cli.mjs` with a Python containing the build requirements (select it with
83
+ `WORKTRUST_TEST_PYTHON`). Tests build and exercise a real wheel using its own runtime with no Node
84
+ on PATH; they use synthetic homes and metadata only. Native execution must pass independently on
85
+ all six claimed OS/architecture combinations before release. Cross-building alone is not that proof.
86
+ Set `WORKTRUST_REQUIRE_TOOL_RUNNERS=1` to require actual uv and pipx wheel smoke tests, and
87
+ `WORKTRUST_TEST_ARTIFACT_DIR` to a fresh directory to retain the exact tested wheel and sdist.
88
+ Existing artifacts are never overwritten. Canonical module bytes match across channels;
89
+ bit-for-bit reproducibility of whole wheel or sdist archives is not asserted.
90
+
91
+ The public `fhomey/worktrust-cli` repository carries this package under `python/`, alongside the
92
+ npm package at its root. There, install `python/requirements-release.txt`, run
93
+ `python python/build_runtime.py`, then `python python/tests/test_distribution.py` with
94
+ `WORKTRUST_TEST_CLI_SOURCE` set to the repository's absolute path and
95
+ `WORKTRUST_REQUIRE_TOOL_RUNNERS=1`. The test derives Node from the built wheel; it does not require
96
+ a system Node installation. Keep build-only workflow results distinct from successful publication.
97
+
98
+ The inactive `release-pypi.yml.example` targets this public layout. Its intended active filename
99
+ is `.github/workflows/release-pypi.yml`, using the protected `pypi` environment and PyPI Trusted
100
+ Publishing. Once activated, the same GitHub release triggers both registries; manual execution
101
+ supports the initial Python publication and retries. npm also requires the supported-platform Python
102
+ artifact/parity tests before publishing. Both channels use the same version and module bytes.
103
+ Approvals and registry failures remain separate; confirm both publications before announcing a
104
+ release as available on both channels. Neither workflow invokes a Vercel deployment.
105
+
106
+ Do not edit generated `bundle/`, `VERSION` or `LICENSE`. Publication requires confirmed
107
+ PyPI ownership and a configured Trusted Publisher; source readiness does not grant that.
108
+
109
+ Official runtime platform requirements: https://github.com/nodejs/node/blob/v22.23.3/BUILDING.md
110
+ Pinned upstream archive checksums: https://nodejs.org/dist/v22.23.3/SHASUMS256.txt
@@ -0,0 +1,26 @@
1
+ worktrust-0.10.13.dist-info/licenses/LICENSE,sha256=Ki1_xmffV4aEorLyoSLIuEneaNNxa6_-34oPERYt8F0,1066
2
+ worktrust_cli/__init__.py,sha256=BykjYnxG83EtV2oUloviRAf6PnnM4li5L9Alz0J9LOk,4109
3
+ worktrust_cli/__main__.py,sha256=K6urUhz9vfSGXuwgaNA4kmUJf0GnO3L30gPDlScqYI0,48
4
+ worktrust_cli/bundle/chat-exports.mjs,sha256=EcjB-fd0kW15t2i4jwdzHcx-d3GohQNoUbn5OFYYVjE,16550
5
+ worktrust_cli/bundle/config-edits.mjs,sha256=DrovF27296-6Bx1uAN4P8ZuisoMYCoAd_ZV6o4we3ZQ,5336
6
+ worktrust_cli/bundle/count-behaviour.mjs,sha256=IN01BZwM90GDoL-8oJYoETJOKqyFysGZ7UNwAkYd43c,108221
7
+ worktrust_cli/bundle/log-session.mjs,sha256=LBzONGAuFfpYLw0s6rv6TBVNErIVW1BhZDXSDxMWxZU,101744
8
+ worktrust_cli/bundle/manifest.json,sha256=Mo_Fk-zKMyAu7BGIMmoKY9dqY3hjC81DC-k2FOxZ02c,1347
9
+ worktrust_cli/bundle/preserve-lines.mjs,sha256=eUVy9N5z9KY3FelnGZkBOhf3SUuSP4FFWgCbQGSvfjg,23827
10
+ worktrust_cli/bundle/preserve-summary.mjs,sha256=Vo_q5_MkbAdfuhTVT1gPrJ7mpUzukYZO0CZyspiuXTc,3326
11
+ worktrust_cli/bundle/preserve.mjs,sha256=OPg72TOFOphv72H05FLCfUo63GAA9ckgHvcPJwtATqw,31466
12
+ worktrust_cli/bundle/runtime-install.mjs,sha256=HtIxahEU8WNYP5nFNwMnztfU6kIoFc44avSVDe6vUT4,4837
13
+ worktrust_cli/bundle/session-databases.mjs,sha256=VD_TuC0o2W14m-Ch3JGGxfr6eD6YuH5bOxeyEVQuK00,35647
14
+ worktrust_cli/bundle/setup-mcp.mjs,sha256=cPY7Yc8eKPsrdHHndo1ZawzxZszcnRzoWe5Y0psuyrw,32284
15
+ worktrust_cli/bundle/stack-rules.mjs,sha256=sjxW5y7Fa6ArF-2JX4aRctmIxGiml-jWAmsOJo9h5Ik,7106
16
+ worktrust_cli/bundle/stretch-evidence.mjs,sha256=WzGm8pt4XSd-kiV1MvPHzuNmp5dZ3sx6dc-67CaG7hM,54279
17
+ worktrust_cli/bundle/transcript-readers.mjs,sha256=MdpJ-NaKu5TsIivVfAO2tP6-Cz_lMRUqZ1ALJCTVrIg,22282
18
+ worktrust_cli/bundle/worktrust.mjs,sha256=sjJaFACn6z1erszrIkdlQwhZ7tRW2axOwtCQf52OUXs,91080
19
+ worktrust_cli/runtime/LICENSE,sha256=jMm7RmsZ_H58yZ0D6d8RMgIf2osB7qJiTFi7Ny2-9XY,148217
20
+ worktrust_cli/runtime/node.exe,sha256=nJJFFmtKjhguC3l9qcIBNhF_8kNo6v8f7INDoSPI2w4,86973768
21
+ worktrust_cli/runtime/runtime.json,sha256=LUbXJqD0rRIQu488si9hojV_nWuABkAictwDmOqsc10,362
22
+ worktrust-0.10.13.dist-info/METADATA,sha256=AzUyejS1ZP6cONyb8OuA1670y-USWPSNnAagPchVmRg,6403
23
+ worktrust-0.10.13.dist-info/WHEEL,sha256=Zx98gwb_dQKckJK3HEOVKnxxD52EfU_PXDG_usMJ2ng,98
24
+ worktrust-0.10.13.dist-info/entry_points.txt,sha256=bVqGAPH_xUP_nIQQmT3ekm324-cLyzqCykjYdI98Dc4,49
25
+ worktrust-0.10.13.dist-info/top_level.txt,sha256=F8SowabDAnIBJZ3OUoVKO0BKeEzsewR7GLlsQfklnsc,14
26
+ worktrust-0.10.13.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ worktrust = worktrust_cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WorkTrust
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ worktrust_cli
@@ -0,0 +1,88 @@
1
+ """Distribution launcher only. All evidence calculation remains in the Node CLI."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import platform
6
+ from pathlib import Path
7
+ import re
8
+ import subprocess
9
+ import sys
10
+
11
+
12
+ def verified_entry():
13
+ root = Path(__file__).resolve().parent / "bundle"
14
+ manifest_path = root / "manifest.json"
15
+ if manifest_path.is_symlink():
16
+ raise ValueError("bundle manifest must not be a symlink")
17
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
18
+ if set(manifest) != {"version", "files"} or not re.fullmatch(r"\d+\.\d+\.\d+", manifest["version"]):
19
+ raise ValueError("invalid bundle manifest")
20
+ files = manifest["files"]
21
+ if not isinstance(files, dict) or "worktrust.mjs" not in files:
22
+ raise ValueError("invalid bundle file list")
23
+ if set(p.name for p in root.iterdir()) != set(files) | {"manifest.json"}:
24
+ raise ValueError("bundle file list mismatch")
25
+ for name, digest in files.items():
26
+ if not re.fullmatch(r"[a-z][a-z0-9-]*\.mjs", name) or not re.fullmatch(r"[a-f0-9]{64}", digest):
27
+ raise ValueError("invalid bundle file identity")
28
+ path = root / name
29
+ if path.is_symlink() or not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
30
+ raise ValueError("bundle integrity check failed")
31
+ return root / "worktrust.mjs"
32
+
33
+
34
+ def verified_runtime():
35
+ root = Path(__file__).resolve().parent / "runtime"
36
+ descriptor = root / "runtime.json"
37
+ if root.is_symlink() or descriptor.is_symlink():
38
+ raise ValueError("bundled runtime must not use symlinks")
39
+ metadata = json.loads(descriptor.read_text(encoding="utf-8"))
40
+ if set(metadata) != {"version","platform","executable","archive_sha256","files"}:
41
+ raise ValueError("invalid bundled runtime descriptor")
42
+ system = {"Darwin":"darwin","Linux":"linux","Windows":"win"}.get(platform.system())
43
+ arch = {"x86_64":"x64","amd64":"x64","aarch64":"arm64","arm64":"arm64"}.get(platform.machine().lower())
44
+ if metadata["platform"] != f"{system}-{arch}":
45
+ raise ValueError("bundled runtime does not support this OS/architecture")
46
+ executable = "node.exe" if os.name == "nt" else "node"
47
+ if metadata["executable"] != executable or set(metadata["files"]) != {executable,"LICENSE"}:
48
+ raise ValueError("invalid bundled runtime file list")
49
+ if not re.fullmatch(r"\d+\.\d+\.\d+", metadata["version"]) or not re.fullmatch(r"[a-f0-9]{64}", metadata["archive_sha256"]):
50
+ raise ValueError("invalid bundled runtime identity")
51
+ if {path.name for path in root.iterdir()} != {executable,"LICENSE","runtime.json"}:
52
+ raise ValueError("bundled runtime file list mismatch")
53
+ for name,digest in metadata["files"].items():
54
+ path = root/name
55
+ if path.is_symlink() or not path.is_file() or not re.fullmatch(r"[a-f0-9]{64}",digest) or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
56
+ raise ValueError("bundled runtime integrity check failed")
57
+ return root/executable
58
+
59
+
60
+ def main():
61
+ try:
62
+ entry = verified_entry()
63
+ node = verified_runtime()
64
+ env = dict(os.environ)
65
+ # Package-manager selection is authoritative. This does not disable agent detection.
66
+ env["WORKTRUST_RELAYED"] = "1"
67
+ env["WORKTRUST_BUNDLED_RUNTIME_DIR"] = str(node.parent)
68
+ argv = [str(node), str(entry), *sys.argv[1:]]
69
+ if os.name != "nt":
70
+ os.execve(node, argv, env)
71
+ child = subprocess.Popen(argv, env=env)
72
+ try:
73
+ return child.wait()
74
+ except KeyboardInterrupt:
75
+ # Windows delivers console Ctrl-C to both processes; allow the child to finish.
76
+ try:
77
+ return child.wait(timeout=5)
78
+ except subprocess.TimeoutExpired:
79
+ child.terminate()
80
+ child.wait()
81
+ return 130
82
+ except (OSError, ValueError, TypeError, KeyError, subprocess.SubprocessError) as error:
83
+ print(f"worktrust: {error}", file=sys.stderr)
84
+ return 1
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())
@@ -0,0 +1,4 @@
1
+ import sys
2
+ from . import main
3
+
4
+ sys.exit(main())
@@ -0,0 +1,240 @@
1
+ /**
2
+ * A WEB CHAT'S DATA EXPORT, READ ONCE FOR TWO READERS (CLI 0.10.10, owner 2026-10-10). ChatGPT (Settings → Data
3
+ * controls → Export data) and Claude on the web (Settings → Privacy → Export data) each hand a person a zip with a
4
+ * `conversations.json` in it. The counter reads it for signals; the session hook reads it as HISTORY, so a person's
5
+ * years of browser chats appear on their record. One reader here, imported by both, so the two cannot drift.
6
+ *
7
+ * WHAT IT NEVER DOES. It opens nothing of the zip but `conversations.json` (a vendor's export carries the account's
8
+ * profile, memories and files beside it: those are never inflated). It hands the text to the counter's rubric on this
9
+ * computer, as it always did, and to the hook it hands clocks, roles and a model name only: the conversation's title
10
+ * and every message stay here. Hours from an export are RECONSTRUCTED, never measured: the file can be edited before
11
+ * it is read and no device key witnessed it, so the record shows them with their origin and never in verified hours.
12
+ *
13
+ * readExport(path) { vendor, conversations, digest } a folder holding conversations.json, the file, or the zip
14
+ * exportSessions(path, …) one session per conversation, in the transcript's own shape (type, timestamp, message)
15
+ * exportRef(vendor, id, day) the stretch's stable name: the same on any computer, so a second send is the same line
16
+ * findExports(dir) the exports lying in one folder (its top level only), by path and vendor
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, openSync, readSync, closeSync, fstatSync, readdirSync, statSync, constants } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { basename, join } from "node:path";
22
+ import { inflateRawSync } from "node:zlib";
23
+
24
+ /** The two vendors, by the client name the door reads as their product (chatgpt → chatgpt_web, claude-web → claude_web). */
25
+ export const EXPORT_CLIENTS = ["chatgpt", "claude-web"];
26
+ /** The neutral line each vendor's conversation is filed under: never the conversation's own title. */
27
+ export const EXPORT_TITLES = { chatgpt: "A conversation in ChatGPT", "claude-web": "A conversation in Claude on the web" };
28
+ const PRODUCT_NAMES = { chatgpt: "ChatGPT", "claude-web": "Claude on the web" };
29
+ export const exportProductName = (vendor) => PRODUCT_NAMES[vendor] ?? vendor;
30
+ const FILE = "conversations.json";
31
+ const expand = (path) => String(path).replace(/^~(?=\/|$)/, homedir());
32
+
33
+ // Bound allocations and parsing work before touching untrusted sizes. Large attachment-bearing ZIPs
34
+ // need not fit in memory; only the directory and conversations entry are ever read. No limit override.
35
+ export const EXPORT_LIMITS = Object.freeze({ fileBytes: 2 * 1024 ** 3, directoryBytes: 8 * 1024 ** 2,
36
+ jsonBytes: 64 * 1024 ** 2, compressedBytes: 64 * 1024 ** 2, depth: 64, structure: 1_000_000, conversations: 50_000 });
37
+ const limit = (condition, label) => { if (!condition) throw Error(`export limit exceeded: ${label}`); };
38
+ const damaged = (condition) => { if (!condition) throw Error("a damaged zip archive"); };
39
+ /** One descriptor, bounded reads: reject special files/short reads and bound concurrent file growth. */
40
+ function openExport(path, maximum) {
41
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
42
+ try {
43
+ const stat = fstatSync(fd);
44
+ if (!stat.isFile()) throw Error("export must be a regular file");
45
+ limit(stat.size <= maximum, "file bytes");
46
+ const readAt = (at, length, end = stat.size) => {
47
+ damaged(Number.isSafeInteger(at) && Number.isSafeInteger(length) && at >= 0 && length >= 0 && at + length <= end && end <= stat.size);
48
+ const buffer = Buffer.alloc(length);
49
+ let got = 0;
50
+ while (got < length) { const count = readSync(fd, buffer, got, length - got, at + got); damaged(count > 0); got += count; }
51
+ return buffer;
52
+ };
53
+ return { fd, size: stat.size, readAt };
54
+ } catch (error) { closeSync(fd); throw error; }
55
+ }
56
+
57
+ /**
58
+ * A ZIP, READ FOR ONE ENTRY. No dependency: the end-of-central-directory record is found from the back, the central
59
+ * directory walked for the one name, and only that entry's bytes inflated (stored or deflated; anything else, and a
60
+ * zip64 archive, is refused by name). Nothing else in the archive is touched.
61
+ */
62
+ function zipEntry(path, wanted) {
63
+ const { fd, size, readAt } = openExport(path, EXPORT_LIMITS.fileBytes);
64
+ try {
65
+ const tailLength = Math.min(size, 65_557);
66
+ const tail = readAt(size - tailLength, tailLength);
67
+ let eocd = -1;
68
+ for (let at = tail.length - 22; at >= 0; at -= 1) if (tail.readUInt32LE(at) === 0x06054b50 && at + 22 + tail.readUInt16LE(at + 20) === tail.length) { eocd = at; break; }
69
+ if (eocd < 0) throw Error(`not a zip archive: ${path}`);
70
+ const entries = tail.readUInt16LE(eocd + 10), directorySize = tail.readUInt32LE(eocd + 12), directoryAt = tail.readUInt32LE(eocd + 16);
71
+ if (entries === 0xffff || directorySize === 0xffffffff || directoryAt === 0xffffffff) throw Error(`a zip64 archive is not read: ${path}`);
72
+ if (tail.readUInt16LE(eocd + 4) !== 0 || tail.readUInt16LE(eocd + 6) !== 0) throw Error("unsupported zip: multiple disks");
73
+ damaged(tail.readUInt16LE(eocd + 8) === entries);
74
+ limit(directorySize <= EXPORT_LIMITS.directoryBytes, "zip directory bytes");
75
+ damaged(directoryAt + directorySize === size - tailLength + eocd);
76
+ const directory = readAt(directoryAt, directorySize);
77
+ let at = 0, selected = null;
78
+ for (let index = 0; index < entries; index += 1) {
79
+ damaged(at + 46 <= directory.length && directory.readUInt32LE(at) === 0x02014b50);
80
+ const method = directory.readUInt16LE(at + 10), compressed = directory.readUInt32LE(at + 20), uncompressed = directory.readUInt32LE(at + 24);
81
+ const nameLength = directory.readUInt16LE(at + 28), extraLength = directory.readUInt16LE(at + 30), commentLength = directory.readUInt16LE(at + 32), localAt = directory.readUInt32LE(at + 42);
82
+ const flags = directory.readUInt16LE(at + 8), disk = directory.readUInt16LE(at + 34);
83
+ damaged(at + 46 + nameLength + extraLength + commentLength <= directory.length);
84
+ const name = directory.toString("utf8", at + 46, at + 46 + nameLength);
85
+ at += 46 + nameLength + extraLength + commentLength;
86
+ if (basename(name) !== wanted || name.endsWith("/")) continue;
87
+ if (compressed === 0xffffffff || uncompressed === 0xffffffff || localAt === 0xffffffff) throw Error(`a zip64 entry is not read: ${path}`);
88
+ limit(compressed <= EXPORT_LIMITS.compressedBytes, "compressed entry bytes");
89
+ limit(uncompressed <= EXPORT_LIMITS.jsonBytes, "expanded entry bytes");
90
+ if ((flags & (1 | 64)) !== 0 || disk !== 0) throw Error("unsupported zip: encrypted or multi-disk entry");
91
+ damaged(selected === null);
92
+ selected = { name, method, flags, compressed, uncompressed, localAt };
93
+ }
94
+ damaged(at === directory.length);
95
+ if (!selected) return null;
96
+ const { name, method, flags, compressed, uncompressed, localAt } = selected;
97
+ const local = readAt(localAt, 30, directoryAt);
98
+ damaged(local.readUInt32LE(0) === 0x04034b50 && local.readUInt16LE(8) === method && local.readUInt16LE(6) === flags);
99
+ const nameLength = local.readUInt16LE(26), dataAt = localAt + 30 + nameLength + local.readUInt16LE(28);
100
+ damaged(readAt(localAt + 30, nameLength, directoryAt).toString("utf8") === name);
101
+ if (!(flags & 8)) damaged(local.readUInt32LE(18) === compressed && local.readUInt32LE(22) === uncompressed);
102
+ const bytes = readAt(dataAt, compressed, directoryAt);
103
+ if (method === 0) { damaged(compressed === uncompressed); return bytes; }
104
+ if (method !== 8) throw Error(`${wanted} in ${path} is compressed in a way this reader does not open (method ${method})`);
105
+ let inflated;
106
+ try { inflated = inflateRawSync(bytes, { maxOutputLength: Math.max(1, uncompressed) }); }
107
+ catch { throw Error("a damaged zip archive or export limit exceeded: expanded entry bytes"); }
108
+ damaged(inflated.length === uncompressed);
109
+ return inflated;
110
+ } finally { closeSync(fd); }
111
+ }
112
+
113
+ /** The bytes of `conversations.json`, wherever the given path keeps them: a folder, the file itself, or the vendor's zip. */
114
+ function exportBytes(given) {
115
+ let path = expand(given);
116
+ let stat; try { stat = statSync(path); } catch { throw Error(`export not found: ${given}`); }
117
+ if (stat.isDirectory()) { path = join(path, FILE); if (!existsSync(path)) throw Error(`no ${FILE} in ${given}`); }
118
+ if (/\.zip$/i.test(path)) { const bytes = zipEntry(path, FILE); if (!bytes) throw Error(`no ${FILE} in ${given}`); return { bytes, path }; }
119
+ const { fd, size, readAt } = openExport(path, EXPORT_LIMITS.jsonBytes);
120
+ try { return { bytes: readAt(0, size), path }; } finally { closeSync(fd); }
121
+ }
122
+
123
+ /** Scan bytes before JSON.parse allocates an object graph; escaped quotes/brackets inside strings
124
+ * are content, not structure. JSON.parse remains responsible for syntax and UTF-8 string handling. */
125
+ function boundJson(bytes) {
126
+ let quoted = false, escaped = false, depth = 0, structure = 0;
127
+ for (const byte of bytes) {
128
+ if (quoted) { if (escaped) escaped = false; else if (byte === 92) escaped = true; else if (byte === 34) quoted = false; continue; }
129
+ if (byte === 34) quoted = true;
130
+ else if (byte === 91 || byte === 123) { depth += 1; structure += 1; limit(depth <= EXPORT_LIMITS.depth, "JSON depth"); }
131
+ else if (byte === 93 || byte === 125) depth -= 1;
132
+ else if (byte === 44 || byte === 58) structure += 1;
133
+ limit(structure <= EXPORT_LIMITS.structure, "JSON structure");
134
+ }
135
+ }
136
+
137
+ /** Which vendor wrote these conversations, by their shape alone; mixed or unknown shapes are refused, never guessed. */
138
+ export function vendorOf(conversations) {
139
+ if (!Array.isArray(conversations) || conversations.length === 0) return null;
140
+ const shapes = new Set(conversations.map((c) => (c && typeof c === "object" && c.mapping && typeof c.mapping === "object" ? "chatgpt" : c && typeof c === "object" && Array.isArray(c.chat_messages) ? "claude-web" : "unknown")));
141
+ return shapes.size === 1 ? [...shapes][0] : null;
142
+ }
143
+
144
+ /** One export, read: its vendor by shape, its conversations, and the digest of its bytes (what `sent` is remembered by). */
145
+ export function readExport(given, expect = null) {
146
+ const { bytes, path } = exportBytes(given);
147
+ boundJson(bytes);
148
+ let conversations; try { conversations = JSON.parse(bytes.toString("utf8")); } catch { throw Error(`export unreadable: ${path}`); }
149
+ if (!Array.isArray(conversations)) throw Error(`${path} must contain an array of conversations`);
150
+ limit(conversations.length <= EXPORT_LIMITS.conversations, "conversation count");
151
+ const vendor = vendorOf(conversations);
152
+ if (conversations.length > 0 && !vendor) throw Error(`${path} is neither a ChatGPT nor a Claude export (or mixes the two)`);
153
+ if (expect && vendor && vendor !== expect) throw Error(`${path} is a ${exportProductName(vendor)} export, not ${exportProductName(expect)}`);
154
+ return { vendor: vendor ?? expect, conversations, digest: createHash("sha256").update(bytes).digest("hex"), path };
155
+ }
156
+
157
+ /** The kept branch of one ChatGPT conversation, oldest first: from `current_node` up through the parents. */
158
+ function chatgptBranch(conversation) {
159
+ const mapping = conversation?.mapping && typeof conversation.mapping === "object" ? conversation.mapping : {};
160
+ const branch = [];
161
+ const seen = new Set();
162
+ for (let id = conversation?.current_node; id && mapping[id] && !seen.has(id); id = mapping[id].parent) { seen.add(id); branch.push(mapping[id]); }
163
+ return branch.reverse();
164
+ }
165
+ /** A ChatGPT conversation's lines: the person and the model only; system prompts, tools and what the app hides are neither. */
166
+ function chatgptLines(conversation) {
167
+ const lines = [];
168
+ for (const node of chatgptBranch(conversation)) {
169
+ const m = node?.message;
170
+ const role = m?.author?.role;
171
+ if ((role !== "user" && role !== "assistant") || m?.metadata?.is_visually_hidden_from_conversation) continue;
172
+ const parts = Array.isArray(m?.content?.parts) ? m.content.parts.filter((part) => typeof part === "string") : [];
173
+ const text = parts.join("\n");
174
+ if (text.length === 0) continue;
175
+ const seconds = Number(m?.create_time ?? conversation?.create_time);
176
+ const at = Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : null;
177
+ const model = typeof m?.metadata?.model_slug === "string" ? m.metadata.model_slug : undefined;
178
+ lines.push({ type: role === "user" ? "user" : "assistant", timestamp: at, message: { ...(role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } });
179
+ }
180
+ return lines;
181
+ }
182
+ /** A claude.ai conversation's messages in the transcript's own shape; the export names no model. */
183
+ function claudeLines(conversation) {
184
+ const messages = Array.isArray(conversation?.chat_messages) ? conversation.chat_messages : [];
185
+ return messages.map((m) => {
186
+ const text = typeof m?.text === "string" && m.text.length > 0 ? m.text : (Array.isArray(m?.content) ? m.content.filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n") : "");
187
+ const at = m?.created_at ?? conversation?.created_at ?? null;
188
+ return { type: m?.sender === "human" ? "user" : "assistant", timestamp: at, message: { content: [{ type: "text", text }] } };
189
+ });
190
+ }
191
+ const titleOf = (vendor, conversation) => String((vendor === "chatgpt" ? conversation?.title : conversation?.name) ?? "");
192
+ const idOf = (vendor, conversation, index) => String((vendor === "chatgpt" ? conversation?.conversation_id ?? conversation?.id : conversation?.uuid) ?? `#${index}`);
193
+
194
+ /**
195
+ * Every conversation of an export as one session: `key` names the vendor and the conversation's id, `lines` carry
196
+ * the transcript shape both readers measure. `excluded(title)` skips a conversation by its title before anything of
197
+ * it is read (the same `--exclude` as a project directory); the title itself goes no further than that call.
198
+ * `given` is a path, or an export `readExport` already returned (so a zip is inflated once).
199
+ */
200
+ export function* exportSessions(given, { expect = null, excluded = () => false, skipped = null } = {}) {
201
+ const { vendor, conversations } = typeof given === "string" ? readExport(given, expect) : given;
202
+ for (let index = 0; index < conversations.length; index += 1) {
203
+ const conversation = conversations[index];
204
+ const title = titleOf(vendor, conversation);
205
+ if (excluded(title)) { skipped?.push(title.slice(0, 40)); continue; }
206
+ const lines = vendor === "chatgpt" ? chatgptLines(conversation) : claudeLines(conversation);
207
+ if (lines.length === 0) continue;
208
+ const id = idOf(vendor, conversation, index);
209
+ yield { key: `${vendor === "chatgpt" ? "chatgpt" : "claude.ai"}:${id}`, projectDir: vendor === "chatgpt" ? "chatgpt" : "claude-ai", basename: vendor === "chatgpt" ? "chatgpt" : "claude.ai", vendor, id, lines };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * THE STRETCH'S NAME (the hook's `stretch_ref`, 43 characters): a hash of the vendor, the conversation's id and the
215
+ * UTC day, the same on any computer and on any copy of the export, so the same export sent twice, or from two
216
+ * computers, is each line once. The conversation's id is a vendor's opaque id; the hash carries nothing readable.
217
+ */
218
+ export const exportRef = (vendor, id, day) => createHash("sha256").update(`worktrust-export|${vendor}|${id}|${day}`).digest("base64url");
219
+
220
+ /**
221
+ * THE EXPORTS IN ONE FOLDER, its top level only (the Downloads folder, where a vendor's export lands): a zip holding
222
+ * `conversations.json`, a folder holding one, or the file itself. Each is read to tell its vendor and digest; a file
223
+ * that is none of these, or not an export, is passed over without a word.
224
+ */
225
+ export function findExports(dir = join(homedir(), "Downloads")) {
226
+ const root = expand(dir);
227
+ let names = []; try { names = readdirSync(root); } catch { return []; }
228
+ const found = [];
229
+ for (const name of names.sort()) {
230
+ const path = join(root, name);
231
+ let stat; try { stat = statSync(path); } catch { continue; }
232
+ const candidate = stat.isDirectory() ? existsSync(join(path, FILE)) : /\.zip$/i.test(name) || name === FILE;
233
+ if (!candidate) continue;
234
+ try {
235
+ const { vendor, conversations, digest } = readExport(path);
236
+ if (vendor && conversations.length > 0) found.push({ path, vendor, digest, conversations: conversations.length });
237
+ } catch { /* not an export, or not one this reader opens */ }
238
+ }
239
+ return found;
240
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * CONFIG EDITS — a YAML file edited line by line, never re-serialised (2026-10-05).
3
+ *
4
+ * Hermes Agent and Goose keep their settings in YAML, written by people, with comments and their own indentation.
5
+ * setup-mcp.mjs puts WorkTrust's entry into them and takes it out again with these: a section is found by its key at
6
+ * a known indent, an entry is added at the indent its siblings use, and a removal leaves every other byte as it was.
7
+ * Only block style is handled; a section written inline (`key: { … }`) makes placeUnder return false, and the caller
8
+ * leaves the file alone. No dependency: it ships beside setup-mcp.mjs.
9
+ */
10
+ const indentOf = (line) => line.length - line.trimStart().length;
11
+ const meaningful = (line) => line.trim() !== "" && !line.trimStart().startsWith("#");
12
+ /** `key:` at `indent` between start and stop: its line and the end of its block, or the inline value it carries. */
13
+ function section(lines, start, stop, indent, key) {
14
+ for (let i = start; i < stop; i += 1) {
15
+ if (!meaningful(lines[i]) || indentOf(lines[i]) !== indent) continue;
16
+ const match = new RegExp(`^\\s*${key}:(?:\\s+(.*?))?\\s*$`).exec(lines[i]);
17
+ if (!match) continue;
18
+ const inline = match[1] && !match[1].startsWith("#") ? match[1] : null;
19
+ let end = i + 1;
20
+ while (end < stop && (!meaningful(lines[end]) || indentOf(lines[end]) > indent || (indentOf(lines[end]) === indent && lines[end].trimStart().startsWith("-")))) end += 1;
21
+ while (end > i + 1 && !meaningful(lines[end - 1])) end -= 1;
22
+ return { at: i, end, inline };
23
+ }
24
+ return null;
25
+ }
26
+ /** The entries of a block (a mapping's keys or a list's items), each [from, to). */
27
+ function entries(lines, at, end) {
28
+ const starts = [];
29
+ const first = lines.slice(at + 1, end).find(meaningful);
30
+ const child = first === undefined ? null : indentOf(first);
31
+ for (let i = at + 1; i < end; i += 1) if (meaningful(lines[i]) && indentOf(lines[i]) === child) starts.push(i);
32
+ return { child, ranges: starts.map((from, n) => [from, starts[n + 1] ?? end]) };
33
+ }
34
+ const EMPTY_INLINE = new Set(["{}", "[]", "null", "~"]);
35
+ /** Puts `unit` under the path, making each section that is missing; false when one is written inline. */
36
+ export function placeUnder(lines, path, unit) {
37
+ let start = 0, stop = lines.length, indent = 0;
38
+ for (const [n, key] of path.entries()) {
39
+ let found = section(lines, start, stop, indent, key);
40
+ if (found?.inline && !EMPTY_INLINE.has(found.inline)) return false;
41
+ if (found?.inline) lines[found.at] = `${" ".repeat(indent)}${key}:`;
42
+ if (!found) { lines.splice(stop, 0, `${" ".repeat(indent)}${key}:`); found = { at: stop, end: stop + 1 }; }
43
+ const child = entries(lines, found.at, found.end).child ?? indent + 2;
44
+ if (n === path.length - 1) { lines.splice(found.end, 0, ...unit.map((line) => " ".repeat(child) + line)); return true; }
45
+ start = found.at + 1; stop = found.end; indent = child;
46
+ }
47
+ return true;
48
+ }
49
+ /** The section at the end of a path, in block style; null when absent or inline. */
50
+ function find(lines, path) {
51
+ let start = 0, stop = lines.length, indent = 0, found = null;
52
+ for (const key of path) {
53
+ found = section(lines, start, stop, indent, key);
54
+ if (!found || found.inline) return null;
55
+ start = found.at + 1; stop = found.end; indent = entries(lines, found.at, found.end).child ?? indent + 2;
56
+ }
57
+ return found;
58
+ }
59
+ /** Takes out every entry under the path that is ours, and a section that leaves empty. */
60
+ export function takeOut(lines, path, mine) {
61
+ const found = find(lines, path);
62
+ if (!found) return;
63
+ let removed = 0;
64
+ for (const [from, to] of entries(lines, found.at, found.end).ranges.reverse()) if (mine(lines.slice(from, to).join("\n"))) { lines.splice(from, to - from); removed += 1; }
65
+ if (removed > 0) for (let depth = path.length; depth > 0; depth -= 1) {
66
+ const left = find(lines, path.slice(0, depth));
67
+ if (left && !lines.slice(left.at + 1, left.end).some(meaningful)) lines.splice(left.at, left.end - left.at);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * ONLY THE HOUSE'S OWN DOOR (0.6.16, audit L5; the same rule as log-session.mjs and count-behaviour.mjs): an MCP address is
73
+ * WorkTrust's when its path is /api/mcp and its host is worktrust.io or a subdomain over https, the host WORKTRUST_MCP_URL
74
+ * names, or WorkTrust's own port on this computer. `/api/mcp` alone is every hosted MCP server's conventional path, so another product's entry is
75
+ * never adopted as the coupling, and never taken out with it.
76
+ */
77
+ /** WorkTrust's own local doors (the app in development, 3011; a module branch, 3021). Another product's local MCP server
78
+ * (the Advisory Board on 3007, 2026-10-09) is not one: it was offered for removal on update, and could have been adopted. */
79
+ const LOCAL_DOOR_PORTS = new Set(["3011", "3021"]);
80
+ export const isWorkTrustDoor = (url) => {
81
+ try {
82
+ const parsed = new URL(String(url));
83
+ if (!/\/api\/mcp\/?$/.test(parsed.pathname)) return false;
84
+ const own = process.env.WORKTRUST_MCP_URL ? new URL(process.env.WORKTRUST_MCP_URL).host : null;
85
+ return parsed.protocol === "https:" && (parsed.hostname === "worktrust.io" || parsed.hostname.endsWith(".worktrust.io")) || (own !== null && parsed.host === own) || ((parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") && LOCAL_DOOR_PORTS.has(parsed.port));
86
+ } catch { return false; }
87
+ };