@pi-in-go/pigpen-pi-typesafe 0.1.0

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.
Files changed (189) hide show
  1. package/CREDITS.md +14 -0
  2. package/LICENSE +22 -0
  3. package/README.md +45 -0
  4. package/extensions/pi-typesafe/branches_test.go +185 -0
  5. package/extensions/pi-typesafe/command.go +319 -0
  6. package/extensions/pi-typesafe/export_test.go +9 -0
  7. package/extensions/pi-typesafe/extension.go +188 -0
  8. package/extensions/pi-typesafe/extension_test.go +321 -0
  9. package/extensions/pi-typesafe/fakehost_test.go +548 -0
  10. package/extensions/pi-typesafe/format.go +191 -0
  11. package/extensions/pi-typesafe/format_test.go +75 -0
  12. package/extensions/pi-typesafe/go.mod +10 -0
  13. package/extensions/pi-typesafe/go.sum +2 -0
  14. package/extensions/pi-typesafe/go.work +11 -0
  15. package/extensions/pi-typesafe/harness_test.go +200 -0
  16. package/extensions/pi-typesafe/ownmodel_test.go +100 -0
  17. package/extensions/pi-typesafe/review_test.go +134 -0
  18. package/extensions/pi-typesafe/tool.go +193 -0
  19. package/extensions/pi-typesafe/twin_test.go +28 -0
  20. package/libs/pi-typesafe-api/CREDITS.md +14 -0
  21. package/libs/pi-typesafe-api/LICENSE +22 -0
  22. package/libs/pi-typesafe-api/README.md +30 -0
  23. package/libs/pi-typesafe-api/ask.go +62 -0
  24. package/libs/pi-typesafe-api/ask_test.go +76 -0
  25. package/libs/pi-typesafe-api/auth.go +249 -0
  26. package/libs/pi-typesafe-api/auth_test.go +131 -0
  27. package/libs/pi-typesafe-api/backends.go +336 -0
  28. package/libs/pi-typesafe-api/backends_test.go +404 -0
  29. package/libs/pi-typesafe-api/batch.go +202 -0
  30. package/libs/pi-typesafe-api/batch_test.go +202 -0
  31. package/libs/pi-typesafe-api/battery_test.go +41 -0
  32. package/libs/pi-typesafe-api/calibrate.go +354 -0
  33. package/libs/pi-typesafe-api/calibrate_test.go +186 -0
  34. package/libs/pi-typesafe-api/client.go +615 -0
  35. package/libs/pi-typesafe-api/client_test.go +490 -0
  36. package/libs/pi-typesafe-api/credentials.go +252 -0
  37. package/libs/pi-typesafe-api/credentials_test.go +216 -0
  38. package/libs/pi-typesafe-api/doc.go +14 -0
  39. package/libs/pi-typesafe-api/errors.go +143 -0
  40. package/libs/pi-typesafe-api/evaluation.go +86 -0
  41. package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
  42. package/libs/pi-typesafe-api/gaps_test.go +77 -0
  43. package/libs/pi-typesafe-api/go.mod +9 -0
  44. package/libs/pi-typesafe-api/go.sum +2 -0
  45. package/libs/pi-typesafe-api/helpers_test.go +169 -0
  46. package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
  47. package/libs/pi-typesafe-api/json.go +299 -0
  48. package/libs/pi-typesafe-api/json_test.go +92 -0
  49. package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
  50. package/libs/pi-typesafe-api/package.json +40 -0
  51. package/libs/pi-typesafe-api/provenance.json +18 -0
  52. package/libs/pi-typesafe-api/review_test.go +23 -0
  53. package/libs/pi-typesafe-api/schema.go +473 -0
  54. package/libs/pi-typesafe-api/schema_test.go +262 -0
  55. package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
  56. package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
  57. package/libs/pi-typesafe-api/twin_test.go +28 -0
  58. package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
  59. package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
  60. package/libs/pi-typesafe-api/ui/login.go +106 -0
  61. package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
  62. package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
  63. package/libs/pi-typesafe-api/usage.go +366 -0
  64. package/libs/pi-typesafe-api/usage_test.go +139 -0
  65. package/libs/typesafe/CONTRACT.md +125 -0
  66. package/libs/typesafe/CREDITS.md +37 -0
  67. package/libs/typesafe/LICENSE +23 -0
  68. package/libs/typesafe/README.md +19 -0
  69. package/libs/typesafe/go.mod +3 -0
  70. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  71. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  72. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  73. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  74. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  75. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  76. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  77. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  78. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  79. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  80. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  81. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  82. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  83. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  84. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  85. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  86. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  87. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  88. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  89. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  90. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  91. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  92. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  93. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  94. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  95. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  96. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  97. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  98. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  99. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  100. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  101. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  102. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  103. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  104. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  105. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  106. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  107. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  108. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  109. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  110. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  111. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  112. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  113. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  114. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  115. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  116. package/libs/typesafe/package.json +37 -0
  117. package/libs/typesafe/provenance.json +49 -0
  118. package/package.json +42 -0
  119. package/port/PORT.md +98 -0
  120. package/port/accepted-gaps.json +3 -0
  121. package/port/golden/enable-confirm.jsonl +11 -0
  122. package/port/golden/enable-decline.jsonl +20 -0
  123. package/port/golden/enable-missing-key.jsonl +4 -0
  124. package/port/golden/login-shadow.jsonl +4 -0
  125. package/port/golden/logout-env-key.jsonl +6 -0
  126. package/port/golden/playground-cancel.jsonl +4 -0
  127. package/port/golden/playground-invalid-json.jsonl +5 -0
  128. package/port/golden/playground-invalid-questions.jsonl +5 -0
  129. package/port/golden/status-env-key.jsonl +6 -0
  130. package/port/golden/status-no-key.jsonl +6 -0
  131. package/port/golden/tool-disabled.jsonl +18 -0
  132. package/port/golden/trailing-words.jsonl +10 -0
  133. package/port/library-mutations.py +44 -0
  134. package/port/mutations.json +302 -0
  135. package/port/oracle/.env.example +4 -0
  136. package/port/oracle/CHANGELOG.md +91 -0
  137. package/port/oracle/CONTRIBUTING.md +35 -0
  138. package/port/oracle/LICENSE +21 -0
  139. package/port/oracle/README.md +159 -0
  140. package/port/oracle/docs/api.md +143 -0
  141. package/port/oracle/docs/ci-cd.md +97 -0
  142. package/port/oracle/examples/decision-extension.ts +41 -0
  143. package/port/oracle/extensions/index.js +2 -0
  144. package/port/oracle/package.json +89 -0
  145. package/port/oracle/scripts/dev-pi.mjs +23 -0
  146. package/port/oracle/scripts/live-smoke.mjs +35 -0
  147. package/port/oracle/src/ask.ts +42 -0
  148. package/port/oracle/src/auth.ts +171 -0
  149. package/port/oracle/src/backends.ts +196 -0
  150. package/port/oracle/src/batch.ts +170 -0
  151. package/port/oracle/src/calibrate.ts +237 -0
  152. package/port/oracle/src/client.ts +310 -0
  153. package/port/oracle/src/credentials.ts +136 -0
  154. package/port/oracle/src/errors.ts +53 -0
  155. package/port/oracle/src/extension.ts +204 -0
  156. package/port/oracle/src/index.ts +31 -0
  157. package/port/oracle/src/key-prompt.ts +51 -0
  158. package/port/oracle/src/login.ts +60 -0
  159. package/port/oracle/src/schema.ts +158 -0
  160. package/port/oracle/src/ui.ts +4 -0
  161. package/port/oracle/src/usage.ts +258 -0
  162. package/port/oracle/tests/ask.test.ts +63 -0
  163. package/port/oracle/tests/auth.test.ts +141 -0
  164. package/port/oracle/tests/backends.test.ts +380 -0
  165. package/port/oracle/tests/batch.test.ts +156 -0
  166. package/port/oracle/tests/calibrate.test.ts +144 -0
  167. package/port/oracle/tests/client.test.ts +499 -0
  168. package/port/oracle/tests/credentials.test.ts +144 -0
  169. package/port/oracle/tests/extension.test.ts +276 -0
  170. package/port/oracle/tests/key-prompt.test.ts +47 -0
  171. package/port/oracle/tests/login.test.ts +101 -0
  172. package/port/oracle/tests/schema.test.ts +85 -0
  173. package/port/oracle/tests/usage.test.ts +106 -0
  174. package/port/oracle/tsconfig.build.json +10 -0
  175. package/port/oracle/tsconfig.json +14 -0
  176. package/port/scenarios/enable-confirm.json +5 -0
  177. package/port/scenarios/enable-decline.json +3 -0
  178. package/port/scenarios/enable-missing-key.json +2 -0
  179. package/port/scenarios/login-shadow.json +2 -0
  180. package/port/scenarios/logout-env-key.json +3 -0
  181. package/port/scenarios/playground-cancel.json +2 -0
  182. package/port/scenarios/playground-invalid-json.json +2 -0
  183. package/port/scenarios/playground-invalid-questions.json +2 -0
  184. package/port/scenarios/status-env-key.json +3 -0
  185. package/port/scenarios/status-no-key.json +3 -0
  186. package/port/scenarios/tool-disabled.json +2 -0
  187. package/port/scenarios/trailing-words.json +5 -0
  188. package/port/upstream-tests.json +160 -0
  189. package/provenance.json +18 -0
