@praxisflux/gates 0.63.0 → 0.63.2
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/codebase-to-course/lib/README.md +4 -0
- package/codebase-to-course/lib/structured-offload.mjs +140 -0
- package/grounding-wiki/lib/README.md +4 -0
- package/grounding-wiki/lib/structured-offload.mjs +140 -0
- package/lib/README.md +4 -0
- package/lib/structured-offload.mjs +140 -0
- package/package.json +1 -1
- package/spec-bridge/lib/README.md +4 -0
- package/spec-bridge/lib/structured-offload.mjs +140 -0
|
@@ -7,6 +7,10 @@ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harne
|
|
|
7
7
|
· `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
|
|
8
8
|
`installer` · `dates` · `template`.
|
|
9
9
|
|
|
10
|
+
Also shipped: `structured-offload` — schema-validated, fail-soft calls to a local
|
|
11
|
+
Ollama/OpenAI-compatible endpoint, config-driven (`.claude/structured-offload.json`),
|
|
12
|
+
opt-in and absent-by-default. See `docs/wiki/chassis.md`.
|
|
13
|
+
|
|
10
14
|
Also shipped here: `handoff-protocol.md` — a stamped copy of the canonical
|
|
11
15
|
`docs/handoff-protocol.md` (re-stamped by `scripts/sync-shared.mjs`), so skills can reference
|
|
12
16
|
the protocol as `${CLAUDE_PLUGIN_ROOT}/lib/handoff-protocol.md` from an installed plugin.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// structured-offload.mjs — schema-validated calls to a local model, fail-soft always.
|
|
2
|
+
//
|
|
3
|
+
// `offload({ prompt, schema, config, timeoutMs })` asks a local Ollama or
|
|
4
|
+
// OpenAI-compatible endpoint to answer `prompt`, constrained to `schema` at the backend
|
|
5
|
+
// (Ollama `format`, OpenAI-compatible `response_format: json_schema`) so the model is
|
|
6
|
+
// structurally prevented from returning prose. The result is validated again on this
|
|
7
|
+
// side against the same schema. NEVER throws: every failure path — unset config,
|
|
8
|
+
// timeout, connection refused, non-2xx, invalid JSON, schema mismatch — resolves to
|
|
9
|
+
// `{ ok: false, reason, residue }` and the caller does the work in-session, exactly as
|
|
10
|
+
// if this module did not exist.
|
|
11
|
+
//
|
|
12
|
+
// `config` is loaded by `loadConfig(root)` from `<root>/.claude/structured-offload.json`
|
|
13
|
+
// (absent/unreadable/malformed -> null -> `reason: 'unconfigured'`), kept separate from
|
|
14
|
+
// `offload` so tests can hand it a stub-server config directly without touching disk.
|
|
15
|
+
//
|
|
16
|
+
// Config shape: `{ endpoint, api: 'ollama'|'openai', model, timeoutMs?, residuePath? }`.
|
|
17
|
+
//
|
|
18
|
+
// Schema checker (minimal subset — extend only when a consumer needs more):
|
|
19
|
+
// - `type`: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
|
20
|
+
// - `required`: array of property names that must exist on an object value
|
|
21
|
+
// - `properties`: per-key sub-schemas, checked recursively
|
|
22
|
+
// - `enum`: value must be one of the listed members
|
|
23
|
+
// - `items`: sub-schema every array element must satisfy
|
|
24
|
+
// ponytail: full JSON Schema (oneOf/anyOf/patternProperties/formats/…) is not
|
|
25
|
+
// implemented; the checked subset is what closed-enum/path-lookup callers need.
|
|
26
|
+
//
|
|
27
|
+
// `residue`: `{ backend, model, outcome: 'validated'|'fallback', reason?, ms }`,
|
|
28
|
+
// returned on every call and appended as a JSON line to `config.residuePath` when set.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Load and validate the config file, or null on any absence/parse/shape failure. */
|
|
34
|
+
export function loadConfig(root) {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(join(root, ".claude", "structured-offload.json"), "utf8"));
|
|
37
|
+
if (!cfg || typeof cfg !== "object") return null;
|
|
38
|
+
if (!cfg.endpoint || !["ollama", "openai"].includes(cfg.api) || !cfg.model) return null;
|
|
39
|
+
return cfg;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTimeoutError(err) {
|
|
46
|
+
return err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRequest(config, prompt, schema) {
|
|
50
|
+
const messages = [{ role: "user", content: prompt }];
|
|
51
|
+
if (config.api === "ollama") {
|
|
52
|
+
return { path: "/api/chat", body: { model: config.model, messages, format: schema, stream: false } };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: "/v1/chat/completions",
|
|
56
|
+
body: {
|
|
57
|
+
model: config.model,
|
|
58
|
+
messages,
|
|
59
|
+
response_format: { type: "json_schema", json_schema: { name: "offload_response", strict: true, schema } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractContent(api, data) {
|
|
65
|
+
return api === "ollama" ? data?.message?.content : data?.choices?.[0]?.message?.content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The minimal schema subset documented in the module header. */
|
|
69
|
+
export function validateSchema(value, schema) {
|
|
70
|
+
if (!schema) return true;
|
|
71
|
+
if (schema.enum) return schema.enum.includes(value);
|
|
72
|
+
switch (schema.type) {
|
|
73
|
+
case "object": {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
75
|
+
for (const key of schema.required || []) if (!(key in value)) return false;
|
|
76
|
+
if (schema.properties) {
|
|
77
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
78
|
+
if (key in value && !validateSchema(value[key], sub)) return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "array":
|
|
84
|
+
return Array.isArray(value) && (!schema.items || value.every((v) => validateSchema(v, schema.items)));
|
|
85
|
+
case "string":
|
|
86
|
+
return typeof value === "string";
|
|
87
|
+
case "number":
|
|
88
|
+
return typeof value === "number";
|
|
89
|
+
case "integer":
|
|
90
|
+
return Number.isInteger(value);
|
|
91
|
+
case "boolean":
|
|
92
|
+
return typeof value === "boolean";
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Async, never-throwing: see module header for the full contract. */
|
|
99
|
+
export async function offload({ prompt, schema, config, timeoutMs } = {}) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const backend = config?.api;
|
|
102
|
+
const model = config?.model;
|
|
103
|
+
|
|
104
|
+
function finish(result) {
|
|
105
|
+
const residue = { backend, model, outcome: result.ok ? "validated" : "fallback", ms: Date.now() - start };
|
|
106
|
+
if (!result.ok) residue.reason = result.reason;
|
|
107
|
+
if (config?.residuePath) {
|
|
108
|
+
try { appendFileSync(config.residuePath, JSON.stringify(residue) + "\n"); } catch { /* residue is best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
return { ...result, residue };
|
|
111
|
+
}
|
|
112
|
+
const fail = (reason) => finish({ ok: false, reason });
|
|
113
|
+
|
|
114
|
+
if (!config || !config.endpoint || !["ollama", "openai"].includes(config.api) || !config.model) {
|
|
115
|
+
return fail("unconfigured");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { path, body } = buildRequest(config, prompt, schema);
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(new URL(path, config.endpoint), {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs ?? 30000),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return fail(isTimeoutError(err) ? "timeout" : "refused");
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) return fail("http-error");
|
|
131
|
+
|
|
132
|
+
let data;
|
|
133
|
+
try { data = await res.json(); } catch { return fail("invalid-json"); }
|
|
134
|
+
|
|
135
|
+
let value;
|
|
136
|
+
try { value = JSON.parse(extractContent(config.api, data)); } catch { return fail("invalid-json"); }
|
|
137
|
+
|
|
138
|
+
if (!validateSchema(value, schema)) return fail("schema-mismatch");
|
|
139
|
+
return finish({ ok: true, value });
|
|
140
|
+
}
|
|
@@ -7,6 +7,10 @@ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harne
|
|
|
7
7
|
· `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
|
|
8
8
|
`installer` · `dates` · `template`.
|
|
9
9
|
|
|
10
|
+
Also shipped: `structured-offload` — schema-validated, fail-soft calls to a local
|
|
11
|
+
Ollama/OpenAI-compatible endpoint, config-driven (`.claude/structured-offload.json`),
|
|
12
|
+
opt-in and absent-by-default. See `docs/wiki/chassis.md`.
|
|
13
|
+
|
|
10
14
|
Also shipped here: `handoff-protocol.md` — a stamped copy of the canonical
|
|
11
15
|
`docs/handoff-protocol.md` (re-stamped by `scripts/sync-shared.mjs`), so skills can reference
|
|
12
16
|
the protocol as `${CLAUDE_PLUGIN_ROOT}/lib/handoff-protocol.md` from an installed plugin.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// structured-offload.mjs — schema-validated calls to a local model, fail-soft always.
|
|
2
|
+
//
|
|
3
|
+
// `offload({ prompt, schema, config, timeoutMs })` asks a local Ollama or
|
|
4
|
+
// OpenAI-compatible endpoint to answer `prompt`, constrained to `schema` at the backend
|
|
5
|
+
// (Ollama `format`, OpenAI-compatible `response_format: json_schema`) so the model is
|
|
6
|
+
// structurally prevented from returning prose. The result is validated again on this
|
|
7
|
+
// side against the same schema. NEVER throws: every failure path — unset config,
|
|
8
|
+
// timeout, connection refused, non-2xx, invalid JSON, schema mismatch — resolves to
|
|
9
|
+
// `{ ok: false, reason, residue }` and the caller does the work in-session, exactly as
|
|
10
|
+
// if this module did not exist.
|
|
11
|
+
//
|
|
12
|
+
// `config` is loaded by `loadConfig(root)` from `<root>/.claude/structured-offload.json`
|
|
13
|
+
// (absent/unreadable/malformed -> null -> `reason: 'unconfigured'`), kept separate from
|
|
14
|
+
// `offload` so tests can hand it a stub-server config directly without touching disk.
|
|
15
|
+
//
|
|
16
|
+
// Config shape: `{ endpoint, api: 'ollama'|'openai', model, timeoutMs?, residuePath? }`.
|
|
17
|
+
//
|
|
18
|
+
// Schema checker (minimal subset — extend only when a consumer needs more):
|
|
19
|
+
// - `type`: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
|
20
|
+
// - `required`: array of property names that must exist on an object value
|
|
21
|
+
// - `properties`: per-key sub-schemas, checked recursively
|
|
22
|
+
// - `enum`: value must be one of the listed members
|
|
23
|
+
// - `items`: sub-schema every array element must satisfy
|
|
24
|
+
// ponytail: full JSON Schema (oneOf/anyOf/patternProperties/formats/…) is not
|
|
25
|
+
// implemented; the checked subset is what closed-enum/path-lookup callers need.
|
|
26
|
+
//
|
|
27
|
+
// `residue`: `{ backend, model, outcome: 'validated'|'fallback', reason?, ms }`,
|
|
28
|
+
// returned on every call and appended as a JSON line to `config.residuePath` when set.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Load and validate the config file, or null on any absence/parse/shape failure. */
|
|
34
|
+
export function loadConfig(root) {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(join(root, ".claude", "structured-offload.json"), "utf8"));
|
|
37
|
+
if (!cfg || typeof cfg !== "object") return null;
|
|
38
|
+
if (!cfg.endpoint || !["ollama", "openai"].includes(cfg.api) || !cfg.model) return null;
|
|
39
|
+
return cfg;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTimeoutError(err) {
|
|
46
|
+
return err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRequest(config, prompt, schema) {
|
|
50
|
+
const messages = [{ role: "user", content: prompt }];
|
|
51
|
+
if (config.api === "ollama") {
|
|
52
|
+
return { path: "/api/chat", body: { model: config.model, messages, format: schema, stream: false } };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: "/v1/chat/completions",
|
|
56
|
+
body: {
|
|
57
|
+
model: config.model,
|
|
58
|
+
messages,
|
|
59
|
+
response_format: { type: "json_schema", json_schema: { name: "offload_response", strict: true, schema } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractContent(api, data) {
|
|
65
|
+
return api === "ollama" ? data?.message?.content : data?.choices?.[0]?.message?.content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The minimal schema subset documented in the module header. */
|
|
69
|
+
export function validateSchema(value, schema) {
|
|
70
|
+
if (!schema) return true;
|
|
71
|
+
if (schema.enum) return schema.enum.includes(value);
|
|
72
|
+
switch (schema.type) {
|
|
73
|
+
case "object": {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
75
|
+
for (const key of schema.required || []) if (!(key in value)) return false;
|
|
76
|
+
if (schema.properties) {
|
|
77
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
78
|
+
if (key in value && !validateSchema(value[key], sub)) return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "array":
|
|
84
|
+
return Array.isArray(value) && (!schema.items || value.every((v) => validateSchema(v, schema.items)));
|
|
85
|
+
case "string":
|
|
86
|
+
return typeof value === "string";
|
|
87
|
+
case "number":
|
|
88
|
+
return typeof value === "number";
|
|
89
|
+
case "integer":
|
|
90
|
+
return Number.isInteger(value);
|
|
91
|
+
case "boolean":
|
|
92
|
+
return typeof value === "boolean";
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Async, never-throwing: see module header for the full contract. */
|
|
99
|
+
export async function offload({ prompt, schema, config, timeoutMs } = {}) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const backend = config?.api;
|
|
102
|
+
const model = config?.model;
|
|
103
|
+
|
|
104
|
+
function finish(result) {
|
|
105
|
+
const residue = { backend, model, outcome: result.ok ? "validated" : "fallback", ms: Date.now() - start };
|
|
106
|
+
if (!result.ok) residue.reason = result.reason;
|
|
107
|
+
if (config?.residuePath) {
|
|
108
|
+
try { appendFileSync(config.residuePath, JSON.stringify(residue) + "\n"); } catch { /* residue is best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
return { ...result, residue };
|
|
111
|
+
}
|
|
112
|
+
const fail = (reason) => finish({ ok: false, reason });
|
|
113
|
+
|
|
114
|
+
if (!config || !config.endpoint || !["ollama", "openai"].includes(config.api) || !config.model) {
|
|
115
|
+
return fail("unconfigured");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { path, body } = buildRequest(config, prompt, schema);
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(new URL(path, config.endpoint), {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs ?? 30000),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return fail(isTimeoutError(err) ? "timeout" : "refused");
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) return fail("http-error");
|
|
131
|
+
|
|
132
|
+
let data;
|
|
133
|
+
try { data = await res.json(); } catch { return fail("invalid-json"); }
|
|
134
|
+
|
|
135
|
+
let value;
|
|
136
|
+
try { value = JSON.parse(extractContent(config.api, data)); } catch { return fail("invalid-json"); }
|
|
137
|
+
|
|
138
|
+
if (!validateSchema(value, schema)) return fail("schema-mismatch");
|
|
139
|
+
return finish({ ok: true, value });
|
|
140
|
+
}
|
package/lib/README.md
CHANGED
|
@@ -7,6 +7,10 @@ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harne
|
|
|
7
7
|
· `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
|
|
8
8
|
`installer` · `dates` · `template`.
|
|
9
9
|
|
|
10
|
+
Also shipped: `structured-offload` — schema-validated, fail-soft calls to a local
|
|
11
|
+
Ollama/OpenAI-compatible endpoint, config-driven (`.claude/structured-offload.json`),
|
|
12
|
+
opt-in and absent-by-default. See `docs/wiki/chassis.md`.
|
|
13
|
+
|
|
10
14
|
Also shipped here: `handoff-protocol.md` — a stamped copy of the canonical
|
|
11
15
|
`docs/handoff-protocol.md` (re-stamped by `scripts/sync-shared.mjs`), so skills can reference
|
|
12
16
|
the protocol as `${CLAUDE_PLUGIN_ROOT}/lib/handoff-protocol.md` from an installed plugin.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// structured-offload.mjs — schema-validated calls to a local model, fail-soft always.
|
|
2
|
+
//
|
|
3
|
+
// `offload({ prompt, schema, config, timeoutMs })` asks a local Ollama or
|
|
4
|
+
// OpenAI-compatible endpoint to answer `prompt`, constrained to `schema` at the backend
|
|
5
|
+
// (Ollama `format`, OpenAI-compatible `response_format: json_schema`) so the model is
|
|
6
|
+
// structurally prevented from returning prose. The result is validated again on this
|
|
7
|
+
// side against the same schema. NEVER throws: every failure path — unset config,
|
|
8
|
+
// timeout, connection refused, non-2xx, invalid JSON, schema mismatch — resolves to
|
|
9
|
+
// `{ ok: false, reason, residue }` and the caller does the work in-session, exactly as
|
|
10
|
+
// if this module did not exist.
|
|
11
|
+
//
|
|
12
|
+
// `config` is loaded by `loadConfig(root)` from `<root>/.claude/structured-offload.json`
|
|
13
|
+
// (absent/unreadable/malformed -> null -> `reason: 'unconfigured'`), kept separate from
|
|
14
|
+
// `offload` so tests can hand it a stub-server config directly without touching disk.
|
|
15
|
+
//
|
|
16
|
+
// Config shape: `{ endpoint, api: 'ollama'|'openai', model, timeoutMs?, residuePath? }`.
|
|
17
|
+
//
|
|
18
|
+
// Schema checker (minimal subset — extend only when a consumer needs more):
|
|
19
|
+
// - `type`: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
|
20
|
+
// - `required`: array of property names that must exist on an object value
|
|
21
|
+
// - `properties`: per-key sub-schemas, checked recursively
|
|
22
|
+
// - `enum`: value must be one of the listed members
|
|
23
|
+
// - `items`: sub-schema every array element must satisfy
|
|
24
|
+
// ponytail: full JSON Schema (oneOf/anyOf/patternProperties/formats/…) is not
|
|
25
|
+
// implemented; the checked subset is what closed-enum/path-lookup callers need.
|
|
26
|
+
//
|
|
27
|
+
// `residue`: `{ backend, model, outcome: 'validated'|'fallback', reason?, ms }`,
|
|
28
|
+
// returned on every call and appended as a JSON line to `config.residuePath` when set.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Load and validate the config file, or null on any absence/parse/shape failure. */
|
|
34
|
+
export function loadConfig(root) {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(join(root, ".claude", "structured-offload.json"), "utf8"));
|
|
37
|
+
if (!cfg || typeof cfg !== "object") return null;
|
|
38
|
+
if (!cfg.endpoint || !["ollama", "openai"].includes(cfg.api) || !cfg.model) return null;
|
|
39
|
+
return cfg;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTimeoutError(err) {
|
|
46
|
+
return err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRequest(config, prompt, schema) {
|
|
50
|
+
const messages = [{ role: "user", content: prompt }];
|
|
51
|
+
if (config.api === "ollama") {
|
|
52
|
+
return { path: "/api/chat", body: { model: config.model, messages, format: schema, stream: false } };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: "/v1/chat/completions",
|
|
56
|
+
body: {
|
|
57
|
+
model: config.model,
|
|
58
|
+
messages,
|
|
59
|
+
response_format: { type: "json_schema", json_schema: { name: "offload_response", strict: true, schema } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractContent(api, data) {
|
|
65
|
+
return api === "ollama" ? data?.message?.content : data?.choices?.[0]?.message?.content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The minimal schema subset documented in the module header. */
|
|
69
|
+
export function validateSchema(value, schema) {
|
|
70
|
+
if (!schema) return true;
|
|
71
|
+
if (schema.enum) return schema.enum.includes(value);
|
|
72
|
+
switch (schema.type) {
|
|
73
|
+
case "object": {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
75
|
+
for (const key of schema.required || []) if (!(key in value)) return false;
|
|
76
|
+
if (schema.properties) {
|
|
77
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
78
|
+
if (key in value && !validateSchema(value[key], sub)) return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "array":
|
|
84
|
+
return Array.isArray(value) && (!schema.items || value.every((v) => validateSchema(v, schema.items)));
|
|
85
|
+
case "string":
|
|
86
|
+
return typeof value === "string";
|
|
87
|
+
case "number":
|
|
88
|
+
return typeof value === "number";
|
|
89
|
+
case "integer":
|
|
90
|
+
return Number.isInteger(value);
|
|
91
|
+
case "boolean":
|
|
92
|
+
return typeof value === "boolean";
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Async, never-throwing: see module header for the full contract. */
|
|
99
|
+
export async function offload({ prompt, schema, config, timeoutMs } = {}) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const backend = config?.api;
|
|
102
|
+
const model = config?.model;
|
|
103
|
+
|
|
104
|
+
function finish(result) {
|
|
105
|
+
const residue = { backend, model, outcome: result.ok ? "validated" : "fallback", ms: Date.now() - start };
|
|
106
|
+
if (!result.ok) residue.reason = result.reason;
|
|
107
|
+
if (config?.residuePath) {
|
|
108
|
+
try { appendFileSync(config.residuePath, JSON.stringify(residue) + "\n"); } catch { /* residue is best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
return { ...result, residue };
|
|
111
|
+
}
|
|
112
|
+
const fail = (reason) => finish({ ok: false, reason });
|
|
113
|
+
|
|
114
|
+
if (!config || !config.endpoint || !["ollama", "openai"].includes(config.api) || !config.model) {
|
|
115
|
+
return fail("unconfigured");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { path, body } = buildRequest(config, prompt, schema);
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(new URL(path, config.endpoint), {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs ?? 30000),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return fail(isTimeoutError(err) ? "timeout" : "refused");
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) return fail("http-error");
|
|
131
|
+
|
|
132
|
+
let data;
|
|
133
|
+
try { data = await res.json(); } catch { return fail("invalid-json"); }
|
|
134
|
+
|
|
135
|
+
let value;
|
|
136
|
+
try { value = JSON.parse(extractContent(config.api, data)); } catch { return fail("invalid-json"); }
|
|
137
|
+
|
|
138
|
+
if (!validateSchema(value, schema)) return fail("schema-mismatch");
|
|
139
|
+
return finish({ ok: true, value });
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -7,6 +7,10 @@ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harne
|
|
|
7
7
|
· `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
|
|
8
8
|
`installer` · `dates` · `template`.
|
|
9
9
|
|
|
10
|
+
Also shipped: `structured-offload` — schema-validated, fail-soft calls to a local
|
|
11
|
+
Ollama/OpenAI-compatible endpoint, config-driven (`.claude/structured-offload.json`),
|
|
12
|
+
opt-in and absent-by-default. See `docs/wiki/chassis.md`.
|
|
13
|
+
|
|
10
14
|
Also shipped here: `handoff-protocol.md` — a stamped copy of the canonical
|
|
11
15
|
`docs/handoff-protocol.md` (re-stamped by `scripts/sync-shared.mjs`), so skills can reference
|
|
12
16
|
the protocol as `${CLAUDE_PLUGIN_ROOT}/lib/handoff-protocol.md` from an installed plugin.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// structured-offload.mjs — schema-validated calls to a local model, fail-soft always.
|
|
2
|
+
//
|
|
3
|
+
// `offload({ prompt, schema, config, timeoutMs })` asks a local Ollama or
|
|
4
|
+
// OpenAI-compatible endpoint to answer `prompt`, constrained to `schema` at the backend
|
|
5
|
+
// (Ollama `format`, OpenAI-compatible `response_format: json_schema`) so the model is
|
|
6
|
+
// structurally prevented from returning prose. The result is validated again on this
|
|
7
|
+
// side against the same schema. NEVER throws: every failure path — unset config,
|
|
8
|
+
// timeout, connection refused, non-2xx, invalid JSON, schema mismatch — resolves to
|
|
9
|
+
// `{ ok: false, reason, residue }` and the caller does the work in-session, exactly as
|
|
10
|
+
// if this module did not exist.
|
|
11
|
+
//
|
|
12
|
+
// `config` is loaded by `loadConfig(root)` from `<root>/.claude/structured-offload.json`
|
|
13
|
+
// (absent/unreadable/malformed -> null -> `reason: 'unconfigured'`), kept separate from
|
|
14
|
+
// `offload` so tests can hand it a stub-server config directly without touching disk.
|
|
15
|
+
//
|
|
16
|
+
// Config shape: `{ endpoint, api: 'ollama'|'openai', model, timeoutMs?, residuePath? }`.
|
|
17
|
+
//
|
|
18
|
+
// Schema checker (minimal subset — extend only when a consumer needs more):
|
|
19
|
+
// - `type`: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
|
20
|
+
// - `required`: array of property names that must exist on an object value
|
|
21
|
+
// - `properties`: per-key sub-schemas, checked recursively
|
|
22
|
+
// - `enum`: value must be one of the listed members
|
|
23
|
+
// - `items`: sub-schema every array element must satisfy
|
|
24
|
+
// ponytail: full JSON Schema (oneOf/anyOf/patternProperties/formats/…) is not
|
|
25
|
+
// implemented; the checked subset is what closed-enum/path-lookup callers need.
|
|
26
|
+
//
|
|
27
|
+
// `residue`: `{ backend, model, outcome: 'validated'|'fallback', reason?, ms }`,
|
|
28
|
+
// returned on every call and appended as a JSON line to `config.residuePath` when set.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Load and validate the config file, or null on any absence/parse/shape failure. */
|
|
34
|
+
export function loadConfig(root) {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(join(root, ".claude", "structured-offload.json"), "utf8"));
|
|
37
|
+
if (!cfg || typeof cfg !== "object") return null;
|
|
38
|
+
if (!cfg.endpoint || !["ollama", "openai"].includes(cfg.api) || !cfg.model) return null;
|
|
39
|
+
return cfg;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTimeoutError(err) {
|
|
46
|
+
return err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRequest(config, prompt, schema) {
|
|
50
|
+
const messages = [{ role: "user", content: prompt }];
|
|
51
|
+
if (config.api === "ollama") {
|
|
52
|
+
return { path: "/api/chat", body: { model: config.model, messages, format: schema, stream: false } };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: "/v1/chat/completions",
|
|
56
|
+
body: {
|
|
57
|
+
model: config.model,
|
|
58
|
+
messages,
|
|
59
|
+
response_format: { type: "json_schema", json_schema: { name: "offload_response", strict: true, schema } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractContent(api, data) {
|
|
65
|
+
return api === "ollama" ? data?.message?.content : data?.choices?.[0]?.message?.content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The minimal schema subset documented in the module header. */
|
|
69
|
+
export function validateSchema(value, schema) {
|
|
70
|
+
if (!schema) return true;
|
|
71
|
+
if (schema.enum) return schema.enum.includes(value);
|
|
72
|
+
switch (schema.type) {
|
|
73
|
+
case "object": {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
75
|
+
for (const key of schema.required || []) if (!(key in value)) return false;
|
|
76
|
+
if (schema.properties) {
|
|
77
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
78
|
+
if (key in value && !validateSchema(value[key], sub)) return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "array":
|
|
84
|
+
return Array.isArray(value) && (!schema.items || value.every((v) => validateSchema(v, schema.items)));
|
|
85
|
+
case "string":
|
|
86
|
+
return typeof value === "string";
|
|
87
|
+
case "number":
|
|
88
|
+
return typeof value === "number";
|
|
89
|
+
case "integer":
|
|
90
|
+
return Number.isInteger(value);
|
|
91
|
+
case "boolean":
|
|
92
|
+
return typeof value === "boolean";
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Async, never-throwing: see module header for the full contract. */
|
|
99
|
+
export async function offload({ prompt, schema, config, timeoutMs } = {}) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const backend = config?.api;
|
|
102
|
+
const model = config?.model;
|
|
103
|
+
|
|
104
|
+
function finish(result) {
|
|
105
|
+
const residue = { backend, model, outcome: result.ok ? "validated" : "fallback", ms: Date.now() - start };
|
|
106
|
+
if (!result.ok) residue.reason = result.reason;
|
|
107
|
+
if (config?.residuePath) {
|
|
108
|
+
try { appendFileSync(config.residuePath, JSON.stringify(residue) + "\n"); } catch { /* residue is best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
return { ...result, residue };
|
|
111
|
+
}
|
|
112
|
+
const fail = (reason) => finish({ ok: false, reason });
|
|
113
|
+
|
|
114
|
+
if (!config || !config.endpoint || !["ollama", "openai"].includes(config.api) || !config.model) {
|
|
115
|
+
return fail("unconfigured");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { path, body } = buildRequest(config, prompt, schema);
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(new URL(path, config.endpoint), {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs ?? 30000),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return fail(isTimeoutError(err) ? "timeout" : "refused");
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) return fail("http-error");
|
|
131
|
+
|
|
132
|
+
let data;
|
|
133
|
+
try { data = await res.json(); } catch { return fail("invalid-json"); }
|
|
134
|
+
|
|
135
|
+
let value;
|
|
136
|
+
try { value = JSON.parse(extractContent(config.api, data)); } catch { return fail("invalid-json"); }
|
|
137
|
+
|
|
138
|
+
if (!validateSchema(value, schema)) return fail("schema-mismatch");
|
|
139
|
+
return finish({ ok: true, value });
|
|
140
|
+
}
|