@evoclock/pi-agentic-driver 0.9.1 → 0.9.3
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.
- package/.agentic-driver/router.defaults.json +64 -0
- package/LICENSE +11 -8
- package/README.md +116 -32
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +1 -1
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +1 -1
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +33 -2
- package/extensions/task-board.ts +1 -1
- package/janus/README.draft.md +210 -0
- package/janus/README.md +196 -0
- package/janus/janus/audit.ts +213 -0
- package/janus/janus/budget.ts +101 -0
- package/janus/janus/budget_breaker.ts +105 -0
- package/janus/janus/config.ts +332 -0
- package/janus/janus/errors.ts +90 -0
- package/janus/janus/evaluate.ts +230 -0
- package/janus/janus/keychain.ts +119 -0
- package/janus/janus/providers.ts +544 -0
- package/janus/janus/schema.ts +344 -0
- package/janus/janus/server.ts +267 -0
- package/janus/janus/single_flight.ts +99 -0
- package/janus/janus/verdict.ts +192 -0
- package/janus/package-lock.json +616 -0
- package/janus/package.json +29 -0
- package/janus/tests/accounting.test.ts +229 -0
- package/janus/tests/allowlist.test.ts +97 -0
- package/janus/tests/config.test.ts +151 -0
- package/janus/tests/errors.test.ts +147 -0
- package/janus/tests/evaluate_pipeline.test.ts +358 -0
- package/janus/tests/helpers.ts +217 -0
- package/janus/tests/providers.test.ts +292 -0
- package/janus/tests/redaction_budget.test.ts +227 -0
- package/janus/tests/schema.test.ts +356 -0
- package/janus/tests/server.test.ts +288 -0
- package/janus/tests/single_flight.test.ts +84 -0
- package/janus/tsconfig.json +19 -0
- package/janus/user.janus.plist +51 -0
- package/lib/adapters/diff-scope.mjs +1 -1
- package/lib/adapters/evidence.mjs +1 -1
- package/lib/adapters/narrative.mjs +1 -1
- package/lib/adapters/review-feedback.mjs +1 -1
- package/lib/adapters/visualization.mjs +1 -1
- package/lib/code-phage-core.mjs +1 -1
- package/lib/python_ast_metrics.py +1 -1
- package/lib/typescript_ast_metrics.mjs +1 -1
- package/package.json +14 -3
- package/scripts/aidr_writing_review.js +1 -1
- package/scripts/enforcement/attended_authority_guard.js +1 -1
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
- package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
- package/scripts/enforcement/herdr_communication_pi.js +13 -8
- package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
- package/scripts/enforcement/native_tui_context.js +1 -1
- package/scripts/enforcement/pulse_core_pi.js +38 -14
- package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
- package/scripts/enforcement/router_engine_pi.js +483 -0
- package/scripts/enforcement/router_ranking_pi.js +146 -0
- package/scripts/enforcement/router_replacement_pi.js +98 -0
- package/scripts/enforcement/router_runtime_pi.js +299 -0
- package/scripts/enforcement/router_schemas_pi.js +654 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +255 -44
- package/skills/pulse/SKILL.md +1 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# janus — Jev evaluation wrapper service
|
|
2
|
+
|
|
3
|
+
janus is the local wrapper service for the Jev decision layer (design of
|
|
4
|
+
record: `.private-planning/JEV_DECISION_LAYER_DESIGN.md` §5–§7, Revision 2.1).
|
|
5
|
+
It wraps the official `@typesafe-ai/sdk` (TypeSafe Direct — the sole
|
|
6
|
+
provider; the Vercel AI Gateway and merge gateway were dropped by owner
|
|
7
|
+
decision) and exposes one evaluation endpoint plus health/readiness/version
|
|
8
|
+
probes on loopback.
|
|
9
|
+
|
|
10
|
+
## What janus does
|
|
11
|
+
|
|
12
|
+
- `POST /evaluate` — evaluates typed questions (boolean / choice / score)
|
|
13
|
+
against one shared state via TypeSafe Direct (`typesafe-ai`, model `jev-latest`).
|
|
14
|
+
- `GET /healthz` — liveness.
|
|
15
|
+
- `GET /readyz` — readiness (gateway key resolvable from the Keychain).
|
|
16
|
+
- `GET /version` — janus version, AI SDK version, schema versions, allowlist.
|
|
17
|
+
|
|
18
|
+
Guarantees:
|
|
19
|
+
|
|
20
|
+
- **Model allowlist** (server-side config; request-level model overrides are
|
|
21
|
+
rejected with `model_unauthorized`).
|
|
22
|
+
- **Loopback only** — binds `127.0.0.1` and nothing else. No auth beyond
|
|
23
|
+
loopback; it is a local tool. There is deliberately no bind-address option.
|
|
24
|
+
- **Single-flight** — identical requests (same evalInputDigest) coalesce;
|
|
25
|
+
distinct in-flight evaluations are capped (default 4) and rejected with
|
|
26
|
+
`busy`.
|
|
27
|
+
- **30k-token request budget** — oversized requests are rejected with
|
|
28
|
+
`state_too_large`, never truncated silently.
|
|
29
|
+
- **Redaction** — a general secrets/PII pass runs over the state before any
|
|
30
|
+
external call.
|
|
31
|
+
- **Audit log** — append-only JSONL of request id, timestamps, question
|
|
32
|
+
names, verdict probabilities, and token usage. **State contents never
|
|
33
|
+
reach the audit log.** 90-day default retention, size-capped with
|
|
34
|
+
oldest-first rotation.
|
|
35
|
+
- **Daily cost breaker** — token/cost ceilings in config; on exceed,
|
|
36
|
+
`/evaluate` returns `busy` with `budget_exhausted` detail until the UTC
|
|
37
|
+
day window resets.
|
|
38
|
+
- **Timeouts** — 30s service-side cap; callers may pass a shorter
|
|
39
|
+
per-request `timeoutMs` (capped at 30s).
|
|
40
|
+
|
|
41
|
+
## Provisioning the Keychain item
|
|
42
|
+
|
|
43
|
+
The TypeSafe Direct key is read from the macOS Keychain at runtime — janus
|
|
44
|
+
does not rely on inherited environment variables. The `typesafe-ai` /
|
|
45
|
+
`Typesafe AI` generic-password item does not exist by default; create it
|
|
46
|
+
once (interactive provisioning):
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
security add-generic-password -U -a typesafe-ai -s "Typesafe AI" -w
|
|
50
|
+
# prompts for the secret; nothing is echoed
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
janus reads it with `security find-generic-password -w` under guarded
|
|
54
|
+
capture: stdout is piped directly into memory, never echoed, never logged,
|
|
55
|
+
never passed to spawned subprocesses. If the item is missing, janus returns
|
|
56
|
+
`model_unauthorized` with `detail.key_unavailable: true` and `GET /readyz`
|
|
57
|
+
reports 503 with the provisioning hint.
|
|
58
|
+
|
|
59
|
+
Verify the item is present (no secret material is printed):
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
/usr/bin/security find-generic-password -s "Typesafe AI" -a typesafe-ai >/dev/null 2>&1 && echo present
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
To rotate the secret, re-run the `security add-generic-password -U` command
|
|
66
|
+
above and enter the new value at the prompt (`-U` updates the existing item).
|
|
67
|
+
To remove the item entirely:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
security delete-generic-password -a typesafe-ai -s "Typesafe AI"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Running
|
|
74
|
+
|
|
75
|
+
Dev (from `janus/`):
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npx tsx janus/server.ts # listens on 127.0.0.1:8787
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Config is read from `~/.config/typesafe-ai/jev/janus-config.json`
|
|
82
|
+
(override with `JANUS_CONFIG_PATH`). Defaults work with no file:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"port": 8787,
|
|
87
|
+
"modelAllowlist": ["typesafe-ai/jev"],
|
|
88
|
+
"defaultModel": "typesafe-ai/jev",
|
|
89
|
+
"maxConcurrent": 4,
|
|
90
|
+
"dailyTokenCeiling": 2000000,
|
|
91
|
+
"dailyCostCeilingUsd": 10,
|
|
92
|
+
"audit": { "path": "~/.local/share/janus/audit", "maxBytes": 10485760, "retentionDays": 90 }
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
launchd — template at `janus/user.janus.plist`. The template is a starting
|
|
97
|
+
point: every user-specific value (the label, the checkout path, log
|
|
98
|
+
locations) is marked ADJUST in the file. Copy it out of the repository,
|
|
99
|
+
adjust it, then load it:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
cp janus/user.janus.plist ~/Library/LaunchAgents/
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
launchctl load ~/Library/LaunchAgents/user.janus.plist
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Tests (no live gateway calls — the SDK is mocked):
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
cd janus && npm test
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Endpoint contract
|
|
116
|
+
|
|
117
|
+
### POST /evaluate
|
|
118
|
+
|
|
119
|
+
Request:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"state": "string | object | array (message histories pass directly)",
|
|
124
|
+
"questions": [
|
|
125
|
+
{ "type": "boolean", "question": "Was a refund issued?", "threshold": 0.9 },
|
|
126
|
+
{ "type": "choice", "question": "Route this ticket", "choices": ["billing", "tech", "other"] },
|
|
127
|
+
{ "type": "score", "question": "Rate this PR" }
|
|
128
|
+
],
|
|
129
|
+
"model": "optional; must be on the server allowlist, overrides rejected",
|
|
130
|
+
"timeoutMs": 10000
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Question schema v1 (§6), enforced atomically — any malformed, absent, or
|
|
135
|
+
contradictory element fails the whole request with `validation_error`:
|
|
136
|
+
|
|
137
|
+
- **boolean** — `threshold` optional, default `0.9`, comparison
|
|
138
|
+
`probability >= threshold`. Optional `criteria: {true?, false?}` labels.
|
|
139
|
+
- **choice** — `choices` array REQUIRED; the verdict's `choice` is guaranteed
|
|
140
|
+
to be a member.
|
|
141
|
+
- **score** — numeric 0..5, one decimal. Ordinal vocabularies must be
|
|
142
|
+
**choice** questions, not scores.
|
|
143
|
+
|
|
144
|
+
§6 → SDK mapping (verified against ai@7.0.106 at launch step 1): the SDK's
|
|
145
|
+
`experimental_evaluate` has **no top-level `criteria` argument**; the
|
|
146
|
+
candidate set lives inside each question as a REQUIRED `criteria` field
|
|
147
|
+
(choice: `Record<option, description | null>`; score: ordered level array;
|
|
148
|
+
boolean: optional `{true?, false?}`). janus accepts §6's `choices` array at
|
|
149
|
+
the transport layer and maps it to `criteria: {a: null, b: null, ...}`. The
|
|
150
|
+
score vocabulary is passed as the ordered level array `["0".."5"]`.
|
|
151
|
+
|
|
152
|
+
Success response (200):
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"request_id": "uuid",
|
|
157
|
+
"verdict": {
|
|
158
|
+
"Was a refund issued?": { "type": "boolean", "probability": 0.97, "passed": true },
|
|
159
|
+
"Route this ticket": { "type": "choice", "choice": "billing", "probabilities": {} },
|
|
160
|
+
"Rate this PR": { "type": "score", "score": 3.0 }
|
|
161
|
+
},
|
|
162
|
+
"usage": { "inputTokens": 0, "outputTokens": 0, "totalTokens": 0 },
|
|
163
|
+
"model": "typesafe-ai/jev",
|
|
164
|
+
"digest": "sha256 evalInputDigest"
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
"Model returned false" is a **200** with `passed: false` / low probability —
|
|
169
|
+
not an error.
|
|
170
|
+
|
|
171
|
+
### Error taxonomy
|
|
172
|
+
|
|
173
|
+
All errors: `{ "error": code, "message": ..., "request_id": ..., "detail"?: {...} }`
|
|
174
|
+
|
|
175
|
+
| Code | HTTP | Meaning |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| `validation_error` | 400 | bad request (schema, JSON, unknown route) |
|
|
178
|
+
| `state_too_large` | 413 | over the 30k-token budget; never truncated |
|
|
179
|
+
| `unknown_question` | 400 | reserved for router/schema-version mismatches |
|
|
180
|
+
| `model_unauthorized` | 403/502/503 | not on allowlist, credential rejected, or key unavailable (`detail.key_unavailable`) |
|
|
181
|
+
| `gateway_unavailable` | 503 | gateway unreachable / model missing / rate limit |
|
|
182
|
+
| `timeout` | 504 | evaluation exceeded the budget |
|
|
183
|
+
| `busy` | 429 | concurrency cap or `detail.budget_exhausted` |
|
|
184
|
+
| `degraded` | 503 | internal error |
|
|
185
|
+
|
|
186
|
+
Callers distinguish **"model returned false"** (200, low probability) from
|
|
187
|
+
**"no verdict"** (`timeout` / `gateway_unavailable`) from **"bad request"**
|
|
188
|
+
(`validation_error`).
|
|
189
|
+
|
|
190
|
+
## Layout
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
janus/
|
|
194
|
+
janus/
|
|
195
|
+
config.ts config load/validate (allowlist, ceilings, retention)
|
|
196
|
+
schema.ts §6 question schema v1 enforcement (atomic fail-closed)
|
|
197
|
+
verdict.ts verdict enforcement (membership, ranges, thresholds)
|
|
198
|
+
budget.ts token estimation, 30k budget, evalInputDigest
|
|
199
|
+
redaction pass: shared library typesafe-secure
|
|
200
|
+
lib/redaction (MIT), wired via a file: dependency
|
|
201
|
+
keychain.ts guarded `security find-generic-password -w` capture
|
|
202
|
+
single_flight.ts digest coalescing + concurrency cap
|
|
203
|
+
budget_breaker.ts daily token/cost breaker
|
|
204
|
+
audit.ts append-only JSONL audit log, rotation + retention
|
|
205
|
+
sdk.ts THE ONLY AI SDK import site (pinned versions)
|
|
206
|
+
evaluate.ts /evaluate pipeline + SDK error classification
|
|
207
|
+
server.ts HTTP surface, loopback bind, entrypoint
|
|
208
|
+
tests/ node:test suites (mocked SDK, no live calls)
|
|
209
|
+
user.janus.plist launchd template
|
|
210
|
+
```
|
package/janus/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# janus — Jev evaluation wrapper service
|
|
2
|
+
|
|
3
|
+
janus is the local wrapper service for the Jev decision layer (design of
|
|
4
|
+
record: `.private-planning/JEV_DECISION_LAYER_DESIGN.md` §5–§7, Revision 2.1).
|
|
5
|
+
It wraps the official `@typesafe-ai/sdk` (TypeSafe Direct — the sole
|
|
6
|
+
provider; the Vercel AI Gateway and merge gateway were dropped by owner
|
|
7
|
+
decision) and exposes one evaluation endpoint plus health/readiness/version
|
|
8
|
+
probes on loopback.
|
|
9
|
+
|
|
10
|
+
## What janus does
|
|
11
|
+
|
|
12
|
+
- `POST /evaluate` — evaluates typed questions (boolean / choice / score)
|
|
13
|
+
against one shared state via TypeSafe Direct (`typesafe-ai`, model `jev-latest`).
|
|
14
|
+
- `GET /healthz` — liveness.
|
|
15
|
+
- `GET /readyz` — readiness (gateway key resolvable from the Keychain).
|
|
16
|
+
- `GET /version` — janus version, AI SDK version, schema versions, allowlist.
|
|
17
|
+
|
|
18
|
+
Guarantees:
|
|
19
|
+
|
|
20
|
+
- **Model allowlist** (server-side config; request-level model overrides are
|
|
21
|
+
rejected with `model_unauthorized`).
|
|
22
|
+
- **Loopback only** — binds `127.0.0.1` and nothing else. No auth beyond
|
|
23
|
+
loopback; it is a local tool. There is deliberately no bind-address option.
|
|
24
|
+
- **Single-flight** — identical requests (same evalInputDigest) coalesce;
|
|
25
|
+
distinct in-flight evaluations are capped (default 4) and rejected with
|
|
26
|
+
`busy`.
|
|
27
|
+
- **30k-token request budget** — oversized requests are rejected with
|
|
28
|
+
`state_too_large`, never truncated silently.
|
|
29
|
+
- **Redaction** — a general secrets/PII pass runs over the state before any
|
|
30
|
+
external call.
|
|
31
|
+
- **Audit log** — append-only JSONL of request id, timestamps, question
|
|
32
|
+
names, verdict probabilities, and token usage. **State contents never
|
|
33
|
+
reach the audit log.** 90-day default retention, size-capped with
|
|
34
|
+
oldest-first rotation.
|
|
35
|
+
- **Daily cost breaker** — token/cost ceilings in config; on exceed,
|
|
36
|
+
`/evaluate` returns `busy` with `budget_exhausted` detail until the UTC
|
|
37
|
+
day window resets.
|
|
38
|
+
- **Timeouts** — 30s service-side cap; callers may pass a shorter
|
|
39
|
+
per-request `timeoutMs` (capped at 30s).
|
|
40
|
+
|
|
41
|
+
## Provisioning the Keychain item
|
|
42
|
+
|
|
43
|
+
The TypeSafe Direct key is read from the macOS Keychain at runtime — janus
|
|
44
|
+
does not rely on inherited environment variables. The `typesafe-ai` /
|
|
45
|
+
`Typesafe AI` generic-password item does not exist by default; create it
|
|
46
|
+
once (interactive provisioning):
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
security add-generic-password -U -a typesafe-ai -s "Typesafe AI" -w
|
|
50
|
+
# prompts for the secret; nothing is echoed
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
janus reads it with `security find-generic-password -w` under guarded
|
|
54
|
+
capture: stdout is piped directly into memory, never echoed, never logged,
|
|
55
|
+
never passed to spawned subprocesses. If the item is missing, janus returns
|
|
56
|
+
`model_unauthorized` with `detail.key_unavailable: true` and `GET /readyz`
|
|
57
|
+
reports 503 with the provisioning hint.
|
|
58
|
+
|
|
59
|
+
## Running
|
|
60
|
+
|
|
61
|
+
Dev (from `janus/`):
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
npx tsx janus/server.ts # listens on 127.0.0.1:8787
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Config is read from `~/.config/typesafe-ai/jev/janus-config.json`
|
|
68
|
+
(override with `JANUS_CONFIG_PATH`). Defaults work with no file:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"port": 8787,
|
|
73
|
+
"modelAllowlist": ["typesafe-ai/jev"],
|
|
74
|
+
"defaultModel": "typesafe-ai/jev",
|
|
75
|
+
"maxConcurrent": 4,
|
|
76
|
+
"dailyTokenCeiling": 2000000,
|
|
77
|
+
"dailyCostCeilingUsd": 10,
|
|
78
|
+
"audit": { "path": "~/.local/share/janus/audit", "maxBytes": 10485760, "retentionDays": 90 }
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
launchd — template at `janus/user.janus.plist`. The template is a starting
|
|
83
|
+
point: every user-specific value (the label, the checkout path, log
|
|
84
|
+
locations) is marked ADJUST in the file. Copy it out of the repository,
|
|
85
|
+
adjust it, then load it:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
cp janus/user.janus.plist ~/Library/LaunchAgents/
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
launchctl load ~/Library/LaunchAgents/user.janus.plist
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Tests (no live gateway calls — the SDK is mocked):
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
cd janus && npm test
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Endpoint contract
|
|
102
|
+
|
|
103
|
+
### POST /evaluate
|
|
104
|
+
|
|
105
|
+
Request:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"state": "string | object | array (message histories pass directly)",
|
|
110
|
+
"questions": [
|
|
111
|
+
{ "type": "boolean", "question": "Was a refund issued?", "threshold": 0.9 },
|
|
112
|
+
{ "type": "choice", "question": "Route this ticket", "choices": ["billing", "tech", "other"] },
|
|
113
|
+
{ "type": "score", "question": "Rate this PR" }
|
|
114
|
+
],
|
|
115
|
+
"model": "optional; must be on the server allowlist, overrides rejected",
|
|
116
|
+
"timeoutMs": 10000
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Question schema v1 (§6), enforced atomically — any malformed, absent, or
|
|
121
|
+
contradictory element fails the whole request with `validation_error`:
|
|
122
|
+
|
|
123
|
+
- **boolean** — `threshold` optional, default `0.9`, comparison
|
|
124
|
+
`probability >= threshold`. Optional `criteria: {true?, false?}` labels.
|
|
125
|
+
- **choice** — `choices` array REQUIRED; the verdict's `choice` is guaranteed
|
|
126
|
+
to be a member.
|
|
127
|
+
- **score** — numeric 0..5, one decimal. Ordinal vocabularies must be
|
|
128
|
+
**choice** questions, not scores.
|
|
129
|
+
|
|
130
|
+
§6 → SDK mapping (verified against ai@7.0.106 at launch step 1): the SDK's
|
|
131
|
+
`experimental_evaluate` has **no top-level `criteria` argument**; the
|
|
132
|
+
candidate set lives inside each question as a REQUIRED `criteria` field
|
|
133
|
+
(choice: `Record<option, description | null>`; score: ordered level array;
|
|
134
|
+
boolean: optional `{true?, false?}`). janus accepts §6's `choices` array at
|
|
135
|
+
the transport layer and maps it to `criteria: {a: null, b: null, ...}`. The
|
|
136
|
+
score vocabulary is passed as the ordered level array `["0".."5"]`.
|
|
137
|
+
|
|
138
|
+
Success response (200):
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"request_id": "uuid",
|
|
143
|
+
"verdict": {
|
|
144
|
+
"Was a refund issued?": { "type": "boolean", "probability": 0.97, "passed": true },
|
|
145
|
+
"Route this ticket": { "type": "choice", "choice": "billing", "probabilities": {} },
|
|
146
|
+
"Rate this PR": { "type": "score", "score": 3.0 }
|
|
147
|
+
},
|
|
148
|
+
"usage": { "inputTokens": 0, "outputTokens": 0, "totalTokens": 0 },
|
|
149
|
+
"model": "typesafe-ai/jev",
|
|
150
|
+
"digest": "sha256 evalInputDigest"
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
"Model returned false" is a **200** with `passed: false` / low probability —
|
|
155
|
+
not an error.
|
|
156
|
+
|
|
157
|
+
### Error taxonomy
|
|
158
|
+
|
|
159
|
+
All errors: `{ "error": code, "message": ..., "request_id": ..., "detail"?: {...} }`
|
|
160
|
+
|
|
161
|
+
| Code | HTTP | Meaning |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `validation_error` | 400 | bad request (schema, JSON, unknown route) |
|
|
164
|
+
| `state_too_large` | 413 | over the 30k-token budget; never truncated |
|
|
165
|
+
| `unknown_question` | 400 | reserved for router/schema-version mismatches |
|
|
166
|
+
| `model_unauthorized` | 403/502/503 | not on allowlist, credential rejected, or key unavailable (`detail.key_unavailable`) |
|
|
167
|
+
| `gateway_unavailable` | 503 | gateway unreachable / model missing / rate limit |
|
|
168
|
+
| `timeout` | 504 | evaluation exceeded the budget |
|
|
169
|
+
| `busy` | 429 | concurrency cap or `detail.budget_exhausted` |
|
|
170
|
+
| `degraded` | 503 | internal error |
|
|
171
|
+
|
|
172
|
+
Callers distinguish **"model returned false"** (200, low probability) from
|
|
173
|
+
**"no verdict"** (`timeout` / `gateway_unavailable`) from **"bad request"**
|
|
174
|
+
(`validation_error`).
|
|
175
|
+
|
|
176
|
+
## Layout
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
janus/
|
|
180
|
+
janus/
|
|
181
|
+
config.ts config load/validate (allowlist, ceilings, retention)
|
|
182
|
+
schema.ts §6 question schema v1 enforcement (atomic fail-closed)
|
|
183
|
+
verdict.ts verdict enforcement (membership, ranges, thresholds)
|
|
184
|
+
budget.ts token estimation, 30k budget, evalInputDigest
|
|
185
|
+
redaction pass: shared library typesafe-secure
|
|
186
|
+
lib/redaction (MIT), wired via a file: dependency
|
|
187
|
+
keychain.ts guarded `security find-generic-password -w` capture
|
|
188
|
+
single_flight.ts digest coalescing + concurrency cap
|
|
189
|
+
budget_breaker.ts daily token/cost breaker
|
|
190
|
+
audit.ts append-only JSONL audit log, rotation + retention
|
|
191
|
+
sdk.ts THE ONLY AI SDK import site (pinned versions)
|
|
192
|
+
evaluate.ts /evaluate pipeline + SDK error classification
|
|
193
|
+
server.ts HTTP surface, loopback bind, entrypoint
|
|
194
|
+
tests/ node:test suites (mocked SDK, no live calls)
|
|
195
|
+
user.janus.plist launchd template
|
|
196
|
+
```
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Audit log (§5, M9 + round-2 retention rule): append-only local log of
|
|
5
|
+
// request id, timestamps, question NAMES (non-sensitive identifiers, never
|
|
6
|
+
// question text or state contents), verdict probabilities, and token usage.
|
|
7
|
+
// 90-day default retention (config-adjustable), size-capped with
|
|
8
|
+
// oldest-first rotation.
|
|
9
|
+
//
|
|
10
|
+
// Retention (review fix #7): enforced for the ACTIVE file as well as rotated
|
|
11
|
+
// files. On start, after each rotation, and on a periodic sweep, records
|
|
12
|
+
// older than the retention window are purged from the active file by
|
|
13
|
+
// rewriting it without them, and rotated files older than the window are
|
|
14
|
+
// deleted. Records older than retention never survive indefinitely.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
closeSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
fstatSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
openSync,
|
|
22
|
+
readSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
statSync,
|
|
25
|
+
unlinkSync,
|
|
26
|
+
writeSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { dirname } from "node:path";
|
|
29
|
+
|
|
30
|
+
export interface AuditEntry {
|
|
31
|
+
requestId: string;
|
|
32
|
+
timestamp: string;
|
|
33
|
+
event: "evaluate" | "evaluate_result" | "evaluate_error";
|
|
34
|
+
/** Non-sensitive question identifiers — never question text. */
|
|
35
|
+
questionNames: string[];
|
|
36
|
+
verdictProbabilities?: Record<string, number>;
|
|
37
|
+
tokenUsage?: { inputTokens?: number | undefined; outputTokens?: number | undefined; totalTokens?: number | undefined };
|
|
38
|
+
errorCode?: string;
|
|
39
|
+
durationMs?: number;
|
|
40
|
+
coalesced?: boolean;
|
|
41
|
+
/** How many provider attempts this request consumed (1 = no failover). */
|
|
42
|
+
providerAttempts?: number;
|
|
43
|
+
/** Provider id that produced the result (or last attempted). */
|
|
44
|
+
provider?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MAX_ROTATED_FILES = 5;
|
|
48
|
+
/** How often the active-file retention sweep runs while the log is open. */
|
|
49
|
+
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
function entryTimestampMs(line: string): number | undefined {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(line) as { timestamp?: unknown };
|
|
54
|
+
if (typeof parsed.timestamp !== "string") return undefined;
|
|
55
|
+
const ms = Date.parse(parsed.timestamp);
|
|
56
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class AuditLog {
|
|
63
|
+
private fd: number | null = null;
|
|
64
|
+
private readonly activePath: string;
|
|
65
|
+
private lastSweepAt = 0;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly basePath: string,
|
|
69
|
+
private readonly maxBytes: number,
|
|
70
|
+
private readonly retentionDays: number,
|
|
71
|
+
private readonly now: () => Date = () => new Date(),
|
|
72
|
+
) {
|
|
73
|
+
this.activePath = basePath === "stdout" ? "stdout" : `${basePath}.jsonl`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Open (or reopen) the log and apply retention. Call once at startup. */
|
|
77
|
+
start(): void {
|
|
78
|
+
if (this.activePath === "stdout") return;
|
|
79
|
+
mkdirSync(dirname(this.activePath), { recursive: true });
|
|
80
|
+
this.applyRetention();
|
|
81
|
+
this.openFd();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
stop(): void {
|
|
85
|
+
if (this.fd !== null) {
|
|
86
|
+
closeSync(this.fd);
|
|
87
|
+
this.fd = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private openFd(): void {
|
|
92
|
+
this.fd = openSync(this.activePath, "a");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Purge records older than the retention window from the ACTIVE file by
|
|
97
|
+
* rewriting it without them. Rotated-file deletion is handled separately
|
|
98
|
+
* by mtime in applyRetention(). This guarantees active-file records do not
|
|
99
|
+
* survive past retention (review fix #7).
|
|
100
|
+
*/
|
|
101
|
+
private sweepActiveFile(): void {
|
|
102
|
+
if (this.activePath === "stdout") return;
|
|
103
|
+
const cutoff = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
|
|
104
|
+
|
|
105
|
+
let size = 0;
|
|
106
|
+
try {
|
|
107
|
+
size = statSync(this.activePath).size;
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (size === 0) return;
|
|
112
|
+
|
|
113
|
+
// Read the whole active file (bounded by maxBytes + slack), filter, and
|
|
114
|
+
// rewrite in place if anything expired.
|
|
115
|
+
let raw: string;
|
|
116
|
+
const fd = openSync(this.activePath, "r");
|
|
117
|
+
try {
|
|
118
|
+
const buffer = Buffer.alloc(size);
|
|
119
|
+
const read = readSync(fd, buffer, 0, size, 0);
|
|
120
|
+
raw = buffer.toString("utf8", 0, read);
|
|
121
|
+
} finally {
|
|
122
|
+
closeSync(fd);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const lines = raw.split("\n");
|
|
126
|
+
const kept: string[] = [];
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
if (line.length === 0) continue;
|
|
129
|
+
const ts = entryTimestampMs(line);
|
|
130
|
+
if (ts === undefined || ts >= cutoff) kept.push(line);
|
|
131
|
+
}
|
|
132
|
+
if (kept.length === lines.filter((l) => l.length > 0).length) return; // nothing expired
|
|
133
|
+
|
|
134
|
+
const replacement = `${kept.join("\n")}\n`;
|
|
135
|
+
// Rewrite via a temp file + rename for atomicity, then reopen for append.
|
|
136
|
+
const tempPath = `${this.activePath}.sweep`;
|
|
137
|
+
const tempFd = openSync(tempPath, "w");
|
|
138
|
+
try {
|
|
139
|
+
writeSync(tempFd, replacement);
|
|
140
|
+
} finally {
|
|
141
|
+
closeSync(tempFd);
|
|
142
|
+
}
|
|
143
|
+
if (this.fd !== null) {
|
|
144
|
+
closeSync(this.fd);
|
|
145
|
+
this.fd = null;
|
|
146
|
+
}
|
|
147
|
+
renameSync(tempPath, this.activePath);
|
|
148
|
+
this.openFd();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private applyRetention(): void {
|
|
152
|
+
const cutoff = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
|
|
153
|
+
for (let i = 1; i <= MAX_ROTATED_FILES; i += 1) {
|
|
154
|
+
const rotated = `${this.activePath}.${i}`;
|
|
155
|
+
if (!existsSync(rotated)) continue;
|
|
156
|
+
try {
|
|
157
|
+
if (statSync(rotated).mtimeMs < cutoff) unlinkSync(rotated);
|
|
158
|
+
} catch {
|
|
159
|
+
// A missing file mid-scan is fine; retention is best-effort.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.sweepActiveFile();
|
|
163
|
+
this.lastSweepAt = this.now().getTime();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private rotateIfNeeded(): void {
|
|
167
|
+
if (this.activePath === "stdout" || this.fd === null) return;
|
|
168
|
+
let size = 0;
|
|
169
|
+
try {
|
|
170
|
+
size = fstatSync(this.fd).size;
|
|
171
|
+
} catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (size < this.maxBytes) return;
|
|
175
|
+
|
|
176
|
+
// Oldest-first rotation: .4→.5, .3→.4, … .1→.2, active→.1.
|
|
177
|
+
for (let i = MAX_ROTATED_FILES - 1; i >= 1; i -= 1) {
|
|
178
|
+
const from = `${this.activePath}.${i}`;
|
|
179
|
+
const to = `${this.activePath}.${i + 1}`;
|
|
180
|
+
if (existsSync(from)) {
|
|
181
|
+
try {
|
|
182
|
+
unlinkSync(to);
|
|
183
|
+
} catch {
|
|
184
|
+
/* first shift may have no target */
|
|
185
|
+
}
|
|
186
|
+
renameSync(from, to);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
closeSync(this.fd);
|
|
190
|
+
this.fd = null;
|
|
191
|
+
renameSync(this.activePath, `${this.activePath}.1`);
|
|
192
|
+
this.applyRetention();
|
|
193
|
+
this.openFd();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
append(entry: AuditEntry): void {
|
|
197
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
198
|
+
if (this.activePath === "stdout") {
|
|
199
|
+
process.stdout.write(line);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (this.fd === null) this.openFd();
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
204
|
+
writeSync(this.fd!, line);
|
|
205
|
+
this.rotateIfNeeded();
|
|
206
|
+
// Periodic active-file retention sweep (review fix #7).
|
|
207
|
+
const nowMs = this.now().getTime();
|
|
208
|
+
if (nowMs - this.lastSweepAt >= RETENTION_SWEEP_INTERVAL_MS) {
|
|
209
|
+
this.sweepActiveFile();
|
|
210
|
+
this.lastSweepAt = nowMs;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|