package/CREDITS.md ADDED
@@ -0,0 +1,14 @@
1
+ # Credits
2
+
3
+ This Package is a Go port of **pi-typesafe** 0.8.0, https://github.com/DevMortimer/pi-typesafe, by
4
+ **Ryan Gapac** (MIT, Copyright (c) 2026 Ryan Gapac). It is an independent project, not affiliated with TypeSafe AI or
5
+ the Pi authors, and so is this port.
6
+
7
+ - Pinned commit: `ed439f834665ad6fc652787f5ba7c23852566d49` (release 0.8.0, "Add Command Code backend and caller-supplied endpoints")
8
+ - The unmodified original, with its tests, is vendored in `components/pi-typesafe/port/oracle/`; its license is in `LICENSE`
9
+ of this Package and in `components/pi-typesafe/port/oracle/LICENSE`.
10
+ - What is modified: everything. The behavior, wording and structure follow the original; the code is a Go
11
+ implementation over the shared TypeSafe client (`components/typesafe`, a port of the official `@typesafe-ai/sdk`).
12
+ The mapping from each original file to its Go file is in `components/pi-typesafe/port/PORT.md`, with every deliberate difference.
13
+
14
+ The Go code, scenarios and tests were written for Pigpen by Michael Kinsy.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Gapac
4
+ Copyright (c) 2026 Michael Kinsy (the Go port)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # pi-typesafe (Go port)
2
+
3
+ [Jev](https://typesafe.ai) inside PiG: TypeSafe's judgment model answers typed questions about some state and returns
4
+ probabilities instead of prose. This Package gives PiG three things:
5
+
6
+ - **A tool for the agent**: `typesafe_evaluate` hands batched Choice, Score and Noul questions to Jev in one call.
7
+ - **A playground**: `/typesafe test` and `/typesafe playground` run requests from the terminal without touching the model's context.
8
+ - **A typed API for other extensions**: the library half is the Package [`pi-typesafe-api`](../pi-typesafe-api/README.md).
9
+
10
+ It is a Go port of [pi-typesafe](https://github.com/DevMortimer/pi-typesafe) by Ryan Gapac (MIT; see [CREDITS.md](CREDITS.md)),
11
+ built on the shared client [`typesafe`](../typesafe/README.md). Independent project: not affiliated with TypeSafe AI.
12
+
13
+ ## Off by default, and where your content goes
14
+
15
+ The tool is registered but **disabled**: nothing is sent until the operator runs `/typesafe enable` (and confirms the notice) or
16
+ sets `PI_TYPESAFE_ENABLED=1` for a headless run. Every submission goes to **`api.typesafe.ai`** and may incur charges, **unless the
17
+ own-model backend is selected**, which answers with the model PiG is configured with and sends the content to *that model's
18
+ provider* instead, never to TypeSafe. Select it with `PI_TYPESAFE_BACKEND=ownmodel` before launch or `/typesafe backend ownmodel`
19
+ in a session. Consent is per destination: switching backend disables the tool again. The consent dialog and the tool description
20
+ name the destination in use. Do not submit secrets; results are model judgments, not proof or authorization.
21
+
22
+ ## Use
23
+
24
+ ```sh
25
+ pig install ./components/pi-typesafe # builds from source on first use (Go toolchain), or fuses into a Piglet Binary
26
+ ```
27
+
28
+ Inside PiG: `/typesafe login` (hidden input; the key is verified and stored with owner-only permissions in
29
+ `<PiG agent dir>/pi-typesafe/`), `/typesafe test`, `/typesafe enable`. For CI set `TYPESAFE_API_KEY`; it wins over the stored key.
30
+ Commands: `login logout setup status enable disable test playground` and `backend [typesafe|ownmodel]`.
31
+ Limits: 32 questions and 64 KiB of JSON per request, 20 attempts per session, 15 s per request, no automatic retries; optional
32
+ day caps `PI_TYPESAFE_MAX_REQUESTS_PER_DAY`, `PI_TYPESAFE_MAX_INPUT_TOKENS_PER_DAY`, `PI_TYPESAFE_MAX_USD_PER_DAY`.
33
+
34
+ The **Piglet** [`pig-typesafe`](../../piglets/pig-typesafe/README.md) selects this Package (still off by default).
35
+
36
+ ## Differences from the original
37
+
38
+ Listed with reasons in [port/PORT.md](port/PORT.md): PiG's agent directory, the own-model backend, question order in tool
39
+ arguments (the SDK decodes them into maps), TypeBox-versus-host validation wording, and a backend switch that stops a request
40
+ admitted before it (nothing is sent to a destination nobody consented to).
41
+
42
+ ## Proof
43
+
44
+ `port/` holds the evidence: the unmodified original and its 134 tests (`port/oracle`), Go twins of every test case, 12 scenarios
45
+ whose traces the original recorded under Pi (re-recorded on Pi 1.0.1) and the port reproduces under PiG (`port/golden`), and a mutation list.
@@ -0,0 +1,185 @@
1
+ package pi_typesafe_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "os"
7
+ "path/filepath"
8
+ "runtime"
9
+ "strings"
10
+ "testing"
11
+
12
+ pi_typesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe/extensions/pi-typesafe"
13
+ )
14
+
15
+ func (r *rig) lastLevel() string { r.mu.Lock(); defer r.mu.Unlock(); return r.levels[len(r.levels)-1] }
16
+
17
+ func TestStartupCalloutIsAWarningNotificationWithUI(t *testing.T) {
18
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
19
+ r := newRig(t)
20
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
21
+ before := r.noticeCount()
22
+ r.startSession("startup")
23
+ if r.noticeCount() != before+1 || r.lastLevel() != "warning" || len(r.messages) != 0 {
24
+ t.Fatalf("notices=%v levels=%v messages=%v", r.notices, r.levels, r.messages)
25
+ }
26
+ }
27
+
28
+ func TestStartupCalloutWithoutUIIsAStatusMessage(t *testing.T) {
29
+ isolate(t)
30
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
31
+ no := false
32
+ var notices, messages, types []string
33
+ host := StartHost(t, pi_typesafe.New(pi_typesafe.Options{}), HostOptions{HasUI: &no, Mode: "print", OnCall: func(method string, args map[string]any) (map[string]any, string) {
34
+ switch method {
35
+ case "ui.notify":
36
+ notices = append(notices, args["message"].(string))
37
+ case "sendMessage":
38
+ m := args["message"].(map[string]any)
39
+ messages = append(messages, m["content"].(string))
40
+ types = append(types, m["customType"].(string))
41
+ }
42
+ return nil, ""
43
+ }})
44
+ host.Fire("session_start", map[string]any{"reason": "startup"})
45
+ if len(notices) != 0 || len(messages) != 1 || types[0] != "typesafe-status" || !strings.Contains(messages[0], "judgments are skipped") {
46
+ t.Fatalf("notices=%v messages=%v", notices, messages)
47
+ }
48
+ }
49
+
50
+ func TestForbiddenKeyIsCalledOutLikeARejectedOne(t *testing.T) {
51
+ r := newRig(t)
52
+ t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
53
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
54
+ r.startSession("startup")
55
+ r.respond = func(*http.Request) *http.Response { return jsonResponse(403, map[string]any{"error": "no"}) }
56
+ before := r.noticeCount()
57
+ if _, failure := r.tool(nil); !strings.Contains(failure, "HTTP 403") {
58
+ t.Fatalf("failure = %q", failure)
59
+ }
60
+ said := strings.Join(r.noticesSince(before), "\n")
61
+ if !strings.Contains(said, "TypeSafe is not authenticated (TypeSafe returned HTTP 403.") || !strings.Contains(said, "Judgments will fail until the key is fixed.") {
62
+ t.Fatalf("said = %q", said)
63
+ }
64
+ // A busy service is not an authentication problem: no callout.
65
+ r.respond = func(*http.Request) *http.Response { return jsonResponse(503, map[string]any{}) }
66
+ before = r.noticeCount()
67
+ _, _ = r.tool(nil)
68
+ if r.noticeCount() != before {
69
+ t.Fatalf("a 503 must not call out: %v", r.noticesSince(before))
70
+ }
71
+ }
72
+
73
+ func TestLogoutForgetsTheAuthRecord(t *testing.T) {
74
+ r := newRig(t)
75
+ t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
76
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
77
+ r.startSession("startup")
78
+ if _, failure := r.tool(nil); failure != "" {
79
+ t.Fatal(failure)
80
+ }
81
+ record := filepath.Join(r.agentDir, "pi-typesafe", "auth-state.json")
82
+ if !fileExists(record) {
83
+ t.Fatal("a successful request records the verification")
84
+ }
85
+ r.command("logout")
86
+ if fileExists(record) {
87
+ t.Fatal("logout must forget verification and degradation")
88
+ }
89
+ }
90
+
91
+ func TestEnableRefusesAnUnusableStoredKey(t *testing.T) {
92
+ if runtime.GOOS == "windows" {
93
+ t.Skip("POSIX permission bits")
94
+ }
95
+ r := newRig(t)
96
+ stored := filepath.Join(r.agentDir, "pi-typesafe", "auth.json")
97
+ if err := os.MkdirAll(filepath.Dir(stored), 0o700); err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ if err := os.WriteFile(stored, []byte(`{"apiKey":"stored-key-0123456789abcdef"}`), 0o644); err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ _ = os.Chmod(stored, 0o644)
104
+ prior := r.confirmations
105
+ r.command("enable")
106
+ if !strings.HasPrefix(r.lastNotice(), "The stored key cannot be used. Refusing to read") || r.lastLevel() != "warning" || r.confirmations != prior {
107
+ t.Fatalf("notice = %s (%s)", r.lastNotice(), r.lastLevel())
108
+ }
109
+ r.command("setup")
110
+ if !strings.Contains(r.lastNotice(), "Key unusable — Refusing to read") {
111
+ t.Fatalf("setup = %s", r.lastNotice())
112
+ }
113
+ }
114
+
115
+ func TestSampleTestAsksForConsentThenRecordsAnEntry(t *testing.T) {
116
+ r := newRig(t)
117
+ t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
118
+ r.respond = func(*http.Request) *http.Response {
119
+ return jsonResponse(200, map[string]any{"model": "jev-test", "usage": map[string]any{"input_tokens": 9, "output_tokens": 0}, "answers": map[string]any{
120
+ "category": map[string]any{"type": "choice", "choice": "billing", "confidence": 1, "probabilities": map[string]any{"billing": 1, "technical": 0, "other": 0}},
121
+ "urgent": map[string]any{"type": "noul", "noul": 0.8},
122
+ "frustration": map[string]any{"type": "score", "score": 1, "confidence": 1, "probabilities": map[string]any{"0": 0, "1": 1, "2": 0}, "legend": map[string]any{"0": "a", "1": "b", "2": "c"}},
123
+ }})
124
+ }
125
+ r.command("test")
126
+ if len(r.confirmTitles) != 1 || r.confirmTitles[0] != "Send this TypeSafe request?" || !strings.Contains(r.confirmBodies[0], "api.typesafe.ai") {
127
+ t.Fatalf("confirmations = %v %v", r.confirmTitles, r.confirmBodies)
128
+ }
129
+ if len(r.entryTypes) != 1 || r.entryTypes[0] != "typesafe-result" || r.network.Load() != 1 {
130
+ t.Fatalf("entries = %v network=%d", r.entryTypes, r.network.Load())
131
+ }
132
+ // The entry keeps the question order for the renderer.
133
+ if !strings.Contains(string(r.entries[0]), `"order":["category","urgent","frustration"]`) {
134
+ t.Fatalf("entry = %s", r.entries[0])
135
+ }
136
+ }
137
+
138
+ func TestRenderersDrawCallPartialAndEntry(t *testing.T) {
139
+ r := newRig(t)
140
+ render := func(args map[string]any) string {
141
+ raw, failure := r.host.roundTrip(map[string]any{"method": "render_tool", "tool": "typesafe_evaluate", "args": mustJSON(args)})
142
+ if failure != "" {
143
+ t.Fatal(failure)
144
+ }
145
+ return string(raw)
146
+ }
147
+ if got := render(map[string]any{"card": "a", "phase": "call", "width": 80, "args": map[string]any{"questions": map[string]any{"a": 1, "b": 2}}}); !strings.Contains(got, "TypeSafe · 2 questions · external request") {
148
+ t.Errorf("call = %s", got)
149
+ }
150
+ if got := render(map[string]any{"card": "a", "phase": "result", "width": 80, "options": map[string]any{"isPartial": true}}); !strings.Contains(got, "TypeSafe · waiting for response") {
151
+ t.Errorf("partial = %s", got)
152
+ }
153
+ if got := render(map[string]any{"card": "a", "phase": "result", "width": 80, "result": map[string]any{"content": []any{map[string]any{"type": "text", "text": "plain failure"}}}}); !strings.Contains(got, "plain failure") {
154
+ t.Errorf("text fallback = %s", got)
155
+ }
156
+ raw, failure := r.host.roundTrip(map[string]any{"method": "render_entry", "tool": "typesafe-result", "args": mustJSON(map[string]any{"entry": map[string]any{}, "width": 80})})
157
+ if failure != "" || !strings.Contains(string(raw), "TypeSafe · no result") {
158
+ t.Errorf("empty entry = %s %s", raw, failure)
159
+ }
160
+ }
161
+
162
+ func TestToolContentIsTheEvaluationJSONTheModelReads(t *testing.T) {
163
+ r := newRig(t)
164
+ t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
165
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
166
+ r.startSession("startup")
167
+ raw, failure := r.host.Tool("typesafe_evaluate", defaultParams)
168
+ if failure != "" {
169
+ t.Fatal(failure)
170
+ }
171
+ var result struct {
172
+ Content string `json:"content"`
173
+ Details map[string]any `json:"details"`
174
+ }
175
+ if err := json.Unmarshal(raw, &result); err != nil {
176
+ t.Fatal(err)
177
+ }
178
+ var content map[string]any
179
+ if err := json.Unmarshal([]byte(result.Content), &content); err != nil {
180
+ t.Fatalf("content is not JSON: %q", result.Content)
181
+ }
182
+ if content["model"] != "jev-test" || content["elapsedMs"] == nil || content["order"] != nil || result.Details["order"] == nil {
183
+ t.Fatalf("content = %s details = %v", result.Content, result.Details)
184
+ }
185
+ }
@@ -0,0 +1,319 @@
1
+ package pi_typesafe
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "strings"
8
+
9
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
10
+ pitypesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api"
11
+ "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api/hostmodel"
12
+ "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api/ui"
13
+ )
14
+
15
+ // actions are the /typesafe subcommands the usage text lists, in the original's order.
16
+ var actions = []string{"login", "logout", "setup", "status", "enable", "disable", "test", "playground"}
17
+
18
+ // backendAction is this port's addition (the own-model backend): it is completed but not part of the usage text.
19
+ const backendAction = "backend"
20
+
21
+ func (s *state) registerCommand() {
22
+ s.ext.RegisterCommand("typesafe", sdk.CommandOptions{
23
+ Description: "TypeSafe login, consent, usage, sample test, and JSON playground",
24
+ GetArgumentCompletions: func(prefix string) ([]sdk.AutocompleteItem, error) {
25
+ var items []sdk.AutocompleteItem
26
+ for _, a := range append(append([]string(nil), actions...), backendAction) {
27
+ if strings.HasPrefix(a, prefix) {
28
+ items = append(items, sdk.AutocompleteItem{Value: a, Label: a})
29
+ }
30
+ }
31
+ return items, nil
32
+ },
33
+ Handler: s.command,
34
+ })
35
+ }
36
+
37
+ func (s *state) reporter(ctx sdk.Context) func(text, level string) {
38
+ return func(text, level string) {
39
+ if ctx.HasUI() {
40
+ ctx.Notify(text, level)
41
+ return
42
+ }
43
+ _ = ctx.SendMessage(statusType, text, true, sdk.SendMessageOptions{})
44
+ }
45
+ }
46
+
47
+ func contains(list []string, v string) bool {
48
+ for _, e := range list {
49
+ if e == v {
50
+ return true
51
+ }
52
+ }
53
+ return false
54
+ }
55
+
56
+ func (s *state) command(ctx sdk.Context, args string) error {
57
+ // The original takes the whole argument text as the action (args.trim() || "status"), so trailing words make
58
+ // an unknown action; only this port's backend action takes an argument.
59
+ text := strings.TrimSpace(args)
60
+ fields := strings.Fields(text)
61
+ action := text
62
+ if action == "" {
63
+ action = "status"
64
+ }
65
+ if len(fields) > 0 && fields[0] == backendAction {
66
+ action = backendAction
67
+ }
68
+ report := s.reporter(ctx)
69
+ err := s.run(ctx, action, fields, report)
70
+ if err != nil {
71
+ report(pitypesafe.SafeError(err, s.currentBackend()).Message, "error")
72
+ }
73
+ return nil
74
+ }
75
+
76
+ func (s *state) run(ctx sdk.Context, action string, fields []string, report func(string, string)) error {
77
+ backend := s.currentBackend()
78
+ switch action {
79
+ case "status":
80
+ return s.status(ctx, report)
81
+ case "logout":
82
+ removed := pitypesafe.ClearStoredAPIKey()
83
+ pitypesafe.ClearAuthState()
84
+ s.resetClient()
85
+ s.setEnabled(false)
86
+ if removed {
87
+ report("Removed the stored key at "+pitypesafe.CredentialsPath()+". TypeSafe is disabled.", "info")
88
+ } else {
89
+ text := "No stored key to remove."
90
+ if pitypesafe.EnvKeySet() {
91
+ text += " TYPESAFE_API_KEY is still set in the environment."
92
+ }
93
+ report(text, "info")
94
+ }
95
+ return nil
96
+ case "disable":
97
+ s.setEnabled(false)
98
+ report("TypeSafe disabled for future agent calls. In-flight requests are not cancelled.", "info")
99
+ return nil
100
+ case backendAction:
101
+ return s.chooseBackend(fields, report)
102
+ }
103
+ if !contains(actions, action) {
104
+ report("Usage: /typesafe "+strings.Join(actions, " | "), "warning")
105
+ return nil
106
+ }
107
+ if !ctx.HasUI() {
108
+ report("This command needs interactive Pi. For headless tool use, explicitly set PI_TYPESAFE_ENABLED=1 and TYPESAFE_API_KEY before launching Pi.", "warning")
109
+ return nil
110
+ }
111
+ situation, err := pitypesafe.KeySituationFor(backend)
112
+ if err != nil {
113
+ return err
114
+ }
115
+ if backend == pitypesafe.BackendOwnModel && (action == "login" || action == "setup") {
116
+ report("The own-model backend needs no key: it answers with the model PiG is configured with. Use /typesafe backend typesafe to use the TypeSafe API.", "info")
117
+ return nil
118
+ }
119
+ if action == "login" || (action == "setup" && situation.Kind == pitypesafe.KeyMissing) {
120
+ if pitypesafe.EnvKeySet() {
121
+ report("TYPESAFE_API_KEY is set in the environment and takes precedence over a stored key. Unset it before using /typesafe login.", "warning")
122
+ return nil
123
+ }
124
+ login, err := ui.LoginWithPrompt(ctx, ui.LoginOptions{HTTPClient: s.opts.HTTPClient})
125
+ if err != nil {
126
+ return err
127
+ }
128
+ if login == nil {
129
+ report("Login cancelled; nothing was saved.", "info")
130
+ return nil
131
+ }
132
+ s.resetClient()
133
+ plural := "s"
134
+ if login.Models == 1 {
135
+ plural = ""
136
+ }
137
+ report(fmt.Sprintf("Key verified (%d model%s available) and saved to %s with owner-only permissions. Run /typesafe enable to allow agent tool calls.", login.Models, plural, login.Path), "info")
138
+ return nil
139
+ }
140
+ if action == "setup" {
141
+ current := "missing"
142
+ switch situation.Kind {
143
+ case pitypesafe.KeyEnvironment, pitypesafe.KeyStored:
144
+ current = "configured via " + pitypesafe.KeySourceLabel(situation)
145
+ case pitypesafe.KeyUnusable:
146
+ current = "unusable — " + situation.Reason
147
+ }
148
+ report("Key "+current+". Run /typesafe test for one sample request or /typesafe enable to allow agent tool calls.", "info")
149
+ return nil
150
+ }
151
+ if action == "enable" {
152
+ switch situation.Kind {
153
+ case pitypesafe.KeyMissing:
154
+ report("Run /typesafe login first: no API key is configured.", "warning")
155
+ return nil
156
+ case pitypesafe.KeyUnusable:
157
+ report("The stored key cannot be used. "+situation.Reason, "warning")
158
+ return nil
159
+ }
160
+ ok, err := ctx.Confirm("Enable TypeSafe for this session?", disclosure(backend))
161
+ if err != nil {
162
+ return err
163
+ }
164
+ if ok {
165
+ s.setEnabled(true)
166
+ report(fmt.Sprintf("TypeSafe enabled. Up to %d attempts in this session; /typesafe disable stops future agent calls.", pitypesafe.DefaultMaxRequests), "info")
167
+ }
168
+ return nil
169
+ }
170
+ return s.sample(ctx, action)
171
+ }
172
+
173
+ // sample is /typesafe test and /typesafe playground: validate, confirm, send, and show the result without
174
+ // putting it in the model's context.
175
+ func (s *state) sample(ctx sdk.Context, action string) error {
176
+ var request any = mustParse(sampleJSON)
177
+ if action == "playground" {
178
+ pretty := indent(sampleJSON)
179
+ text, ok, err := ctx.Editor("TypeSafe request JSON · edit state and questions", pretty)
180
+ if err != nil {
181
+ return err
182
+ }
183
+ if !ok {
184
+ return nil
185
+ }
186
+ parsed, err := pitypesafe.ParseJSON([]byte(text))
187
+ if err != nil {
188
+ s.reporter(ctx)("Invalid JSON. Keep quoted strings on one line; nothing was sent.", "error")
189
+ return nil
190
+ }
191
+ request = parsed
192
+ }
193
+ validated, err := pitypesafe.PrepareEvaluationRequest(request, pitypesafe.PrepareOptions{})
194
+ if err != nil {
195
+ return err
196
+ }
197
+ consent := s.consentNow()
198
+ ok, err := ctx.Confirm("Send this TypeSafe request?", disclosure(s.currentBackend()))
199
+ if err != nil || !ok {
200
+ return err
201
+ }
202
+ // The operator confirmed one destination; a backend switch while the dialog was open voids that.
203
+ client, err := s.clientFor(consent)
204
+ if err != nil {
205
+ return err
206
+ }
207
+ goCtx, cancel := goContext(ctx)
208
+ defer cancel()
209
+ result, err := client.EvaluateRaw(goCtx, validated)
210
+ if err != nil {
211
+ return err
212
+ }
213
+ // Playground results stay out of LLM context; the agent tool returns its own results normally.
214
+ details, err := result.DetailsJSON()
215
+ if err != nil {
216
+ return err
217
+ }
218
+ return ctx.AppendEntry(entryType, json.RawMessage(details))
219
+ }
220
+
221
+ func mustParse(text string) any {
222
+ v, err := pitypesafe.ParseJSON([]byte(text))
223
+ if err != nil {
224
+ panic(err)
225
+ }
226
+ return v
227
+ }
228
+
229
+ // indent is JSON.stringify(value, null, 2).
230
+ func indent(compact string) string {
231
+ var b bytes.Buffer
232
+ if err := json.Indent(&b, []byte(compact), "", " "); err != nil {
233
+ return compact
234
+ }
235
+ return b.String()
236
+ }
237
+
238
+ func (s *state) status(ctx sdk.Context, report func(string, string)) error {
239
+ backend := s.currentBackend()
240
+ state, err := pitypesafe.GetAuthState(pitypesafe.AuthOptions{Backend: backend})
241
+ if err != nil {
242
+ return err
243
+ }
244
+ auth, err := pitypesafe.DescribeAuth(state)
245
+ if err != nil {
246
+ return err
247
+ }
248
+ client := s.peekClient()
249
+ session := fmt.Sprintf("Session 0/%d attempts; no client yet in this session.", pitypesafe.DefaultMaxRequests)
250
+ today, blocked := "", ""
251
+ if client != nil {
252
+ spend := client.GetSpend()
253
+ session = fmt.Sprintf("Session %d/%d attempts, %d successful, %d failed, %d input tokens (~$%.4f).",
254
+ spend.Session.RequestsStarted, pitypesafe.DefaultMaxRequests, spend.Session.RequestsSucceeded, spend.Session.RequestsFailed, spend.Session.InputTokens, spend.Session.EstimatedUSD)
255
+ today = fmt.Sprintf("Today %d requests (%d ok, %d failed), %d input tokens, ~$%.4f.",
256
+ spend.Today.RequestsStarted, spend.Today.RequestsSucceeded, spend.Today.RequestsFailed, spend.Today.InputTokens, spend.Today.EstimatedUSD)
257
+ if b := spend.Blocked; b != nil {
258
+ blocked = fmt.Sprintf(" Cap reached: %s %s/%s on %s; no request will be submitted until the local day rolls over.", b.Cap, num(b.Used), num(b.Limit), b.Day)
259
+ }
260
+ }
261
+ enabled := s.isEnabled()
262
+ word := "disabled"
263
+ if enabled {
264
+ word = "enabled"
265
+ }
266
+ level := "info"
267
+ if auth.Level == pitypesafe.LevelError && enabled {
268
+ level = "warning"
269
+ }
270
+ model := pitypesafe.DefaultModelID(pitypesafe.DefaultBackend)
271
+ if backend == pitypesafe.BackendOwnModel {
272
+ model = "the model PiG is configured with"
273
+ if ref, err := hostmodel.Active(ctx); err == nil {
274
+ model = ref.Provider + "/" + ref.ID
275
+ }
276
+ }
277
+ report(fmt.Sprintf("TypeSafe: %s. %s %s %s%s Model: %s. Session limits reset on session start/reload; daily counters persist and caps come from client options or PI_TYPESAFE_MAX_* environment variables. %s",
278
+ word, auth.Text, session, today, blocked, model, disclosure(backend)), level)
279
+ return nil
280
+ }
281
+
282
+ func num(v float64) string {
283
+ b, _ := pitypesafe.EncodeJSON(v)
284
+ return string(b)
285
+ }
286
+
287
+ // chooseBackend switches between the TypeSafe API and the own-model backend. Consent is per destination, so a
288
+ // switch disables the tool until the operator enables it again.
289
+ func (s *state) chooseBackend(fields []string, report func(string, string)) error {
290
+ current := s.currentBackend()
291
+ if len(fields) < 2 {
292
+ report(fmt.Sprintf("Judgment backend: %s. Options: %s (api.typesafe.ai), %s (the model PiG is configured with). Set PI_TYPESAFE_BACKEND before launch to choose the startup backend.",
293
+ current, pitypesafe.BackendTypeSafe, pitypesafe.BackendOwnModel), "info")
294
+ return nil
295
+ }
296
+ choice := fields[1]
297
+ if choice != pitypesafe.BackendTypeSafe && choice != pitypesafe.BackendOwnModel {
298
+ report(fmt.Sprintf("Unknown backend %q. Options: %s, %s.", choice, pitypesafe.BackendTypeSafe, pitypesafe.BackendOwnModel), "warning")
299
+ return nil
300
+ }
301
+ if choice == current {
302
+ report("Backend is already "+choice+".", "info")
303
+ return nil
304
+ }
305
+ s.mu.Lock()
306
+ s.backend = choice
307
+ s.consent++
308
+ s.enabled = false
309
+ s.client = nil
310
+ s.calledOut = ""
311
+ s.mu.Unlock()
312
+ s.registerTool()
313
+ destination := "api.typesafe.ai"
314
+ if choice == pitypesafe.BackendOwnModel {
315
+ destination = "the provider of the model PiG is configured with"
316
+ }
317
+ report(fmt.Sprintf("Backend set to %s for this session. TypeSafe is disabled: run /typesafe enable to consent to sending content to %s.", choice, destination), "info")
318
+ return nil
319
+ }
@@ -0,0 +1,9 @@
1
+ package pi_typesafe
2
+
3
+ // SetAdmittedHook replaces the seam that runs between a tool call's consent gate and its send, and returns
4
+ // the function that restores the previous one. Tests use it to change the backend at that moment.
5
+ func SetAdmittedHook(f func()) (restore func()) {
6
+ previous := admitted
7
+ admitted = f
8
+ return func() { admitted = previous }
9
+ }