pi-system-one 1.1.3 → 1.2.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.
- package/README.md +101 -1
- package/demo.gif +0 -0
- package/package.json +1 -1
- package/prompts/judge.md +29 -0
- package/skills/system-one/SKILL.md +91 -0
- package/skills/system-one/references/use-cases.md +171 -0
- package/src/commands.ts +1 -1
- package/src/extension.ts +30 -0
- package/src/tool.ts +452 -51
- package/tests/commands.test.ts +6 -1
- package/tests/extension.test.ts +123 -0
- package/tests/packaging.test.ts +49 -0
- package/tests/tool.test.ts +1176 -3
package/src/tool.ts
CHANGED
|
@@ -1,87 +1,488 @@
|
|
|
1
|
-
// biome-ignore-all lint/suspicious/noExplicitAny: intentional dynamic boundary over JSON protocol values
|
|
2
|
-
// pi-system-one/src/tool.ts
|
|
3
|
-
|
|
4
1
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type QuestionMap,
|
|
4
|
+
SystemOne,
|
|
5
|
+
type SystemOneProvider,
|
|
6
|
+
type SystemOneState,
|
|
7
|
+
} from "system-one-core";
|
|
6
8
|
import type { Static } from "typebox";
|
|
7
9
|
import { Type } from "typebox";
|
|
8
10
|
import { renderSystemOneResult } from "./render.ts";
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Public tool parameters (the LLM-facing boundary schema).
|
|
14
|
+
*
|
|
15
|
+
* The LLM-facing tool contract is intentionally more permissive than the
|
|
16
|
+
* System One domain model. The schema answers only "can I safely accept
|
|
17
|
+
* and inspect this?" — every field is optional, extra keys are allowed,
|
|
18
|
+
* and `questions` accepts any JSON so near-miss shapes (singular
|
|
19
|
+
* `question`, arrays, unwrapped singles, alias spellings like `Choice`,
|
|
20
|
+
* `rating`, or `Yes/No`) survive Pi's pre-execution validation on every
|
|
21
|
+
* Pi version, including older ones without `prepareArguments`.
|
|
22
|
+
* Normalization (`prepareSystemOneArgs`) repairs representation only;
|
|
23
|
+
* semantic validation in `execute()` (via `assertAnswerableQuestions`)
|
|
24
|
+
* establishes the canonical System One contract — `{type: "choice" |
|
|
25
|
+
* "noul" | "score", instructions, criteria}` per question — and rejects
|
|
26
|
+
* what cannot be repaired with an actionable error.
|
|
27
|
+
*/
|
|
28
|
+
export const systemOneParams = Type.Object(
|
|
29
|
+
{
|
|
30
|
+
state: Type.Optional(
|
|
31
|
+
Type.Any({
|
|
32
|
+
description:
|
|
33
|
+
"Shared state or evidence that all questions should be evaluated against. Usually a string or object. Aliases: context, evidence.",
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
|
|
37
|
+
context: Type.Optional(Type.Any()),
|
|
38
|
+
evidence: Type.Optional(Type.Any()),
|
|
39
|
+
|
|
40
|
+
questions: Type.Optional(
|
|
41
|
+
Type.Any({
|
|
42
|
+
description:
|
|
43
|
+
'One or more named decision questions. "choice" selects one outcome from a bounded set (criteria is an object keyed by label); "noul" estimates a yes/no likelihood (criteria usually omitted); "score" scores against an ordered rubric (criteria is an array of at least two levels). Accepts a name-keyed object, an array of questions, or a single unwrapped question. Aliases: question, queries; near-miss type spellings are normalized.',
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
question: Type.Optional(Type.Any()),
|
|
47
|
+
queries: Type.Optional(Type.Any()),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
additionalProperties: true,
|
|
51
|
+
description:
|
|
52
|
+
'Evaluate bounded decision questions against shared state. Example: {"state": "...", "questions": {"q1": {"type": "choice", "instructions": "...", "criteria": {"a": null, "b": null}}}}.',
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
36
56
|
export type SystemOneParams = Static<typeof systemOneParams>;
|
|
37
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Near-miss spellings low-quality models emit for the `type` discriminator.
|
|
60
|
+
* Lookup is case-insensitive; unlisted values pass through untouched so
|
|
61
|
+
* validation still reports a clear error for genuinely unknown kinds.
|
|
62
|
+
*
|
|
63
|
+
* Every entry here must be SAFE: a representation-only difference whose
|
|
64
|
+
* meaning is unambiguous. Deliberately NOT aliased (rejected instead):
|
|
65
|
+
* - "probability" / "likelihood": describe an output statistic, not a task.
|
|
66
|
+
* They could mean a noul question, a choice with probabilities, or a
|
|
67
|
+
* free-floating number. Guessing noul would silently convert the wrong
|
|
68
|
+
* task into a valid but semantically incorrect request — a believable
|
|
69
|
+
* wrong answer is worse than a validation error the model can retry.
|
|
70
|
+
* - "ranking": ordering items relative to each other is not scoring each
|
|
71
|
+
* against a rubric. Mapping it to score would corrupt the task.
|
|
72
|
+
*/
|
|
73
|
+
const QUESTION_TYPE_ALIASES = new Map<string, string>([
|
|
74
|
+
["choice", "choice"],
|
|
75
|
+
["choose", "choice"],
|
|
76
|
+
["multiple_choice", "choice"],
|
|
77
|
+
["categorical", "choice"],
|
|
78
|
+
["select", "choice"],
|
|
79
|
+
["classify", "choice"],
|
|
80
|
+
["classification", "choice"],
|
|
81
|
+
["noul", "noul"],
|
|
82
|
+
["boolean", "noul"],
|
|
83
|
+
["bool", "noul"],
|
|
84
|
+
["yes_no", "noul"],
|
|
85
|
+
["yesno", "noul"],
|
|
86
|
+
["score", "score"],
|
|
87
|
+
["rating", "score"],
|
|
88
|
+
["rate", "score"],
|
|
89
|
+
["scale", "score"],
|
|
90
|
+
["rubric", "score"],
|
|
91
|
+
["grade", "score"],
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/** Field aliases models use instead of `criteria`. First hit wins. */
|
|
95
|
+
const CRITERIA_ALIASES = [
|
|
96
|
+
"criteria",
|
|
97
|
+
"options",
|
|
98
|
+
"choices",
|
|
99
|
+
"levels",
|
|
100
|
+
"rubric",
|
|
101
|
+
"labels",
|
|
102
|
+
"candidates",
|
|
103
|
+
"outcomes",
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/** Field aliases models use instead of `instructions`. First hit wins. */
|
|
107
|
+
const INSTRUCTIONS_ALIASES = [
|
|
108
|
+
"instructions",
|
|
109
|
+
"instruction",
|
|
110
|
+
"prompt",
|
|
111
|
+
"question",
|
|
112
|
+
"text",
|
|
113
|
+
"query",
|
|
114
|
+
"task",
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Question-level keys that are never part of the schema (naming metadata
|
|
119
|
+
* from array-form questions, or hallucinated documentation fields).
|
|
120
|
+
* Dropped so they don't trip `additionalProperties: false`.
|
|
121
|
+
*/
|
|
122
|
+
const QUESTION_JUNK_KEYS = new Set([
|
|
123
|
+
"description",
|
|
124
|
+
"title",
|
|
125
|
+
"detail",
|
|
126
|
+
"details",
|
|
127
|
+
"example",
|
|
128
|
+
"examples",
|
|
129
|
+
"id",
|
|
130
|
+
"key",
|
|
131
|
+
"name",
|
|
132
|
+
"label",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
136
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Assign an externally controlled key. Plain `obj[key] = value` with
|
|
141
|
+
* `key === "__proto__"` would reparent the object instead of storing an
|
|
142
|
+
* entry; defineProperty always creates an ordinary own data property.
|
|
143
|
+
*/
|
|
144
|
+
function setOwn(
|
|
145
|
+
obj: Record<string, unknown>,
|
|
146
|
+
key: string,
|
|
147
|
+
value: unknown,
|
|
148
|
+
): void {
|
|
149
|
+
Object.defineProperty(obj, key, {
|
|
150
|
+
value,
|
|
151
|
+
enumerable: true,
|
|
152
|
+
writable: true,
|
|
153
|
+
configurable: true,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeQuestionType(raw: unknown): unknown {
|
|
158
|
+
if (typeof raw !== "string") return raw;
|
|
159
|
+
const key = raw
|
|
160
|
+
.trim()
|
|
161
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
162
|
+
.toLowerCase()
|
|
163
|
+
.replace(/[-\s/.]+/g, "_");
|
|
164
|
+
// Map lookup (not a plain object) so inherited names like "__proto__"
|
|
165
|
+
// or "constructor" can never accidentally resolve to an alias.
|
|
166
|
+
return QUESTION_TYPE_ALIASES.get(key) ?? raw;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function pickAlias(
|
|
170
|
+
input: Record<string, unknown>,
|
|
171
|
+
aliases: readonly string[],
|
|
172
|
+
): unknown {
|
|
173
|
+
for (const alias of aliases) {
|
|
174
|
+
if (input[alias] !== undefined) return input[alias];
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Coerce one question into the flat schema shape: canonical `type`,
|
|
181
|
+
* `instructions` / `criteria` resolved through aliases, array-form choice
|
|
182
|
+
* criteria (`["a", "b"]`) folded to `{"a": null, "b": null}`, and
|
|
183
|
+
* naming/junk keys stripped.
|
|
184
|
+
*/
|
|
185
|
+
function normalizeQuestion(input: unknown): unknown {
|
|
186
|
+
if (!isRecord(input)) return input;
|
|
187
|
+
const out: Record<string, unknown> = {};
|
|
188
|
+
if (Object.hasOwn(input, "type"))
|
|
189
|
+
out.type = normalizeQuestionType(input.type);
|
|
190
|
+
const instructions = pickAlias(input, INSTRUCTIONS_ALIASES);
|
|
191
|
+
if (instructions !== undefined) out.instructions = instructions;
|
|
192
|
+
else if (Object.hasOwn(input, "instructions"))
|
|
193
|
+
out.instructions = input.instructions;
|
|
194
|
+
let criteria = pickAlias(input, CRITERIA_ALIASES);
|
|
195
|
+
// `null` is this schema's idiom for "no content"; on noul it reads as
|
|
196
|
+
// "omitted" rather than a degenerate value worth a backend round trip.
|
|
197
|
+
if (out.type === "noul" && criteria === null) criteria = undefined;
|
|
198
|
+
if (
|
|
199
|
+
out.type === "choice" &&
|
|
200
|
+
Array.isArray(criteria) &&
|
|
201
|
+
criteria.every((item) => typeof item === "string")
|
|
202
|
+
) {
|
|
203
|
+
const folded: Record<string, unknown> = {};
|
|
204
|
+
for (const item of criteria as string[]) setOwn(folded, item, null);
|
|
205
|
+
criteria = folded;
|
|
206
|
+
}
|
|
207
|
+
if (criteria !== undefined) out.criteria = criteria;
|
|
208
|
+
// Preserve any other non-junk keys so genuinely new fields still surface
|
|
209
|
+
// in validation instead of being silently dropped.
|
|
210
|
+
for (const [key, value] of Object.entries(input)) {
|
|
211
|
+
if (
|
|
212
|
+
key === "type" ||
|
|
213
|
+
INSTRUCTIONS_ALIASES.includes(key) ||
|
|
214
|
+
CRITERIA_ALIASES.includes(key) ||
|
|
215
|
+
QUESTION_JUNK_KEYS.has(key)
|
|
216
|
+
) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
setOwn(out, key, value);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeQuestions(input: unknown): unknown {
|
|
225
|
+
if (Array.isArray(input)) {
|
|
226
|
+
const record: Record<string, unknown> = {};
|
|
227
|
+
const used = new Set<string>();
|
|
228
|
+
// Duplicate names would silently overwrite; suffix instead so every
|
|
229
|
+
// requested question reaches the backend.
|
|
230
|
+
const claimName = (base: string): string => {
|
|
231
|
+
let candidate = base;
|
|
232
|
+
for (let n = 2; used.has(candidate); n += 1) candidate = `${base}_${n}`;
|
|
233
|
+
used.add(candidate);
|
|
234
|
+
return candidate;
|
|
235
|
+
};
|
|
236
|
+
input.forEach((entry, index) => {
|
|
237
|
+
if (isRecord(entry)) {
|
|
238
|
+
const { name, id, key, ...rest } = entry;
|
|
239
|
+
const rawName = name ?? id ?? key;
|
|
240
|
+
const base =
|
|
241
|
+
typeof rawName === "string" && rawName.trim() !== ""
|
|
242
|
+
? rawName
|
|
243
|
+
: `q${index + 1}`;
|
|
244
|
+
setOwn(record, claimName(base), normalizeQuestion(rest));
|
|
245
|
+
} else {
|
|
246
|
+
setOwn(record, claimName(`q${index + 1}`), entry);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
return record;
|
|
250
|
+
}
|
|
251
|
+
if (isRecord(input)) {
|
|
252
|
+
// Single question sent without a wrapping name (`questions: {type, ...}`)
|
|
253
|
+
// instead of (`questions: {q1: {type, ...}}`). Unwrap on a known kind so
|
|
254
|
+
// a hallucinated extra field still reports against the question (not as
|
|
255
|
+
// phantom questions named "type"/"instructions"); fall back to the
|
|
256
|
+
// vocabulary check for unknown types so those report precisely too. A
|
|
257
|
+
// record that merely contains a question *named* "type" has an object
|
|
258
|
+
// there, never a string, so it is never misread.
|
|
259
|
+
if (typeof input.type === "string") {
|
|
260
|
+
const kind = normalizeQuestionType(input.type);
|
|
261
|
+
const knownKind =
|
|
262
|
+
kind === "choice" || kind === "noul" || kind === "score";
|
|
263
|
+
const keys = Object.keys(input);
|
|
264
|
+
const allQuestionKeys = keys.every(
|
|
265
|
+
(key) =>
|
|
266
|
+
key === "type" ||
|
|
267
|
+
INSTRUCTIONS_ALIASES.includes(key) ||
|
|
268
|
+
CRITERIA_ALIASES.includes(key) ||
|
|
269
|
+
QUESTION_JUNK_KEYS.has(key),
|
|
270
|
+
);
|
|
271
|
+
if (knownKind || allQuestionKeys) return { q: normalizeQuestion(input) };
|
|
272
|
+
}
|
|
273
|
+
const record: Record<string, unknown> = {};
|
|
274
|
+
for (const [name, question] of Object.entries(input)) {
|
|
275
|
+
setOwn(record, name, normalizeQuestion(question));
|
|
276
|
+
}
|
|
277
|
+
return record;
|
|
278
|
+
}
|
|
279
|
+
return input;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Compatibility shim for sloppy tool arguments. Runs before schema
|
|
284
|
+
* validation (wired as `prepareArguments`) and is idempotent, so `execute`
|
|
285
|
+
* re-applies it defensively for direct calls that bypass validation.
|
|
286
|
+
*/
|
|
287
|
+
export function prepareSystemOneArgs(args: unknown): SystemOneParams {
|
|
288
|
+
if (!isRecord(args)) return args as SystemOneParams;
|
|
289
|
+
const out: Record<string, unknown> = {};
|
|
290
|
+
if (args.state !== undefined) {
|
|
291
|
+
out.state = args.state;
|
|
292
|
+
} else if (args.context !== undefined) {
|
|
293
|
+
out.state = args.context;
|
|
294
|
+
} else if (args.evidence !== undefined) {
|
|
295
|
+
out.state = args.evidence;
|
|
296
|
+
}
|
|
297
|
+
const questions =
|
|
298
|
+
args.questions ?? args.question ?? args.queries ?? undefined;
|
|
299
|
+
if (questions !== undefined) out.questions = normalizeQuestions(questions);
|
|
300
|
+
// Drop unknown top-level keys; a missing `state` / `questions` still
|
|
301
|
+
// produces a clear error from semantic validation in execute().
|
|
302
|
+
return out as SystemOneParams;
|
|
303
|
+
}
|
|
304
|
+
|
|
38
305
|
export interface SystemOneDetails {
|
|
39
306
|
answers: Record<string, unknown>;
|
|
40
307
|
model?: string;
|
|
41
|
-
usage?: {
|
|
42
|
-
|
|
308
|
+
usage?: {
|
|
309
|
+
inputTokens?: number;
|
|
310
|
+
outputTokens?: number;
|
|
311
|
+
};
|
|
312
|
+
requestId?: string;
|
|
313
|
+
metadata: {
|
|
314
|
+
provider: string;
|
|
315
|
+
latencyMs?: number;
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Runtime semantic validation: "is this actually a valid System One
|
|
321
|
+
* request?" The public schema lets repairable shapes through; this gate
|
|
322
|
+
* enforces the canonical contract the provider requires. Limits here are
|
|
323
|
+
* the ones the tool itself guarantees (verified against
|
|
324
|
+
* system-one-core, which enforces no option/level counts — the backend
|
|
325
|
+
* is the final arbiter for anything stricter). Every error states what
|
|
326
|
+
* was wrong, which question it was in, and what valid shape looks like.
|
|
327
|
+
*/
|
|
328
|
+
const KNOWN_QUESTION_KEYS = new Set(["type", "instructions", "criteria"]);
|
|
329
|
+
|
|
330
|
+
function assertAnswerableQuestions(questions: unknown): void {
|
|
331
|
+
if (questions === undefined) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
'system_one: questions is required. Provide at least one named question, e.g. {"q1": {"type": "choice", "instructions": "...", "criteria": {"a": null}}}.',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
// The semantic gate is authoritative: anything that is not a question
|
|
337
|
+
// map after normalization is rejected here, never sent to the provider.
|
|
338
|
+
if (!isRecord(questions)) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
'system_one: "questions" must resolve to a question map after normalization, e.g. {"q1": {"type": "choice", "instructions": "...", "criteria": {"a": null}}}.',
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (Object.keys(questions).length === 0) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
"system_one: at least one question is required in questions.",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
for (const [name, value] of Object.entries(questions)) {
|
|
349
|
+
if (!isRecord(value)) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`system_one: question "${name}" must be an object with "type", "instructions", and "criteria".`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
for (const key of Object.keys(value)) {
|
|
355
|
+
if (!KNOWN_QUESTION_KEYS.has(key)) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`system_one: question "${name}" has unknown field "${key}"; use only "type", "instructions", and "criteria".`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (value.instructions === undefined || value.instructions === null) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`system_one: question "${name}" needs "instructions" describing what to evaluate, e.g. {"type": "${typeof value.type === "string" ? value.type : "choice"}", "instructions": "Which option fits best?"}.`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
if (value.type === "choice") {
|
|
367
|
+
if (
|
|
368
|
+
!isRecord(value.criteria) ||
|
|
369
|
+
Object.keys(value.criteria).length === 0
|
|
370
|
+
) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`system_one: choice question "${name}" needs criteria as an object keyed by choice label with at least one choice, e.g. {"a": null, "b": null}.`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
} else if (value.type === "score") {
|
|
376
|
+
if (!Array.isArray(value.criteria) || value.criteria.length < 2) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`system_one: score question "${name}" needs criteria as an ordered array of at least two levels from lowest to highest, e.g. ["low", "high"].`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
} else if (value.type === "noul") {
|
|
382
|
+
if (value.criteria !== undefined && !isRecord(value.criteria)) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`system_one: noul question "${name}" needs criteria omitted or as an object describing the true/false outcomes, e.g. {"true": "...", "false": "..."}.`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
const got =
|
|
389
|
+
typeof value.type === "string" ? `"${value.type}"` : "missing";
|
|
390
|
+
throw new Error(
|
|
391
|
+
`system_one: question "${name}" has unknown type ${got}; use "choice", "noul", or "score".`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
43
395
|
}
|
|
44
396
|
|
|
45
|
-
export function buildSystemOneTool(deps: {
|
|
46
|
-
|
|
47
|
-
|
|
397
|
+
export function buildSystemOneTool(deps: {
|
|
398
|
+
provider: SystemOneProvider;
|
|
399
|
+
}): ToolDefinition<typeof systemOneParams, SystemOneDetails> {
|
|
400
|
+
const systemOne = new SystemOne({
|
|
401
|
+
provider: deps.provider,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return {
|
|
48
405
|
name: "system_one",
|
|
49
406
|
label: "System One",
|
|
407
|
+
|
|
50
408
|
description:
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
409
|
+
"Judge bounded decisions over explicitly supplied state with a dedicated judge model — calibrated probabilities conditional on that state (confidence for choice/score only; noul has no confidence field, its probability is the uncertainty measure). " +
|
|
410
|
+
"Call system_one instead of answering directly when the request is a pick from named options, a yes/no likelihood, or a rubric score over state already in context — the judge's state-conditional probabilities beat guesses from priors. " +
|
|
411
|
+
"The judge only weighs the state you supply: it cannot recall facts or browse, so retrieve evidence first and never ask it to recall facts. " +
|
|
412
|
+
"Do not use it for open-ended text generation or factual lookup. " +
|
|
413
|
+
"Questions that share the same state can be evaluated together in one call. " +
|
|
414
|
+
'Each question is {"type": "choice" | "noul" | "score", "instructions": "...", "criteria": ...}: ' +
|
|
415
|
+
'choice criteria is an object keyed by choice label ({"a": null, "b": null}), ' +
|
|
416
|
+
"noul usually omits criteria, " +
|
|
417
|
+
"score criteria is an ordered array of at least two levels.",
|
|
418
|
+
|
|
54
419
|
parameters: systemOneParams,
|
|
420
|
+
|
|
55
421
|
promptSnippet:
|
|
56
|
-
"
|
|
422
|
+
"Use system_one for bounded choice, yes/no, and ordered-scale judgments over supplied evidence",
|
|
423
|
+
|
|
57
424
|
promptGuidelines: [
|
|
58
|
-
"
|
|
59
|
-
"
|
|
425
|
+
"Before answering a bounded judgment over supplied or retrieved evidence, call system_one instead of making the judgment yourself. A bounded judgment is: choosing among explicit alternatives, making a yes/no judgment, or rating something on an ordered scale.",
|
|
426
|
+
"Pick the system_one question type mechanically: unordered alternatives (frontend, backend, platform) -> choice; yes/no -> noul; ordered scale, rating, severity, risk, or grade -> score. Never use choice for an ordered scale merely because its levels have names: very low / low / moderate / high / very high is score, not choice.",
|
|
427
|
+
"Retrieve missing factual evidence into state first; never ask system_one to recall facts or browse. Use system_one for bounded judgments over available evidence, not as a replacement for factual lookup.",
|
|
428
|
+
"Batch independent system_one questions sharing the same state into one call instead of one call per question.",
|
|
429
|
+
"For system_one choice questions, the keys of criteria are the available choices — preserve user-specified labels and use null values when labels are self-explanatory; never add a separate options field.",
|
|
60
430
|
],
|
|
61
431
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
432
|
+
prepareArguments: (args) => prepareSystemOneArgs(args),
|
|
433
|
+
|
|
434
|
+
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
|
435
|
+
/*
|
|
436
|
+
* The LLM-facing TypeBox schema is intentionally separate from the
|
|
437
|
+
* provider protocol types. Cast only at this boundary instead of
|
|
438
|
+
* spreading `any` throughout the implementation.
|
|
439
|
+
*
|
|
440
|
+
* `prepareArguments` above is an optimization for newer Pi only: it
|
|
441
|
+
* runs before schema validation there, but older Pi versions ignore
|
|
442
|
+
* it and validate the raw model output against the permissive
|
|
443
|
+
* schema instead. Correctness never depends on it — re-normalize
|
|
444
|
+
* here so every path (new Pi, old Pi, direct calls) converges on
|
|
445
|
+
* the same canonical shape before semantic validation.
|
|
446
|
+
*/
|
|
447
|
+
const normalized = prepareSystemOneArgs(params);
|
|
448
|
+
// prepareSystemOneArgs passes non-objects through untouched; direct
|
|
449
|
+
// calls bypassing Pi validation must get an actionable error, not a
|
|
450
|
+
// TypeError from property access on null.
|
|
451
|
+
if (!isRecord(normalized)) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
'system_one: request must be an object with "state" and "questions".',
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
if (normalized.state === undefined) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
'system_one: state is required. Put the material under judgment in "state" (string or object), e.g. {"state": "<evidence>", "questions": {...}}.',
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
assertAnswerableQuestions(normalized.questions);
|
|
66
462
|
const response = await systemOne.evaluate(
|
|
67
463
|
{
|
|
68
|
-
state:
|
|
69
|
-
questions:
|
|
70
|
-
...(params.model ? { model: params.model } : {}),
|
|
464
|
+
state: normalized.state as SystemOneState,
|
|
465
|
+
questions: normalized.questions as QuestionMap,
|
|
71
466
|
},
|
|
72
467
|
{ signal },
|
|
73
468
|
);
|
|
74
|
-
|
|
469
|
+
|
|
75
470
|
return {
|
|
76
|
-
content: [
|
|
471
|
+
content: [
|
|
472
|
+
{
|
|
473
|
+
type: "text" as const,
|
|
474
|
+
text: renderSystemOneResult(response),
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
|
|
77
478
|
details: {
|
|
78
479
|
answers: response.answers,
|
|
79
480
|
model: response.model,
|
|
80
481
|
usage: response.usage,
|
|
482
|
+
requestId: response.requestId,
|
|
81
483
|
metadata: response.metadata,
|
|
82
484
|
},
|
|
83
485
|
};
|
|
84
486
|
},
|
|
85
487
|
};
|
|
86
|
-
return tool;
|
|
87
488
|
}
|
package/tests/commands.test.ts
CHANGED
|
@@ -109,8 +109,13 @@ describe("so command", () => {
|
|
|
109
109
|
await cap.handler?.("", ctxFor(["", "", "sk-mem", ""], cap));
|
|
110
110
|
assert.equal(store.session?.keyInMemory, true);
|
|
111
111
|
await cap.handler?.("", ctxFor(["", "", "-", ""], cap));
|
|
112
|
+
// After forgetting the memory key, the API key should revert to the environment variable (if any).
|
|
113
|
+
// In the CI environment, SYSTEM_ONE_API_KEY may be set; otherwise it will be undefined.
|
|
112
114
|
assert.equal(store.session?.keyInMemory, false);
|
|
113
|
-
assert.equal(
|
|
115
|
+
assert.equal(
|
|
116
|
+
store.session?.current.apiKey,
|
|
117
|
+
process.env.SYSTEM_ONE_API_KEY || undefined,
|
|
118
|
+
);
|
|
114
119
|
});
|
|
115
120
|
it("status shows current config; STATUS routes case-insensitively", async () => {
|
|
116
121
|
const { pi, cap } = harness();
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// pi-system-one/tests/extension.test.ts
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { describe, it } from "node:test";
|
|
7
|
+
import piSystemOneExtension, {
|
|
8
|
+
systemOnePromptsDir,
|
|
9
|
+
systemOneSkillsDir,
|
|
10
|
+
} from "../src/extension.ts";
|
|
11
|
+
|
|
12
|
+
describe("extension resources", () => {
|
|
13
|
+
it("contributes a valid system-one skill directory", async () => {
|
|
14
|
+
const prev = process.env.SYSTEM_ONE_BASE_URL;
|
|
15
|
+
process.env.SYSTEM_ONE_BASE_URL = "http://localhost:8008";
|
|
16
|
+
try {
|
|
17
|
+
const handlers: Record<string, (event: never) => unknown> = {};
|
|
18
|
+
const pi = {
|
|
19
|
+
registerTool: () => {},
|
|
20
|
+
registerCommand: () => {},
|
|
21
|
+
on: (channel: string, handler: (event: never) => unknown) => {
|
|
22
|
+
handlers[channel] = handler;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
piSystemOneExtension(pi as never);
|
|
26
|
+
const discover = handlers.resources_discover;
|
|
27
|
+
assert.ok(discover, "resources_discover handler registered");
|
|
28
|
+
const result = (await discover({} as never)) as {
|
|
29
|
+
skillPaths: string[];
|
|
30
|
+
};
|
|
31
|
+
assert.equal(result.skillPaths.length, 1);
|
|
32
|
+
assert.equal(result.skillPaths[0], systemOneSkillsDir());
|
|
33
|
+
const skillMd = join(result.skillPaths[0], "system-one", "SKILL.md");
|
|
34
|
+
assert.ok(existsSync(skillMd), `missing ${skillMd}`);
|
|
35
|
+
const raw = readFileSync(skillMd, "utf-8");
|
|
36
|
+
const frontmatter = raw.split("---")[1] ?? "";
|
|
37
|
+
// YAML forbids `: ` inside unquoted plain scalars (it starts a nested
|
|
38
|
+
// mapping — Pi rejects the skill). Every multi-word value must be
|
|
39
|
+
// quoted or use a block scalar.
|
|
40
|
+
for (const line of frontmatter.split("\n")) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
43
|
+
const value = trimmed.replace(/^\w+:\s*/, "");
|
|
44
|
+
const quoted =
|
|
45
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
46
|
+
(value.startsWith("'") && value.endsWith("'")) ||
|
|
47
|
+
value.startsWith("|") ||
|
|
48
|
+
value.startsWith(">");
|
|
49
|
+
assert.ok(
|
|
50
|
+
quoted || !value.includes(": "),
|
|
51
|
+
`unquoted ": " in SKILL.md frontmatter: ${trimmed}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const stripQuotes = (s: string) =>
|
|
55
|
+
s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
|
|
56
|
+
const name = stripQuotes(
|
|
57
|
+
frontmatter.match(/name:\s*(.+)/)?.[1]?.trim() ?? "",
|
|
58
|
+
);
|
|
59
|
+
const description = stripQuotes(
|
|
60
|
+
frontmatter.match(/description:\s*(.+)/)?.[1]?.trim() ?? "",
|
|
61
|
+
);
|
|
62
|
+
// Pi's skill validation rules (docs/skills.md): name 1-64 chars,
|
|
63
|
+
// lowercase alphanumerics/hyphens, no leading/trailing or consecutive
|
|
64
|
+
// hyphens; description required, max 1024 chars.
|
|
65
|
+
assert.match(name, /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/);
|
|
66
|
+
assert.ok(name.length >= 1 && name.length <= 64);
|
|
67
|
+
assert.ok(!name.includes("--"));
|
|
68
|
+
assert.ok(description.length >= 1 && description.length <= 1024);
|
|
69
|
+
assert.match(description, /system_one|System One/);
|
|
70
|
+
assert.ok(
|
|
71
|
+
existsSync(
|
|
72
|
+
join(
|
|
73
|
+
result.skillPaths[0],
|
|
74
|
+
"system-one",
|
|
75
|
+
"references",
|
|
76
|
+
"use-cases.md",
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
"missing use-cases reference",
|
|
80
|
+
);
|
|
81
|
+
} finally {
|
|
82
|
+
if (prev === undefined) delete process.env.SYSTEM_ONE_BASE_URL;
|
|
83
|
+
else process.env.SYSTEM_ONE_BASE_URL = prev;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("contributes a valid /judge prompt template", async () => {
|
|
88
|
+
const prev = process.env.SYSTEM_ONE_BASE_URL;
|
|
89
|
+
process.env.SYSTEM_ONE_BASE_URL = "http://localhost:8008";
|
|
90
|
+
try {
|
|
91
|
+
const handlers: Record<string, (event: never) => unknown> = {};
|
|
92
|
+
const pi = {
|
|
93
|
+
registerTool: () => {},
|
|
94
|
+
registerCommand: () => {},
|
|
95
|
+
on: (channel: string, handler: (event: never) => unknown) => {
|
|
96
|
+
handlers[channel] = handler;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
piSystemOneExtension(pi as never);
|
|
100
|
+
const discover = handlers.resources_discover;
|
|
101
|
+
assert.ok(discover, "resources_discover handler registered");
|
|
102
|
+
const result = (await discover({} as never)) as {
|
|
103
|
+
promptPaths: string[];
|
|
104
|
+
};
|
|
105
|
+
assert.equal(result.promptPaths.length, 1);
|
|
106
|
+
assert.equal(result.promptPaths[0], systemOnePromptsDir());
|
|
107
|
+
const judgeMd = join(result.promptPaths[0], "judge.md");
|
|
108
|
+
assert.ok(existsSync(judgeMd), `missing ${judgeMd}`);
|
|
109
|
+
const content = readFileSync(judgeMd, "utf-8");
|
|
110
|
+
// Prompt templates take `$@` args and must name the tool explicitly:
|
|
111
|
+
// that explicit naming is what makes routing deterministic.
|
|
112
|
+
assert.match(content, /\$@/);
|
|
113
|
+
assert.match(content, /system_one/);
|
|
114
|
+
assert.match(content, /do not\s+answer it directly/i);
|
|
115
|
+
// noul answers carry no confidence field; the template must not ask
|
|
116
|
+
// the model to report one or it will invent numbers.
|
|
117
|
+
assert.match(content, /for noul the probability/i);
|
|
118
|
+
} finally {
|
|
119
|
+
if (prev === undefined) delete process.env.SYSTEM_ONE_BASE_URL;
|
|
120
|
+
else process.env.SYSTEM_ONE_BASE_URL = prev;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|